thread-archive 0.0.2__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.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,600 @@
1
+ """Grok CLI (`~/.grok/sessions`) incremental session import + assembler.
2
+
3
+ ``chat_history.jsonl`` is the content spine; real timestamps come from sibling
4
+ files (``updates.jsonl`` for tool times, ``prompt_history.jsonl`` for user-prompt
5
+ times, ``summary.json`` for session metadata). Pure ``_grok_*`` helpers; orchestration
6
+ wired onto the shared scaffold.
7
+
8
+ We seed the timestamp anchor from the session ``created_at`` and rely on dedup_key
9
+ for re-imports. Refining it to seed from the newest persisted event (so a final
10
+ answer landing in a later pass than its tool rounds doesn't sort to the top) is
11
+ deferred.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import re
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from thread_archive._thread_import import DefaultEventBuilder
24
+
25
+ from ._events import assemble_events
26
+ from ._line_stream import import_line_stream_session
27
+ from ._result import IncrementalImportResult
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _GROK_USER_QUERY_RE = re.compile(r"<user_query>(.*?)</user_query>", re.S)
32
+
33
+
34
+ def import_grok_session_incremental(session_path, source_id: str, *, session=None) -> IncrementalImportResult:
35
+ """Import a Grok CLI session (``chat_history.jsonl`` path) into the event log."""
36
+ session_path = Path(session_path)
37
+
38
+ def _do_import(sess, thread_id, all_lines, new_lines, meta):
39
+ session_dir = session_path.parent
40
+ tool_times = _grok_tool_timestamps(session_dir)
41
+ prompt_times = _grok_prompt_ts_map(session_dir, source_id)
42
+ base_ts = _parse_grok_timestamp(meta.get("created_at")) or datetime.now(timezone.utc)
43
+ messages = _build_grok_messages(
44
+ new_lines, meta, tool_times, prompt_times,
45
+ prefix_lines=all_lines[: len(all_lines) - len(new_lines)],
46
+ )
47
+ return assemble_events(sess, thread_id, messages, DefaultEventBuilder(), base_prev_ts=base_ts)
48
+
49
+ return import_line_stream_session(
50
+ source="grok",
51
+ source_id=source_id,
52
+ session_path=session_path,
53
+ session=session,
54
+ not_found_msg=f"Grok session file not found: {session_path}",
55
+ prepare=lambda _all_lines, path: _grok_session_meta(path.parent),
56
+ has_importable_content=_grok_has_importable_content,
57
+ make_title=lambda all_lines, meta: _grok_title(all_lines, meta),
58
+ make_source_metadata=lambda meta: _grok_source_metadata(meta, source_id),
59
+ import_lines=_do_import,
60
+ )
61
+
62
+
63
+ def _parse_grok_timestamp(ts: Any) -> Optional[datetime]:
64
+ if not isinstance(ts, str) or not ts:
65
+ return None
66
+ try:
67
+ return datetime.fromisoformat(ts[:-1] + "+00:00" if ts.endswith("Z") else ts)
68
+ except (ValueError, TypeError):
69
+ return None
70
+
71
+
72
+ def _grok_unix_ts(v: Any) -> Optional[datetime]:
73
+ if isinstance(v, bool):
74
+ return None
75
+ if isinstance(v, (int, float)):
76
+ try:
77
+ return datetime.fromtimestamp(v, tz=timezone.utc)
78
+ except (ValueError, OSError, OverflowError):
79
+ return None
80
+ if isinstance(v, str):
81
+ return _parse_grok_timestamp(v)
82
+ return None
83
+
84
+
85
+ def _grok_session_meta(session_dir: Path) -> dict[str, Any]:
86
+ path = session_dir / "summary.json"
87
+ if not path.exists():
88
+ return {}
89
+ try:
90
+ data = json.loads(path.read_text(encoding="utf-8"))
91
+ except (json.JSONDecodeError, OSError):
92
+ return {}
93
+ return data if isinstance(data, dict) else {}
94
+
95
+
96
+ def _grok_meta_info(meta: dict[str, Any]) -> dict[str, Any]:
97
+ info = meta.get("info")
98
+ return info if isinstance(info, dict) else {}
99
+
100
+
101
+ def _grok_model(meta: dict[str, Any]) -> str:
102
+ model = meta.get("current_model_id")
103
+ if isinstance(model, str) and model.strip():
104
+ return model.strip()
105
+ return "grok"
106
+
107
+
108
+ def _grok_source_metadata(meta: dict[str, Any], source_id: str) -> dict[str, Any]:
109
+ info = _grok_meta_info(meta)
110
+ data: dict[str, Any] = {
111
+ "provider": "grok",
112
+ "session_id": info.get("id") or source_id,
113
+ "cwd": info.get("cwd") or meta.get("git_root_dir"),
114
+ "model": meta.get("current_model_id"),
115
+ "agent_name": meta.get("agent_name"),
116
+ "git_branch": meta.get("head_branch"),
117
+ "git_commit": meta.get("head_commit"),
118
+ "git_remotes": meta.get("git_remotes"),
119
+ "created_at": meta.get("created_at"),
120
+ }
121
+ return {k: v for k, v in data.items() if v is not None}
122
+
123
+
124
+ def _grok_provider_data(meta: dict[str, Any], role: str) -> dict[str, Any]:
125
+ info = _grok_meta_info(meta)
126
+ data: dict[str, Any] = {
127
+ "provider": "grok",
128
+ "role": role,
129
+ "session_id": info.get("id"),
130
+ "cwd": info.get("cwd"),
131
+ "model": meta.get("current_model_id"),
132
+ "agent_name": meta.get("agent_name"),
133
+ }
134
+ return {k: v for k, v in data.items() if v is not None}
135
+
136
+
137
+ def _grok_user_text(line: dict) -> Optional[str]:
138
+ content = line.get("content")
139
+ if isinstance(content, str):
140
+ return content
141
+ if isinstance(content, list):
142
+ parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
143
+ return "".join(parts) if parts else None
144
+ return None
145
+
146
+
147
+ def _grok_extract_query(text_content: str) -> str:
148
+ match = _GROK_USER_QUERY_RE.search(text_content)
149
+ if match:
150
+ return match.group(1).strip()
151
+ stripped = text_content.strip()
152
+ if stripped.startswith(("<user_info>", "<system-reminder>", "<environment")):
153
+ return ""
154
+ return stripped
155
+
156
+
157
+ def _grok_reasoning_text(line: dict) -> str:
158
+ summary = line.get("summary")
159
+ if isinstance(summary, list):
160
+ parts = [b.get("text", "") for b in summary if isinstance(b, dict) and b.get("type") == "summary_text"]
161
+ return "\n\n".join(p for p in parts if p)
162
+ return ""
163
+
164
+
165
+ def _grok_tool_input(tool_call: dict) -> dict[str, Any]:
166
+ raw = tool_call.get("arguments")
167
+ if isinstance(raw, dict):
168
+ return raw
169
+ if isinstance(raw, str) and raw.strip():
170
+ try:
171
+ parsed = json.loads(raw)
172
+ except json.JSONDecodeError:
173
+ return {"arguments": raw}
174
+ return parsed if isinstance(parsed, dict) else {"arguments": parsed}
175
+ return {}
176
+
177
+
178
+ def _grok_first_query(all_lines: list[dict]) -> Optional[str]:
179
+ for line in all_lines:
180
+ if line.get("type") != "user" or line.get("synthetic_reason"):
181
+ continue
182
+ text_content = _grok_user_text(line)
183
+ if text_content is None:
184
+ continue
185
+ query = _grok_extract_query(text_content)
186
+ if query.strip():
187
+ return query
188
+ return None
189
+
190
+
191
+ def _grok_title(all_lines: list[dict], meta: dict[str, Any]) -> str:
192
+ for key in ("generated_title", "session_summary"):
193
+ value = meta.get(key)
194
+ if isinstance(value, str) and value.strip():
195
+ return value.strip()[:100]
196
+ query = _grok_first_query(all_lines)
197
+ if query:
198
+ first = next((part.strip() for part in query.splitlines() if part.strip()), "")
199
+ return (first[:97] + "...") if len(first) > 100 else first
200
+ return "Grok Session"
201
+
202
+
203
+ def _grok_has_importable_content(lines: list[dict]) -> bool:
204
+ for line in lines:
205
+ line_type = line.get("type")
206
+ if line_type == "assistant":
207
+ content = line.get("content")
208
+ if (isinstance(content, str) and content.strip()) or line.get("tool_calls"):
209
+ return True
210
+ elif line_type == "tool_result":
211
+ return True
212
+ elif line_type == "reasoning":
213
+ if _grok_reasoning_text(line):
214
+ return True
215
+ elif line_type == "user" and not line.get("synthetic_reason"):
216
+ text_content = _grok_user_text(line)
217
+ if text_content and _grok_extract_query(text_content).strip():
218
+ return True
219
+ return False
220
+
221
+
222
+ def _grok_apply_tool_update(update: dict[str, Any], ts: Optional[datetime], rec: dict[str, Any]) -> None:
223
+ status = update.get("status")
224
+ if status:
225
+ rec["status"] = status
226
+ if ts:
227
+ rec["end"] = ts
228
+ elif ts and "start" not in rec:
229
+ rec["start"] = ts
230
+
231
+
232
+ def _grok_fold_tool_update(obj: dict[str, Any], out: dict[str, dict[str, Any]]) -> None:
233
+ update = (obj.get("params") or {}).get("update") or {}
234
+ session_update = update.get("sessionUpdate")
235
+ if session_update not in ("tool_call", "tool_call_update"):
236
+ return
237
+ call_id = update.get("toolCallId")
238
+ if not isinstance(call_id, str) or not call_id:
239
+ return
240
+ ts = _grok_unix_ts(obj.get("timestamp"))
241
+ rec = out.setdefault(call_id, {})
242
+ if session_update == "tool_call":
243
+ if ts and "start" not in rec:
244
+ rec["start"] = ts
245
+ return
246
+ _grok_apply_tool_update(update, ts, rec)
247
+
248
+
249
+ def _grok_tool_timestamps(session_dir: Path) -> dict[str, dict[str, Any]]:
250
+ path = session_dir / "updates.jsonl"
251
+ out: dict[str, dict[str, Any]] = {}
252
+ if not path.exists():
253
+ return out
254
+ try:
255
+ handle = open(path, "r", encoding="utf-8")
256
+ except OSError:
257
+ return out
258
+ with handle:
259
+ for raw_line in handle:
260
+ raw_line = raw_line.strip()
261
+ if not raw_line:
262
+ continue
263
+ try:
264
+ obj = json.loads(raw_line)
265
+ except json.JSONDecodeError:
266
+ continue
267
+ _grok_fold_tool_update(obj, out)
268
+ return out
269
+
270
+
271
+ def _grok_prompt_ts_map(session_dir: Path, source_id: str) -> dict[str, list[datetime]]:
272
+ path = session_dir.parent / "prompt_history.jsonl"
273
+ out: dict[str, list[datetime]] = {}
274
+ if not path.exists():
275
+ return out
276
+ try:
277
+ handle = open(path, "r", encoding="utf-8")
278
+ except OSError:
279
+ return out
280
+ with handle:
281
+ for raw_line in handle:
282
+ raw_line = raw_line.strip()
283
+ if not raw_line:
284
+ continue
285
+ try:
286
+ obj = json.loads(raw_line)
287
+ except json.JSONDecodeError:
288
+ continue
289
+ if obj.get("session_id") != source_id:
290
+ continue
291
+ ts = _parse_grok_timestamp(obj.get("timestamp"))
292
+ prompt = obj.get("prompt")
293
+ if ts is None or not isinstance(prompt, str):
294
+ continue
295
+ out.setdefault(prompt.strip(), []).append(ts)
296
+ return out
297
+
298
+
299
+ def _grok_pop_prompt_ts(prompt_times: dict[str, list[datetime]], query: str) -> Optional[datetime]:
300
+ key = query.strip()
301
+ queue = prompt_times.get(key)
302
+ if queue:
303
+ return queue.pop(0)
304
+ head = key[:80]
305
+ for stored_key, queue in prompt_times.items():
306
+ if queue and (stored_key.startswith(head) or key.startswith(stored_key[:80])):
307
+ return queue.pop(0)
308
+ return None
309
+
310
+
311
+ def _grok_iso(dt: Optional[datetime]) -> Optional[str]:
312
+ return dt.isoformat() if dt is not None else None
313
+
314
+
315
+ def _grok_build_user_message(
316
+ text: str, query: str, prompt_times: dict[str, list[datetime]], meta: dict[str, Any]
317
+ ) -> dict[str, Any]:
318
+ """Build a user NormalizedMessage carrying the FULL user-line text.
319
+
320
+ ``text`` is the complete user text (harness-injected ``<user_info>`` /
321
+ ``<environment>`` context and all); ``query`` is the ``<user_query>`` span (or the
322
+ same text when the line is unwrapped), used only to match the prompt-history
323
+ timestamp and noted under ``user_query`` so the span stays recoverable. Nothing
324
+ outside the query tags is discarded — the archive captures the whole turn."""
325
+ provider_data = _grok_provider_data(meta, "user")
326
+ query = query.strip()
327
+ if query and query != text.strip():
328
+ provider_data["user_query"] = query
329
+ return {
330
+ "role": "user",
331
+ # Keep the prompt-time lookup keyed on the query span (unchanged behavior);
332
+ # fall back to the full text only for context-only turns that have no span.
333
+ "created_at": _grok_iso(_grok_pop_prompt_ts(prompt_times, query or text)),
334
+ "content_text": text,
335
+ "content_blocks": [],
336
+ "provider_message_id": "",
337
+ "provider_data": provider_data,
338
+ }
339
+
340
+
341
+ def _grok_preserve_line(
342
+ line: dict[str, Any],
343
+ meta: dict[str, Any],
344
+ *,
345
+ block_type: str,
346
+ extra_provider_data: Optional[dict[str, Any]] = None,
347
+ ) -> dict[str, Any]:
348
+ """Preserve a grok line the modeled path would otherwise silently drop.
349
+
350
+ Emitted as a ``role="system"`` NormalizedMessage carrying the line's readable
351
+ text plus a single content block, so the shared event builder turns it into a
352
+ ``context_summary`` event (given a block, a system message is never dropped) and
353
+ keeps the raw line — plus any extra tags — under ``provider_data``. This upholds
354
+ the archive's capture-EVERYTHING invariant: no provider record is skipped on
355
+ import. The text is never truncated. ``created_at`` is left unset so the builder
356
+ inherits the prior turn's time (monotonic, flagged) rather than fabricating one."""
357
+ text = _grok_user_text(line) or ""
358
+ provider_data = {
359
+ **_grok_provider_data(meta, "system"),
360
+ "grok_line_type": line.get("type"),
361
+ "raw_line": line,
362
+ }
363
+ if extra_provider_data:
364
+ provider_data.update(extra_provider_data)
365
+ return {
366
+ "role": "system",
367
+ "created_at": None,
368
+ "content_text": text,
369
+ "content_blocks": [{"type": block_type, "text": text}],
370
+ "provider_message_id": "",
371
+ "provider_data": provider_data,
372
+ }
373
+
374
+
375
+ def _grok_build_assistant_message(
376
+ line: dict[str, Any],
377
+ pending_thinking: Optional[str],
378
+ tool_times: dict[str, dict[str, Any]],
379
+ tool_names: dict[str, str],
380
+ model_default: str,
381
+ meta: dict[str, Any],
382
+ ) -> Optional[dict[str, Any]]:
383
+ content = line.get("content")
384
+ content = content if isinstance(content, str) else (str(content) if content else "")
385
+ tool_calls = line.get("tool_calls") or []
386
+ if not isinstance(tool_calls, list):
387
+ tool_calls = []
388
+ if not content.strip() and not tool_calls and not pending_thinking:
389
+ return None
390
+
391
+ model = line.get("model_id") or model_default
392
+ start_ts: Optional[datetime] = None
393
+ for tool_call in tool_calls:
394
+ cid = tool_call.get("id")
395
+ started = tool_times.get(cid, {}).get("start") if isinstance(cid, str) else None
396
+ if started and (start_ts is None or started < start_ts):
397
+ start_ts = started
398
+
399
+ blocks: list[dict[str, Any]] = []
400
+ if pending_thinking:
401
+ blocks.append({"type": "thinking", "text": pending_thinking})
402
+ if content.strip():
403
+ blocks.append({"type": "text", "text": content})
404
+ for tool_call in tool_calls:
405
+ cid = tool_call.get("id")
406
+ if not isinstance(cid, str) or not cid:
407
+ continue
408
+ name = tool_call.get("name") or "unknown"
409
+ tool_names[cid] = name
410
+ blocks.append({
411
+ "type": "tool_use", "id": cid, "name": name,
412
+ "input": _grok_tool_input(tool_call),
413
+ "start_timestamp": _grok_iso(tool_times.get(cid, {}).get("start")),
414
+ })
415
+ return {
416
+ "role": "assistant",
417
+ "created_at": _grok_iso(start_ts),
418
+ "content_text": "",
419
+ "content_blocks": blocks,
420
+ "provider_message_id": "",
421
+ "provider_data": {**_grok_provider_data(meta, "assistant"), "model": model},
422
+ }
423
+
424
+
425
+ def _grok_tool_result_block(
426
+ line: dict, tool_times: dict[str, dict[str, Any]], tool_names: dict[str, str]
427
+ ) -> Optional[dict[str, Any]]:
428
+ cid = line.get("tool_call_id")
429
+ if not isinstance(cid, str) or not cid:
430
+ return None
431
+ output = line.get("content")
432
+ if not isinstance(output, str):
433
+ output = json.dumps(output, default=str) if output is not None else ""
434
+ info = tool_times.get(cid, {})
435
+ return {
436
+ "type": "tool_result", "tool_use_id": cid,
437
+ "name": tool_names.get(cid, "unknown"),
438
+ "content": output,
439
+ "is_error": info.get("status") == "failed",
440
+ "start_timestamp": _grok_iso(info.get("end")),
441
+ }
442
+
443
+
444
+ def _grok_fold_tool_result(
445
+ line: dict,
446
+ cur: Optional[dict[str, Any]],
447
+ tool_times: dict[str, dict[str, Any]],
448
+ tool_names: dict[str, str],
449
+ model_default: str,
450
+ meta: dict[str, Any],
451
+ ) -> Optional[dict[str, Any]]:
452
+ block = _grok_tool_result_block(line, tool_times, tool_names)
453
+ if block is None:
454
+ return cur
455
+ if cur is None:
456
+ cur = {
457
+ "role": "assistant",
458
+ "created_at": block["start_timestamp"],
459
+ "content_text": "",
460
+ "content_blocks": [],
461
+ "provider_message_id": "",
462
+ "provider_data": {**_grok_provider_data(meta, "assistant"), "model": model_default},
463
+ }
464
+ cur["content_blocks"].append(block)
465
+ return cur
466
+
467
+
468
+ def _grok_accumulate_thinking(pending_thinking: Optional[str], line: dict) -> Optional[str]:
469
+ thought = _grok_reasoning_text(line)
470
+ if not thought:
471
+ return pending_thinking
472
+ return pending_thinking + "\n\n" + thought if pending_thinking else thought
473
+
474
+
475
+ def _grok_user_turn(
476
+ line: dict, prompt_times: dict[str, list[datetime]], meta: dict[str, Any]
477
+ ) -> Optional[dict[str, Any]]:
478
+ """Build the normalized message for a genuine (non-synthetic) user line.
479
+
480
+ Synthetic/injected turns are preserved-and-tagged by the caller. Here we keep the
481
+ FULL text — context-only turns (a line that is only ``<user_info>`` /
482
+ ``<system-reminder>`` / ``<environment>`` with no ``<user_query>``) are preserved
483
+ rather than dropped, and the ``<user_query>`` span keeps the text
484
+ around it."""
485
+ text_content = _grok_user_text(line)
486
+ if text_content is None or not text_content.strip():
487
+ return None
488
+ query = _grok_extract_query(text_content)
489
+ return _grok_build_user_message(text_content, query, prompt_times, meta)
490
+
491
+
492
+ def _harvest_tool_names(lines: list[dict]) -> dict[str, str]:
493
+ """tool_call id→name from every assistant line in ``lines``. The already-
494
+ imported prefix of an incremental batch must contribute its names, or a
495
+ ``tool_result`` landing in a later poll than its ``tool_calls`` line
496
+ resolves to ``"unknown"`` — permanently, since the payload is baked into
497
+ the event log."""
498
+ names: dict[str, str] = {}
499
+ for line in lines:
500
+ if not isinstance(line, dict) or line.get("type") != "assistant":
501
+ continue
502
+ tool_calls = line.get("tool_calls") or []
503
+ if not isinstance(tool_calls, list):
504
+ continue
505
+ for tool_call in tool_calls:
506
+ cid = tool_call.get("id")
507
+ if isinstance(cid, str) and cid:
508
+ names[cid] = tool_call.get("name") or "unknown"
509
+ return names
510
+
511
+
512
+ def _build_grok_messages(
513
+ new_lines: list[dict],
514
+ meta: dict[str, Any],
515
+ tool_times: dict[str, dict[str, Any]],
516
+ prompt_times: dict[str, list[datetime]],
517
+ prefix_lines: list[dict] | None = None,
518
+ ) -> list[dict[str, Any]]:
519
+ """Assemble grok's interleaved line stream into canonical NormalizedMessages.
520
+
521
+ ``prefix_lines`` is the file's already-imported prefix: it seeds the
522
+ tool-name map so results split across increments still resolve."""
523
+ messages: list[dict[str, Any]] = []
524
+ model_default = _grok_model(meta)
525
+ tool_names: dict[str, str] = _harvest_tool_names(prefix_lines or [])
526
+ pending_thinking: Optional[str] = None
527
+ cur: Optional[dict[str, Any]] = None
528
+
529
+ def flush() -> None:
530
+ nonlocal cur
531
+ if cur is not None and cur["content_blocks"]:
532
+ messages.append(cur)
533
+ cur = None
534
+
535
+ for line in new_lines:
536
+ if not isinstance(line, dict):
537
+ continue
538
+ line_type = line.get("type")
539
+
540
+ if line_type == "system":
541
+ # Grok system lines/prompts are real records — preserve them as a tagged
542
+ # system event instead of dropping. Flush the in-progress assistant turn
543
+ # first so ordering holds, but don't reset pending thinking: a system
544
+ # line isn't a turn boundary and mustn't discard dangling reasoning.
545
+ flush()
546
+ messages.append(_grok_preserve_line(line, meta, block_type="grok_system"))
547
+ continue
548
+
549
+ if line_type == "user":
550
+ if line.get("synthetic_reason"):
551
+ # Synthetic/injected user turn — keep AND tag (with the reason + raw
552
+ # line) rather than drop. Not a real turn boundary, so leave the
553
+ # in-progress assistant/thinking state alone.
554
+ flush()
555
+ messages.append(_grok_preserve_line(
556
+ line, meta, block_type="grok_synthetic_user",
557
+ extra_provider_data={
558
+ "synthetic": True,
559
+ "synthetic_reason": line.get("synthetic_reason"),
560
+ "grok_original_role": "user",
561
+ },
562
+ ))
563
+ continue
564
+ user_msg = _grok_user_turn(line, prompt_times, meta)
565
+ if user_msg is None:
566
+ continue
567
+ flush()
568
+ pending_thinking = None
569
+ messages.append(user_msg)
570
+ continue
571
+
572
+ if line_type == "reasoning":
573
+ pending_thinking = _grok_accumulate_thinking(pending_thinking, line)
574
+ continue
575
+
576
+ if line_type == "assistant":
577
+ built = _grok_build_assistant_message(
578
+ line, pending_thinking, tool_times, tool_names, model_default, meta
579
+ )
580
+ if built is None:
581
+ continue
582
+ flush()
583
+ cur = built
584
+ pending_thinking = None
585
+ continue
586
+
587
+ if line_type == "tool_result":
588
+ cur = _grok_fold_tool_result(line, cur, tool_times, tool_names, model_default, meta)
589
+ continue
590
+
591
+ # Any other grok line type (outside {system,user,reasoning,assistant,
592
+ # tool_result}) — a new or unmodeled type must never vanish on import.
593
+ # Preserve the raw line as a tagged event instead of silently ignoring it.
594
+ flush()
595
+ messages.append(
596
+ _grok_preserve_line(line, meta, block_type=f"grok_{line_type or 'unknown'}")
597
+ )
598
+
599
+ flush()
600
+ return messages