Coverage for astrocyte/harness/transcript.py: 99%

150 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-10-29 22:10 +0000

1"""Turn an agent transcript (JSONL) into conversation turns to capture. 

2 

3Claude Code's format is described first; Antigravity's further down. 

4 

5What is kept, per the transcript structure measured on Claude Code 2.1: 

6 

7* **User prompts** — ``type == "user"`` lines with string content whose 

8 ``origin.kind`` is ``"human"`` (older transcripts lack ``origin``; untagged 

9 plain text counts as human). Task notifications, command wrappers and tool 

10 results are not the user speaking. 

11* **Assistant prose** — ``text`` blocks only. ``thinking`` is internal and 

12 ``tool_use`` / ``tool_result`` are bulky and can carry file contents. 

13* **Files the turn touched** — the paths of the assistant's file tools 

14 (``Read``, ``Edit``, ``Write``, ``MultiEdit``, ``NotebookEdit``), not their 

15 contents: what ties a captured turn to the code it was about. 

16 

17Hook output (including memories *we* inject) lives in separate ``attachment`` 

18lines, so it is never re-captured: there is no feedback loop. 

19 

20Reading is incremental from a byte offset. The returned offset never passes a 

21half-written line or a turn whose answer has not arrived, so the next read 

22resumes exactly where this one stopped — no turn is lost or captured twice. 

23""" 

24 

25from __future__ import annotations 

26 

27import json 

28import re 

29from dataclasses import dataclass, field 

30from datetime import datetime 

31from pathlib import Path 

32 

33# Long messages are clipped: memory wants the gist, not a 40 KB paste. 

34MAX_USER_CHARS = 2_000 

35MAX_ASSISTANT_CHARS = 4_000 

36 

37_REMINDER = re.compile(r"<system-reminder>.*?</system-reminder>", re.DOTALL) 

38_WRAPPER = re.compile(r"^\s*<(command-name|command-message|command-args|local-command-[a-z]+|task-notification)>") 

39 

40 

41@dataclass 

42class Turn: 

43 user: str 

44 assistant: list[str] = field(default_factory=list) 

45 started_at: datetime | None = None 

46 files: list[str] = field(default_factory=list) # touched, in first-touch order 

47 

48 def render(self) -> str: 

49 """Conversation-engine input (``**role**: text``).""" 

50 answer = _clip("\n\n".join(self.assistant), MAX_ASSISTANT_CHARS) 

51 return f"**user**: {_clip(self.user, MAX_USER_CHARS)}\n\n**assistant**: {answer}" 

52 

53 

54def _clip(text: str, limit: int) -> str: 

55 text = text.strip() 

56 return text if len(text) <= limit else text[: limit - 1].rstrip() + "…" 

57 

58 

59def _parse_time(raw: object) -> datetime | None: 

60 if not isinstance(raw, str): 

61 return None 

62 try: 

63 return datetime.fromisoformat(raw.replace("Z", "+00:00")) 

64 except ValueError: 

65 return None 

66 

67 

68def _human_prompt(line: dict) -> str | None: 

69 if line.get("type") != "user" or line.get("isSidechain") or line.get("isMeta"): 

70 return None 

71 content = (line.get("message") or {}).get("content") 

72 if isinstance(content, list): # text blocks typed by a human; tool results are not 

73 if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content): 

74 return None 

75 content = "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text") 

76 if not isinstance(content, str): 

77 return None 

78 origin = line.get("origin") 

79 if isinstance(origin, dict) and origin.get("kind") not in (None, "human"): 

80 return None 

81 if _WRAPPER.match(content): 

82 return None 

83 text = _REMINDER.sub("", content).strip() 

84 return text or None 

85 

86 

87def _assistant_text(line: dict) -> list[str]: 

88 if line.get("type") != "assistant" or line.get("isSidechain"): 

89 return [] 

90 content = (line.get("message") or {}).get("content") 

91 if isinstance(content, str): 

92 return [content] if content.strip() else [] 

93 if not isinstance(content, list): 

94 return [] 

95 return [b["text"] for b in content if isinstance(b, dict) and b.get("type") == "text" and b.get("text", "").strip()] 

96 

97 

98_FILE_TOOLS = {"Read": "file_path", "Edit": "file_path", "Write": "file_path", "MultiEdit": "file_path", 

99 "NotebookEdit": "notebook_path"} 

100 

101 

102def _touched_files(line: dict) -> list[str]: 

103 """Paths the assistant's file tools name in this line (Claude Code).""" 

104 if line.get("type") != "assistant" or line.get("isSidechain"): 

105 return [] 

106 content = (line.get("message") or {}).get("content") 

107 if not isinstance(content, list): 

108 return [] 

109 found = [] 

110 for block in content: 

111 if isinstance(block, dict) and block.get("type") == "tool_use" and (key := _FILE_TOOLS.get(block.get("name"))): 

112 path = (block.get("input") or {}).get(key) 

113 if isinstance(path, str) and path: 

114 found.append(path) 

115 return found 

116 

117 

118# ── Antigravity (transcript.jsonl under brain/<conversation>/) ─────────── 

119# 

120# Steps, measured on agy 1.2 and the Antigravity app: the user's message is a 

121# USER_INPUT step whose content wraps the text in <USER_REQUEST> (followed by 

122# <ADDITIONAL_METADATA> the app adds); replies are PLANNER_RESPONSE steps with 

123# string content (tool-calling ones have none). Context our hooks inject is an 

124# EPHEMERAL_MESSAGE step, so it is never re-captured. 

125 

126_USER_REQUEST = re.compile(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", re.DOTALL) 

127 

128 

129def antigravity_prompt(line: dict) -> str | None: 

130 if line.get("type") != "USER_INPUT" or line.get("source") not in (None, "USER_EXPLICIT"): 

131 return None 

132 content = line.get("content") 

133 if not isinstance(content, str): 

134 return None 

135 m = _USER_REQUEST.search(content) 

136 text = (m.group(1) if m else content).strip() 

137 return text or None 

138 

139 

140# File tools by name, and the argument holding the path. The envelope — 

141# PLANNER_RESPONSE ``tool_calls: [{"name", "args"}]`` with each argument value 

142# JSON-encoded as a string — is measured on agy 1.2; the tool names and their 

143# path arguments come from Antigravity's hook documentation (no file-tool 

144# call has been recorded here yet). 

145_ANTIGRAVITY_FILE_TOOLS = {"view_file": "AbsolutePath", "write_to_file": "TargetFile", 

146 "replace_file_content": "TargetFile", "multi_replace_file_content": "TargetFile"} 

147 

148 

149def _antigravity_files(line: dict) -> list[str]: 

150 if line.get("type") != "PLANNER_RESPONSE" or not isinstance(line.get("tool_calls"), list): 

151 return [] 

152 found = [] 

153 for call in line["tool_calls"]: 

154 if not isinstance(call, dict) or not (key := _ANTIGRAVITY_FILE_TOOLS.get(call.get("name"))): 

155 continue 

156 value = (call.get("args") or {}).get(key) 

157 if isinstance(value, str) and value.startswith('"'): 

158 try: 

159 value = json.loads(value) 

160 except ValueError: 

161 continue 

162 if isinstance(value, str) and value: 

163 found.append(value) 

164 return found 

165 

166 

167def _antigravity_reply(line: dict) -> list[str]: 

168 if line.get("type") != "PLANNER_RESPONSE": 

169 return [] 

170 content = line.get("content") 

171 return [content] if isinstance(content, str) and content.strip() else [] 

172 

173 

174def read_new_turns(path: str | Path, offset: int = 0, *, antigravity: bool = False) -> tuple[list[Turn], int]: 

175 """Complete turns after ``offset``, and the offset to resume from.""" 

176 p = Path(path) 

177 try: 

178 size = p.stat().st_size 

179 except OSError: 

180 return [], offset 

181 if offset > size: # transcript was rewritten or truncated; start over 

182 offset = 0 

183 with p.open("rb") as fh: 

184 fh.seek(offset) 

185 data = fh.read() 

186 

187 turns: list[Turn] = [] 

188 current: Turn | None = None 

189 current_start = offset # where the in-progress turn began 

190 resume = offset 

191 pos = offset 

192 for raw in data.splitlines(keepends=True): 

193 if not raw.endswith(b"\n"): 

194 break # half-written line: leave it for the next read 

195 line_start, pos = pos, pos + len(raw) # pos only ever covers complete lines 

196 try: 

197 line = json.loads(raw) 

198 except (json.JSONDecodeError, UnicodeDecodeError): 

199 resume = pos if current is None else resume 

200 continue 

201 if not isinstance(line, dict): 

202 continue 

203 prompt = antigravity_prompt(line) if antigravity else _human_prompt(line) 

204 if prompt is not None: 

205 if current is not None and current.assistant: 

206 turns.append(current) 

207 resume = line_start 

208 when = line.get("created_at") if antigravity else line.get("timestamp") 

209 current, current_start = Turn(user=prompt, started_at=_parse_time(when)), line_start 

210 continue 

211 texts = _antigravity_reply(line) if antigravity else _assistant_text(line) 

212 if texts and current is not None: 

213 current.assistant.extend(texts) 

214 if current is not None: 

215 touched = _antigravity_files(line) if antigravity else _touched_files(line) 

216 current.files.extend(f for f in touched if f not in current.files) 

217 if current is None: 

218 resume = pos # nothing pending: safe to move past this line 

219 

220 if current is not None and current.assistant: 

221 # Stop fires when the assistant has finished, so the last turn is done. 

222 turns.append(current) 

223 resume = pos 

224 elif current is not None: 

225 resume = current_start # prompt without an answer yet: re-read next time 

226 return turns, resume