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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Adapter registry and the live-session identification algorithm."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
from ..model import PUSH_MARKER_RE
|
|
9
|
+
from .base import Adapter, SessionRef, recent
|
|
10
|
+
from .claude_code import ClaudeCodeAdapter
|
|
11
|
+
|
|
12
|
+
REGISTRY: Dict[str, Adapter] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(adapter: Adapter) -> None:
|
|
16
|
+
REGISTRY[adapter.name] = adapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
register(ClaudeCodeAdapter())
|
|
20
|
+
|
|
21
|
+
try: # optional adapters are added as they land; a broken import must not take the tool down
|
|
22
|
+
from .codex import CodexAdapter
|
|
23
|
+
register(CodexAdapter())
|
|
24
|
+
except ImportError: # pragma: no cover
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
from .opencode import OpenCodeAdapter
|
|
28
|
+
register(OpenCodeAdapter())
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
pass
|
|
31
|
+
try:
|
|
32
|
+
from .gemini import GeminiAdapter
|
|
33
|
+
register(GeminiAdapter())
|
|
34
|
+
except ImportError: # pragma: no cover
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
AGENT_CHOICES = ["auto"] + list(REGISTRY.keys())
|
|
38
|
+
RECENT_MINUTES = 15
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Resolution:
|
|
42
|
+
def __init__(self, ref: Optional[SessionRef], how: str, candidates: List[SessionRef], adapter: Optional[Adapter]) -> None:
|
|
43
|
+
self.ref, self.how, self.candidates, self.adapter = ref, how, candidates, adapter
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def installed_adapters(agent: str = "auto") -> List[Adapter]:
|
|
47
|
+
if agent != "auto":
|
|
48
|
+
if agent not in REGISTRY:
|
|
49
|
+
raise KeyError(agent)
|
|
50
|
+
return [REGISTRY[agent]]
|
|
51
|
+
return [a for a in REGISTRY.values() if a.installed()]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve(agent: str, session_id: Optional[str], repo_root: str) -> Resolution:
|
|
55
|
+
"""Find the live session. Never picks silently between several plausible ones."""
|
|
56
|
+
adapters = installed_adapters(agent)
|
|
57
|
+
if session_id:
|
|
58
|
+
for a in adapters:
|
|
59
|
+
ref = a.locate_by_id(session_id)
|
|
60
|
+
if ref:
|
|
61
|
+
return Resolution(ref, "session id", [], a)
|
|
62
|
+
# known id, no data yet: fresh session. Pick the adapter the environment proves, else the explicit one.
|
|
63
|
+
owner = REGISTRY[agent] if agent != "auto" else _env_agent(adapters)
|
|
64
|
+
if owner:
|
|
65
|
+
return Resolution(owner.pending_ref(session_id), "not written yet", [], owner)
|
|
66
|
+
return Resolution(None, "not found", [], None)
|
|
67
|
+
|
|
68
|
+
for a in adapters:
|
|
69
|
+
env_id = a.in_env()
|
|
70
|
+
if env_id:
|
|
71
|
+
ref = a.locate_by_id(env_id)
|
|
72
|
+
if ref:
|
|
73
|
+
return Resolution(ref, f"environment ({a.name})", [], a)
|
|
74
|
+
return Resolution(a.pending_ref(env_id), "not written yet", [], a)
|
|
75
|
+
|
|
76
|
+
all_cands: List[Tuple[Adapter, SessionRef]] = []
|
|
77
|
+
for a in adapters:
|
|
78
|
+
try:
|
|
79
|
+
for ref in a.candidates(repo_root, limit=5):
|
|
80
|
+
all_cands.append((a, ref))
|
|
81
|
+
except Exception: # noqa: BLE001 - one broken store must not hide the others
|
|
82
|
+
continue
|
|
83
|
+
all_cands.sort(key=lambda ar: ar[1].updated.timestamp() if ar[1].updated else 0, reverse=True)
|
|
84
|
+
|
|
85
|
+
# self-referencing locate call still in flight
|
|
86
|
+
hits = []
|
|
87
|
+
for a, ref in all_cands:
|
|
88
|
+
if not recent(ref, RECENT_MINUTES):
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
sess = a.load(ref)
|
|
92
|
+
except Exception: # noqa: BLE001
|
|
93
|
+
continue
|
|
94
|
+
ref.extra["session"] = sess
|
|
95
|
+
ref.title = ref.title or sess.title
|
|
96
|
+
if sess.has_pending_self_call():
|
|
97
|
+
hits.append((a, ref))
|
|
98
|
+
if len(hits) == 1:
|
|
99
|
+
return Resolution(hits[0][1], "self-referencing locate call", [], hits[0][0])
|
|
100
|
+
|
|
101
|
+
# push marker in the latest user turn
|
|
102
|
+
marks = []
|
|
103
|
+
for a, ref in all_cands:
|
|
104
|
+
sess = ref.extra.get("session")
|
|
105
|
+
if sess is None:
|
|
106
|
+
try:
|
|
107
|
+
sess = a.load(ref)
|
|
108
|
+
ref.extra["session"] = sess
|
|
109
|
+
ref.title = ref.title or sess.title
|
|
110
|
+
except Exception: # noqa: BLE001
|
|
111
|
+
continue
|
|
112
|
+
if PUSH_MARKER_RE.search(sess.last_user_text() or "") or any(e.is_push_invocation for e in sess.events[-3:]):
|
|
113
|
+
marks.append((a, ref))
|
|
114
|
+
if len(marks) == 1:
|
|
115
|
+
return Resolution(marks[0][1], "push marker in latest user turn", [], marks[0][0])
|
|
116
|
+
|
|
117
|
+
if len(all_cands) == 1:
|
|
118
|
+
a, ref = all_cands[0]
|
|
119
|
+
how = "only recent session for this repo" if recent(ref, RECENT_MINUTES) else "only session for this repo (not recent; check it is the right one)"
|
|
120
|
+
return Resolution(ref, how, [], a)
|
|
121
|
+
if not all_cands:
|
|
122
|
+
return Resolution(None, "not found", [], None)
|
|
123
|
+
return Resolution(None, "ambiguous", [ref for _, ref in all_cands], None)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _env_agent(adapters: List[Adapter]) -> Optional[Adapter]:
|
|
127
|
+
for a in adapters:
|
|
128
|
+
if a.in_env() is not None:
|
|
129
|
+
return a
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def adapter_for(agent: str) -> Adapter:
|
|
134
|
+
if agent not in REGISTRY:
|
|
135
|
+
raise KeyError(agent)
|
|
136
|
+
return REGISTRY[agent]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Adapter contract shared by every agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ..model import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SessionRef:
|
|
15
|
+
agent: str
|
|
16
|
+
session_id: str
|
|
17
|
+
short_id: str
|
|
18
|
+
source: str # file path or "sqlite:<db>#<id>"; "" when not on disk yet
|
|
19
|
+
cwd: Optional[str] = None
|
|
20
|
+
updated: Optional[dt.datetime] = None
|
|
21
|
+
title: Optional[str] = None
|
|
22
|
+
extra: dict = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def on_disk(self) -> bool:
|
|
26
|
+
return bool(self.source)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Adapter:
|
|
30
|
+
name = "base"
|
|
31
|
+
label = "Base"
|
|
32
|
+
env_ids: Tuple[str, ...] = () # env vars carrying the session id
|
|
33
|
+
env_presence: Tuple[str, ...] = () # env vars that merely prove we run inside this agent
|
|
34
|
+
tested_version: Optional[Tuple[int, ...]] = None
|
|
35
|
+
|
|
36
|
+
def data_dir(self) -> str:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
|
|
39
|
+
def installed(self) -> bool:
|
|
40
|
+
return os.path.exists(self.data_dir())
|
|
41
|
+
|
|
42
|
+
def short_id(self, session_id: str) -> str:
|
|
43
|
+
return session_id[:8]
|
|
44
|
+
|
|
45
|
+
def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def load(self, ref: SessionRef) -> Session:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def ref_from_source(self, source: str) -> Optional[SessionRef]:
|
|
55
|
+
"""Build a ref from a SOURCE string this adapter printed earlier, or None if it is not ours."""
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def pending_ref(self, session_id: str) -> SessionRef:
|
|
59
|
+
"""A session we know the id of but that has no data on disk yet."""
|
|
60
|
+
return SessionRef(self.name, session_id, self.short_id(session_id), "")
|
|
61
|
+
|
|
62
|
+
def in_env(self) -> Optional[str]:
|
|
63
|
+
"""Session id from the environment, or "" if the environment proves the agent but not the id."""
|
|
64
|
+
for var in self.env_ids:
|
|
65
|
+
val = os.environ.get(var, "").strip()
|
|
66
|
+
if val and not val.startswith("${"):
|
|
67
|
+
return val
|
|
68
|
+
for var in self.env_presence:
|
|
69
|
+
if os.environ.get(var):
|
|
70
|
+
return ""
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generic_label(name: str, kind: str, inp: object, paths: List[str]) -> str:
|
|
75
|
+
"""Default one-line label. Built from full strings; the renderer redacts then truncates."""
|
|
76
|
+
d = inp if isinstance(inp, dict) else {}
|
|
77
|
+
if kind == "shell":
|
|
78
|
+
cmd = d.get("command") or d.get("cmd") or (inp if isinstance(inp, str) else "")
|
|
79
|
+
if isinstance(cmd, list):
|
|
80
|
+
cmd = "; ".join(str(c) for c in cmd)
|
|
81
|
+
desc = str(d.get("description") or "").strip()
|
|
82
|
+
return f"{name}: {desc or str(cmd).strip().split(chr(10))[0]}"
|
|
83
|
+
if kind in ("read", "edit", "write"):
|
|
84
|
+
target = paths[0] if paths else (d.get("filePath") or d.get("file_path") or d.get("path") or "")
|
|
85
|
+
return f"{name}: {target}"
|
|
86
|
+
if kind == "agent":
|
|
87
|
+
return f"{name}: {str(d.get('description') or d.get('prompt') or '')}"
|
|
88
|
+
if kind == "search":
|
|
89
|
+
return f"{name}: {str(d.get('pattern') or d.get('query') or d.get('path') or '')}"
|
|
90
|
+
if kind == "web":
|
|
91
|
+
return f"{name}: {str(d.get('url') or d.get('query') or d.get('prompt') or '')}"
|
|
92
|
+
if kind == "ask":
|
|
93
|
+
return f"{name}"
|
|
94
|
+
return name
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def strip_wrappers(text: str, tags: Tuple[str, ...]) -> str:
|
|
98
|
+
"""Remove <tag ...>…</tag> blocks the harness injects around user text."""
|
|
99
|
+
import re
|
|
100
|
+
for tag in tags:
|
|
101
|
+
text = re.sub(rf"<{tag}\b[^>]*>[\s\S]*?</{tag}>", "", text, flags=re.I)
|
|
102
|
+
return text.strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def recent(ref: SessionRef, minutes: int) -> bool:
|
|
106
|
+
if not ref.updated:
|
|
107
|
+
return True
|
|
108
|
+
return (dt.datetime.now(dt.timezone.utc) - ref.updated) <= dt.timedelta(minutes=minutes)
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Claude Code: ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from ..core import config_dir, first_json_line, mtime, parse_ts, read_jsonl, rel_to_root, under, home_to_tilde
|
|
11
|
+
from ..model import Event, PUSH_MARKER_RE, Session, ToolCall
|
|
12
|
+
from .base import Adapter, SessionRef
|
|
13
|
+
|
|
14
|
+
KIND_BY_NAME = {
|
|
15
|
+
"Bash": "shell", "Read": "read", "Edit": "edit", "MultiEdit": "edit", "NotebookEdit": "edit", "Write": "write",
|
|
16
|
+
"Grep": "search", "Glob": "search", "WebFetch": "web", "WebSearch": "web", "Agent": "agent", "Task": "agent",
|
|
17
|
+
"AskUserQuestion": "ask", "ReadMcpResourceTool": "mcp", "ReadMcpResourceDirTool": "mcp",
|
|
18
|
+
}
|
|
19
|
+
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
20
|
+
|
|
21
|
+
SYSTEM_NOISE_RE = re.compile(
|
|
22
|
+
r"<system-reminder>[\s\S]*?</system-reminder>|<local-command-caveat>[\s\S]*?</local-command-caveat>"
|
|
23
|
+
r"|<local-command-stdout>[\s\S]*?</local-command-stdout>|<command-message>[\s\S]*?</command-message>",
|
|
24
|
+
re.I,
|
|
25
|
+
)
|
|
26
|
+
COMMAND_NAME_RE = re.compile(r"<command-name>\s*([^<]*?)\s*</command-name>", re.I)
|
|
27
|
+
COMMAND_ARGS_RE = re.compile(r"<command-args>\s*([^<]*?)\s*</command-args>", re.I)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def blocks_of(message: Optional[dict]) -> List[dict]:
|
|
31
|
+
if not isinstance(message, dict):
|
|
32
|
+
return []
|
|
33
|
+
content = message.get("content")
|
|
34
|
+
if isinstance(content, str):
|
|
35
|
+
return [{"type": "text", "text": content}]
|
|
36
|
+
if isinstance(content, list):
|
|
37
|
+
return [b for b in content if isinstance(b, dict)]
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def result_text(content: object) -> str:
|
|
42
|
+
if isinstance(content, str):
|
|
43
|
+
return content
|
|
44
|
+
if isinstance(content, list):
|
|
45
|
+
parts = []
|
|
46
|
+
for b in content:
|
|
47
|
+
if not isinstance(b, dict):
|
|
48
|
+
continue
|
|
49
|
+
t = b.get("type")
|
|
50
|
+
if t == "text":
|
|
51
|
+
parts.append(str(b.get("text", "")))
|
|
52
|
+
elif t == "tool_reference":
|
|
53
|
+
parts.append(f"[tool reference: {b.get('tool_name', '?')}]")
|
|
54
|
+
elif t == "image":
|
|
55
|
+
parts.append("[image omitted]")
|
|
56
|
+
else:
|
|
57
|
+
parts.append(f"[unsupported block: {t}]")
|
|
58
|
+
return "\n".join(parts)
|
|
59
|
+
if content is None:
|
|
60
|
+
return ""
|
|
61
|
+
return str(content)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def clean_user_text(text: str) -> Tuple[str, Optional[str]]:
|
|
65
|
+
m = COMMAND_NAME_RE.search(text)
|
|
66
|
+
if m:
|
|
67
|
+
name = m.group(1).strip()
|
|
68
|
+
am = COMMAND_ARGS_RE.search(text)
|
|
69
|
+
args = am.group(1).strip() if am else ""
|
|
70
|
+
return "", (name + (" " + args if args else "")).strip()
|
|
71
|
+
return SYSTEM_NOISE_RE.sub("", text).strip(), None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def tool_label(name: str, inp: dict) -> str:
|
|
75
|
+
"""Labels are built from full strings; the renderer redacts first and truncates last."""
|
|
76
|
+
if name == "Bash":
|
|
77
|
+
desc = str(inp.get("description") or "").strip()
|
|
78
|
+
cmd = str(inp.get("command") or "").strip().split("\n")[0]
|
|
79
|
+
return f"Bash: {desc or cmd}"
|
|
80
|
+
if name == "Read":
|
|
81
|
+
return f"Read: {home_to_tilde(str(inp.get('file_path', '')))}"
|
|
82
|
+
if name in EDIT_TOOLS:
|
|
83
|
+
return f"{name}: {home_to_tilde(str(inp.get('file_path') or inp.get('notebook_path') or ''))}"
|
|
84
|
+
if name in ("Agent", "Task"):
|
|
85
|
+
return f"Agent: {str(inp.get('description') or '')[:80]}"
|
|
86
|
+
if name.startswith("mcp__"):
|
|
87
|
+
parts = name.split("__")
|
|
88
|
+
return f"MCP {parts[1] if len(parts) > 1 else '?'}: {parts[-1]}"
|
|
89
|
+
if name in ("Grep", "Glob"):
|
|
90
|
+
return f"{name}: {str(inp.get('pattern', ''))[:80]}"
|
|
91
|
+
if name in ("WebFetch", "WebSearch"):
|
|
92
|
+
return f"{name}: {str(inp.get('url') or inp.get('query') or '')[:80]}"
|
|
93
|
+
return name
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ClaudeCodeAdapter(Adapter):
|
|
97
|
+
name = "claude-code"
|
|
98
|
+
label = "Claude Code"
|
|
99
|
+
env_ids = ("CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID")
|
|
100
|
+
env_presence = ("CLAUDECODE",)
|
|
101
|
+
tested_version = (2, 1, 278)
|
|
102
|
+
|
|
103
|
+
def data_dir(self) -> str:
|
|
104
|
+
return os.path.join(config_dir(), "projects")
|
|
105
|
+
|
|
106
|
+
def locate_by_id(self, session_id: str) -> Optional[SessionRef]:
|
|
107
|
+
hits = glob.glob(os.path.join(self.data_dir(), "*", f"{session_id}.jsonl"))
|
|
108
|
+
if not hits:
|
|
109
|
+
return None
|
|
110
|
+
hits.sort(key=os.path.getmtime, reverse=True)
|
|
111
|
+
return self._ref(hits[0])
|
|
112
|
+
|
|
113
|
+
def ref_from_source(self, source: str) -> Optional[SessionRef]:
|
|
114
|
+
if source.endswith(".jsonl") and os.path.isfile(source):
|
|
115
|
+
return self._ref(os.path.abspath(source))
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def _ref(self, path: str) -> SessionRef:
|
|
119
|
+
sid = os.path.basename(path)[:-6]
|
|
120
|
+
first = first_json_line(path)
|
|
121
|
+
cwd = None
|
|
122
|
+
if first and isinstance(first.get("cwd"), str):
|
|
123
|
+
cwd = first["cwd"]
|
|
124
|
+
else:
|
|
125
|
+
# cwd lives on the first user/assistant record; peek a few lines
|
|
126
|
+
try:
|
|
127
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
128
|
+
for _ in range(40):
|
|
129
|
+
line = fh.readline()
|
|
130
|
+
if not line:
|
|
131
|
+
break
|
|
132
|
+
if '"cwd"' in line:
|
|
133
|
+
import json
|
|
134
|
+
try:
|
|
135
|
+
obj = json.loads(line)
|
|
136
|
+
if isinstance(obj.get("cwd"), str):
|
|
137
|
+
cwd = obj["cwd"]
|
|
138
|
+
break
|
|
139
|
+
except ValueError:
|
|
140
|
+
continue
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
return SessionRef(self.name, sid, self.short_id(sid), path, cwd=cwd, updated=mtime(path))
|
|
144
|
+
|
|
145
|
+
def candidates(self, repo_root: str, limit: int = 5) -> List[SessionRef]:
|
|
146
|
+
paths = sorted(glob.glob(os.path.join(self.data_dir(), "*", "*.jsonl")), key=os.path.getmtime, reverse=True)[:80]
|
|
147
|
+
out: List[SessionRef] = []
|
|
148
|
+
for p in paths:
|
|
149
|
+
ref = self._ref(p)
|
|
150
|
+
if under(ref.cwd, repo_root):
|
|
151
|
+
out.append(ref)
|
|
152
|
+
if len(out) >= limit:
|
|
153
|
+
break
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
# -- loading
|
|
157
|
+
|
|
158
|
+
def load(self, ref: SessionRef) -> Session:
|
|
159
|
+
records, bad = read_jsonl(ref.source)
|
|
160
|
+
by_uuid: Dict[str, dict] = {r["uuid"]: r for r in records if isinstance(r.get("uuid"), str)}
|
|
161
|
+
path = _active_path(records, by_uuid)
|
|
162
|
+
results: Dict[str, Tuple[str, bool]] = {}
|
|
163
|
+
for rec in path:
|
|
164
|
+
if rec.get("type") == "user":
|
|
165
|
+
for b in blocks_of(rec.get("message")):
|
|
166
|
+
if b.get("type") == "tool_result" and isinstance(b.get("tool_use_id"), str):
|
|
167
|
+
results[b["tool_use_id"]] = (result_text(b.get("content")), bool(b.get("is_error")))
|
|
168
|
+
events: List[Event] = []
|
|
169
|
+
for rec in path:
|
|
170
|
+
for ev in _events_from_record(rec, results, ref):
|
|
171
|
+
# the compaction summary follows its boundary; fold it into one event
|
|
172
|
+
if ev.kind == "compaction" and ev.meta.get("summary_only") and events and events[-1].kind == "compaction" and not events[-1].text:
|
|
173
|
+
events[-1].text = ev.text
|
|
174
|
+
continue
|
|
175
|
+
events.append(ev)
|
|
176
|
+
sidecars = self._subagent_files(ref)
|
|
177
|
+
for sub in sidecars:
|
|
178
|
+
sub_records, _ = read_jsonl(sub)
|
|
179
|
+
for rec in sub_records:
|
|
180
|
+
if rec.get("type") != "assistant":
|
|
181
|
+
continue
|
|
182
|
+
for b in blocks_of(rec.get("message")):
|
|
183
|
+
if b.get("type") == "tool_use" and b.get("name") in EDIT_TOOLS:
|
|
184
|
+
inp = b.get("input") if isinstance(b.get("input"), dict) else {}
|
|
185
|
+
p = inp.get("file_path") or inp.get("notebook_path")
|
|
186
|
+
if isinstance(p, str) and p:
|
|
187
|
+
events.append(Event("tool", tool=ToolCall("edit", str(b.get("name")), f"{b.get('name')}: {home_to_tilde(p)} (subagent)",
|
|
188
|
+
input={}, paths=[rel_to_root(p, _repo_hint(ref))]), meta={"subagent": True, "hidden": True}))
|
|
189
|
+
title = None
|
|
190
|
+
for rec in records:
|
|
191
|
+
if rec.get("type") == "custom-title" and isinstance(rec.get("customTitle"), str):
|
|
192
|
+
title = rec["customTitle"].strip()
|
|
193
|
+
if not title:
|
|
194
|
+
for rec in records:
|
|
195
|
+
if rec.get("type") == "ai-title" and isinstance(rec.get("aiTitle"), str):
|
|
196
|
+
title = rec["aiTitle"].strip()
|
|
197
|
+
stamps = [parse_ts(r.get("timestamp")) for r in records if r.get("type") in ("user", "assistant")]
|
|
198
|
+
stamps = [s for s in stamps if s]
|
|
199
|
+
version = next((r["version"] for r in records if isinstance(r.get("version"), str)), None)
|
|
200
|
+
visible = [e for e in events if not e.meta.get("hidden")]
|
|
201
|
+
hidden = [e for e in events if e.meta.get("hidden")]
|
|
202
|
+
return Session(
|
|
203
|
+
agent=self.name, session_id=ref.session_id, short_id=ref.short_id, title=title, cwd=ref.cwd,
|
|
204
|
+
started=min(stamps) if stamps else None, ended=max(stamps) if stamps else None,
|
|
205
|
+
events=visible + hidden, source=ref.source, agent_version=version, subagent_files=len(sidecars),
|
|
206
|
+
tested_version=self.tested_version, record_count=len(records), bad_lines=bad,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _subagent_files(self, ref: SessionRef) -> List[str]:
|
|
210
|
+
side = os.path.join(os.path.dirname(ref.source), ref.session_id, "subagents")
|
|
211
|
+
return sorted(glob.glob(os.path.join(side, "*.jsonl")))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _repo_hint(ref: SessionRef) -> Optional[str]:
|
|
215
|
+
return ref.extra.get("repo_root") or ref.cwd
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _active_path(records: List[dict], by_uuid: Dict[str, dict]) -> List[dict]:
|
|
219
|
+
leaf = None
|
|
220
|
+
for rec in reversed(records):
|
|
221
|
+
if rec.get("type") in ("user", "assistant") and not rec.get("isSidechain") and rec.get("uuid") in by_uuid:
|
|
222
|
+
leaf = rec
|
|
223
|
+
break
|
|
224
|
+
if leaf is None:
|
|
225
|
+
return [r for r in records if r.get("type") in ("user", "assistant", "system") and not r.get("isSidechain")]
|
|
226
|
+
path: List[dict] = []
|
|
227
|
+
seen = set()
|
|
228
|
+
cur: Optional[dict] = leaf
|
|
229
|
+
while cur is not None and cur.get("uuid") not in seen:
|
|
230
|
+
seen.add(cur.get("uuid"))
|
|
231
|
+
path.append(cur)
|
|
232
|
+
parent = cur.get("parentUuid")
|
|
233
|
+
if parent is None and cur.get("type") == "system" and cur.get("subtype") == "compact_boundary":
|
|
234
|
+
parent = cur.get("logicalParentUuid")
|
|
235
|
+
cur = by_uuid.get(parent) if isinstance(parent, str) else None
|
|
236
|
+
path.reverse()
|
|
237
|
+
return path
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _events_from_record(rec: dict, results: Dict[str, Tuple[str, bool]], ref: SessionRef) -> List[Event]:
|
|
241
|
+
rtype = rec.get("type")
|
|
242
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
243
|
+
out: List[Event] = []
|
|
244
|
+
if rtype == "system":
|
|
245
|
+
if rec.get("subtype") == "compact_boundary":
|
|
246
|
+
meta = rec.get("compactMetadata") if isinstance(rec.get("compactMetadata"), dict) else {}
|
|
247
|
+
out.append(Event("compaction", ts=ts, meta={"pre_tokens": meta.get("preTokens"), "post_tokens": meta.get("postTokens")}))
|
|
248
|
+
return out
|
|
249
|
+
if rtype == "user":
|
|
250
|
+
if rec.get("isMeta") or rec.get("isSidechain"):
|
|
251
|
+
return out
|
|
252
|
+
if rec.get("isCompactSummary"):
|
|
253
|
+
# attach the summary text to the preceding compaction event when possible
|
|
254
|
+
out.append(Event("compaction", text=result_text(rec.get("message", {}).get("content")), ts=ts, meta={"summary_only": True}))
|
|
255
|
+
return out
|
|
256
|
+
for b in blocks_of(rec.get("message")):
|
|
257
|
+
btype = b.get("type")
|
|
258
|
+
if btype == "text":
|
|
259
|
+
raw = str(b.get("text", ""))
|
|
260
|
+
if PUSH_MARKER_RE.search(raw):
|
|
261
|
+
out.append(Event("user", text="", ts=ts, is_push_invocation=True))
|
|
262
|
+
continue
|
|
263
|
+
text, command = clean_user_text(raw)
|
|
264
|
+
if command:
|
|
265
|
+
out.append(Event("user", ts=ts, command=command))
|
|
266
|
+
elif text:
|
|
267
|
+
out.append(Event("user", text=text, ts=ts))
|
|
268
|
+
elif btype == "image":
|
|
269
|
+
out.append(Event("note", text="[image omitted]", ts=ts))
|
|
270
|
+
return out
|
|
271
|
+
if rtype == "assistant":
|
|
272
|
+
if rec.get("isSidechain"):
|
|
273
|
+
return out
|
|
274
|
+
if rec.get("isApiErrorMessage"):
|
|
275
|
+
out.append(Event("note", text="API error message omitted", ts=ts))
|
|
276
|
+
return out
|
|
277
|
+
for b in blocks_of(rec.get("message")):
|
|
278
|
+
btype = b.get("type")
|
|
279
|
+
if btype == "text":
|
|
280
|
+
out.append(Event("assistant", text=str(b.get("text", "")), ts=ts))
|
|
281
|
+
elif btype == "thinking":
|
|
282
|
+
out.append(Event("thinking", text=str(b.get("thinking", "")), ts=ts))
|
|
283
|
+
elif btype == "tool_use":
|
|
284
|
+
name = str(b.get("name") or "tool")
|
|
285
|
+
inp = b.get("input") if isinstance(b.get("input"), dict) else {}
|
|
286
|
+
kind = KIND_BY_NAME.get(name, "mcp" if name.startswith("mcp__") else "other")
|
|
287
|
+
paths: List[str] = []
|
|
288
|
+
if name in EDIT_TOOLS:
|
|
289
|
+
p = inp.get("file_path") or inp.get("notebook_path")
|
|
290
|
+
if isinstance(p, str) and p:
|
|
291
|
+
paths.append(rel_to_root(p, _repo_hint(ref)))
|
|
292
|
+
tool_id = b.get("id") if isinstance(b.get("id"), str) else None
|
|
293
|
+
output, is_error = results.get(tool_id, ("", False)) if tool_id else ("", False)
|
|
294
|
+
pending = bool(tool_id) and tool_id not in results
|
|
295
|
+
out.append(Event("tool", ts=ts, tool=ToolCall(kind, name, tool_label(name, inp), input=inp, output=output,
|
|
296
|
+
is_error=is_error, paths=paths, pending=pending)))
|
|
297
|
+
else:
|
|
298
|
+
out.append(Event("note", text=f"unsupported block: {btype}", ts=ts))
|
|
299
|
+
return out
|
|
300
|
+
return out
|