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,179 @@
1
+ """Title + one-line-description extraction for Claude Code import.
2
+
3
+ Ported from canonical ``streaming/incremental_import/_titles.py`` (+ the
4
+ command-title helper from ``importer.py``): derive a thread title or description
5
+ from raw CC session lines — the ``custom-title`` / ``ai-title`` rows CC writes,
6
+ else the first non-XML line of the first user message (or a bare slash-command's
7
+ name). Pure helpers; no store access, so they stay separately testable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import Any, Optional
14
+
15
+ from thread_archive._thread_import.parsers.base import NormalizedMessage
16
+ from thread_archive._thread_import.parsers.claude_code import ClaudeCodeParser
17
+
18
+ # CC records a slash-command invocation as a user turn whose content is
19
+ # ``<command-name>/x</command-name>`` (plus optional args). The parser strips those
20
+ # tags, leaving the turn with empty content_text — and the next turn is usually
21
+ # injected content (a skill doc). Recover the command so it, not the injected doc,
22
+ # becomes the title.
23
+ _CC_COMMAND_NAME_RE = re.compile(r"<command-name>\s*(/[^<\n]+?)\s*</command-name>")
24
+ _CC_COMMAND_ARGS_RE = re.compile(r"<command-args>\s*(.*?)\s*</command-args>", re.DOTALL)
25
+
26
+
27
+ def _cc_command_title(msg: NormalizedMessage) -> Optional[str]:
28
+ """A slash-command title (e.g. ``/garden``) for a user turn whose raw content
29
+ is a bare CC command invocation, else None. Keeps the injected skill doc that
30
+ follows the command out of the thread list."""
31
+ provider_data = msg.get("provider_data")
32
+ if not isinstance(provider_data, dict):
33
+ return None
34
+ raw_line = provider_data.get("line")
35
+ if not isinstance(raw_line, dict):
36
+ return None
37
+ message = raw_line.get("message")
38
+ if not isinstance(message, dict):
39
+ return None
40
+ content = message.get("content")
41
+ if not isinstance(content, str):
42
+ return None
43
+ name_match = _CC_COMMAND_NAME_RE.search(content)
44
+ if not name_match:
45
+ return None
46
+ title = name_match.group(1).strip()
47
+ args_match = _CC_COMMAND_ARGS_RE.search(content)
48
+ if args_match:
49
+ args = args_match.group(1).strip()
50
+ if args:
51
+ title = f"{title} {args}"
52
+ return title[:100]
53
+
54
+
55
+ def _extract_ai_title(lines: list[dict]) -> Optional[str]:
56
+ """The current ``ai-title`` value from a CC JSONL. CC writes a single-row
57
+ ``{"type": "ai-title", "aiTitle": "..."}`` line each time the auto-titler
58
+ refines the title; the **last** such row wins."""
59
+ latest: Optional[str] = None
60
+ for line in lines:
61
+ if line.get("type") == "ai-title":
62
+ value = line.get("aiTitle")
63
+ if isinstance(value, str) and value.strip():
64
+ latest = value.strip()
65
+ return latest
66
+
67
+
68
+ def _extract_custom_title(lines: list[dict]) -> Optional[str]:
69
+ """The latest user rename from a CC JSONL ``custom-title`` row. The **last**
70
+ such row wins; a returned ``""`` is CC's "clear the rename" signal."""
71
+ latest: Optional[str] = None
72
+ for line in lines:
73
+ if line.get("type") == "custom-title":
74
+ value = line.get("customTitle")
75
+ if isinstance(value, str):
76
+ latest = value.strip()
77
+ return latest
78
+
79
+
80
+ def extract_session_title(lines: list[dict]) -> Optional[str]:
81
+ """The effective CC session title: a non-empty user rename (``custom-title``)
82
+ wins over the auto-titler's ``ai-title``, matching CC's reader precedence.
83
+ Returns None when neither is present."""
84
+ custom = _extract_custom_title(lines)
85
+ if custom:
86
+ return custom
87
+ return _extract_ai_title(lines)
88
+
89
+
90
+ def _title_from_messages(messages: list[NormalizedMessage]) -> Optional[str]:
91
+ """First-user-message title fallback: the first non-XML line of the first user
92
+ message (truncated to 100 + "..."), or a bare slash-command's name. Returns
93
+ None when no user message yields a title."""
94
+ for msg in messages:
95
+ if msg.get("role") != "user":
96
+ continue
97
+ text_blob = msg.get("content_text", "").strip()
98
+ if not text_blob:
99
+ # Bare slash-command (e.g. `/garden`): title it with the command, not
100
+ # the skill doc that gets injected on the next turn.
101
+ cmd_title = _cc_command_title(msg)
102
+ if cmd_title:
103
+ return cmd_title
104
+ continue
105
+ for line in text_blob.split("\n"):
106
+ stripped = line.strip()
107
+ if not stripped:
108
+ continue
109
+ if stripped.startswith("<") and ">" in stripped:
110
+ continue
111
+ title = stripped[:100]
112
+ if len(stripped) > 100:
113
+ title += "..."
114
+ return title
115
+ return None
116
+
117
+
118
+ def extract_title(lines: list[dict]) -> str:
119
+ """A title for a new thread. Preferred: the session title CC stores (a user
120
+ rename wins over the auto-titler). Falls back to the first non-XML line of the
121
+ first user message, then to ``"Claude Code Session"``."""
122
+ resolved = extract_session_title(lines)
123
+ if resolved:
124
+ return resolved[:100]
125
+
126
+ parser = ClaudeCodeParser()
127
+ session_data = {
128
+ "provider": "claude-code",
129
+ "sessions": [{"session_id": "temp", "project": "temp", "lines": lines[:20]}],
130
+ }
131
+ try:
132
+ messages = parser.parse_export(session_data)
133
+ except Exception:
134
+ return "Claude Code Session"
135
+ return _title_from_messages(messages) or "Claude Code Session"
136
+
137
+
138
+ def _user_content_texts(content: Any) -> list[str]:
139
+ """The text strings of a CC user-message ``content``: the string itself when
140
+ content is a str, or every ``{"type": "text"}`` block's text when it is a list."""
141
+ texts: list[str] = []
142
+ if isinstance(content, str):
143
+ texts.append(content)
144
+ elif isinstance(content, list):
145
+ for block in content:
146
+ if isinstance(block, dict) and block.get("type") == "text":
147
+ texts.append(block.get("text", ""))
148
+ return texts
149
+
150
+
151
+ def _description_from_texts(texts: list[str]) -> Optional[str]:
152
+ """The first meaningful (non-blank, non-XML, non-continuation) line across these
153
+ text blocks, truncated to 200 (+"..."). Returns None when none match."""
154
+ for t in texts:
155
+ for text_line in t.split("\n"):
156
+ stripped = text_line.strip()
157
+ if not stripped:
158
+ continue
159
+ if stripped.startswith("<") and ">" in stripped:
160
+ continue
161
+ if stripped.startswith("This session is being continued"):
162
+ continue
163
+ if len(stripped) > 200:
164
+ return stripped[:197] + "..."
165
+ return stripped
166
+ return None
167
+
168
+
169
+ def extract_description(lines: list[dict]) -> Optional[str]:
170
+ """A one-line description from the first user message: its first meaningful
171
+ line of content, truncated to 200 chars. None when none found."""
172
+ for line in lines[:20]:
173
+ if line.get("type") != "user":
174
+ continue
175
+ texts = _user_content_texts(line.get("message", {}).get("content", ""))
176
+ desc = _description_from_texts(texts)
177
+ if desc is not None:
178
+ return desc
179
+ return None
@@ -0,0 +1,254 @@
1
+ """Antigravity (Gemini CLI) incremental session import + assembler.
2
+
3
+ Pure ``_antigravity_*`` / ``_build_antigravity_messages`` helpers; orchestration
4
+ wired onto the shared scaffold.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import re
12
+ from typing import Any, Optional
13
+
14
+ from thread_archive._thread_import import DefaultEventBuilder
15
+
16
+ from ._events import assemble_events
17
+ from ._line_stream import import_line_stream_session
18
+ from ._result import IncrementalImportResult
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def import_antigravity_session_incremental(session_path, source_id: str, *, session=None) -> IncrementalImportResult:
24
+ """Import an Antigravity (Gemini agentic CLI) transcript.jsonl into the event log."""
25
+
26
+ def _do_import(sess, thread_id, all_lines, new_lines, _ctx):
27
+ messages = _build_antigravity_messages(new_lines, all_lines, source_id)
28
+ return assemble_events(sess, thread_id, messages, DefaultEventBuilder())
29
+
30
+ return import_line_stream_session(
31
+ source="antigravity",
32
+ source_id=source_id,
33
+ session_path=session_path,
34
+ session=session,
35
+ not_found_msg=f"Antigravity session file not found: {session_path}",
36
+ has_importable_content=_antigravity_has_importable_content,
37
+ make_title=lambda all_lines, _ctx: _antigravity_title(all_lines),
38
+ import_lines=_do_import,
39
+ )
40
+
41
+
42
+ _ANTIGRAVITY_USER_REQUEST_RE = re.compile(r"<USER_REQUEST>\s*(.*?)\s*</USER_REQUEST>", re.DOTALL)
43
+ _ANTIGRAVITY_MODEL_RE = re.compile(r"changed setting `Model Selection` from .*? to (.+?)\.\s")
44
+ # MODEL step `type` values that are the model's natural-language turn; everything
45
+ # else a MODEL step emits is a tool action/result.
46
+ _ANTIGRAVITY_TEXT_TYPES = {"PLANNER_RESPONSE"}
47
+
48
+
49
+ def _antigravity_kind(line: dict) -> Optional[str]:
50
+ source = line.get("source")
51
+ line_type = line.get("type")
52
+ if source == "USER_EXPLICIT" or line_type == "USER_INPUT":
53
+ return "user"
54
+ if source == "MODEL":
55
+ return "assistant" if line_type in _ANTIGRAVITY_TEXT_TYPES else "tool_result"
56
+ if line_type == "ERROR_MESSAGE":
57
+ return "error"
58
+ return None
59
+
60
+
61
+ def _antigravity_content(line: dict) -> str:
62
+ content = line.get("content")
63
+ if not isinstance(content, str) or not content.strip():
64
+ return ""
65
+ if _antigravity_kind(line) == "user":
66
+ m = _ANTIGRAVITY_USER_REQUEST_RE.search(content)
67
+ if m:
68
+ return m.group(1).strip()
69
+ return content.strip()
70
+
71
+
72
+ def _antigravity_tool_args(args: Any) -> dict[str, Any]:
73
+ """Antigravity tool_call args arrive double-JSON-encoded; unwrap each string once."""
74
+ if not isinstance(args, dict):
75
+ return {}
76
+ out: dict[str, Any] = {}
77
+ for k, v in args.items():
78
+ if isinstance(v, str):
79
+ try:
80
+ out[k] = json.loads(v)
81
+ except (json.JSONDecodeError, ValueError):
82
+ out[k] = v
83
+ else:
84
+ out[k] = v
85
+ return out
86
+
87
+
88
+ def _antigravity_model(lines: list[dict]) -> str:
89
+ model = "gemini"
90
+ for line in lines:
91
+ content = line.get("content")
92
+ if not isinstance(content, str):
93
+ continue
94
+ m = _ANTIGRAVITY_MODEL_RE.search(content)
95
+ if m:
96
+ model = m.group(1).strip()[:80]
97
+ return model
98
+
99
+
100
+ def _antigravity_has_importable_content(lines: list[dict]) -> bool:
101
+ for line in lines:
102
+ kind = _antigravity_kind(line)
103
+ if kind == "user" and _antigravity_content(line):
104
+ return True
105
+ if kind == "assistant" and (_antigravity_content(line) or line.get("tool_calls")):
106
+ return True
107
+ return False
108
+
109
+
110
+ def _antigravity_title(lines: list[dict]) -> str:
111
+ for line in lines:
112
+ if _antigravity_kind(line) == "user":
113
+ text_content = _antigravity_content(line)
114
+ if text_content:
115
+ first = next((part.strip() for part in text_content.splitlines() if part.strip()), "")
116
+ return (first[:97] + "...") if len(first) > 100 else first
117
+ return "Antigravity Session"
118
+
119
+
120
+ def _antigravity_preserved_message(line: dict, ts: Optional[str]) -> dict[str, Any]:
121
+ """Preserve an antigravity step the importer doesn't model, rather than drop it.
122
+
123
+ ``_antigravity_kind`` only maps user/model/error steps; any other step (a
124
+ ``Model Selection`` setting change, a system notice, a future step kind) returns
125
+ None and would be skipped. Kept here as a ``role="unknown"`` message so the
126
+ builder emits a ``message`` event carrying the step's text plus the raw step
127
+ verbatim under ``content_blocks`` — nothing silently lost, and idempotent because
128
+ the raw step is inside the dedup-hashed ``content_blocks``."""
129
+ content = line.get("content")
130
+ text = content.strip() if isinstance(content, str) else ""
131
+ return {
132
+ "role": "unknown",
133
+ "created_at": ts,
134
+ "content_text": text,
135
+ "content_blocks": [{
136
+ "type": "antigravity_step",
137
+ "source": line.get("source"),
138
+ "step_type": line.get("type"),
139
+ "raw": line,
140
+ "start_timestamp": ts,
141
+ }],
142
+ "provider_message_id": ts or "",
143
+ "provider_data": {"provider": "antigravity"},
144
+ }
145
+
146
+
147
+ def _build_antigravity_messages(
148
+ new_lines: list[dict],
149
+ all_lines: list[dict],
150
+ source_id: str,
151
+ ) -> list[dict[str, Any]]:
152
+ """Assemble antigravity's transcript steps into canonical NormalizedMessages.
153
+
154
+ Tool calls carry no id and their result arrives as a later step, so each
155
+ PLANNER_RESPONSE turn mints a deterministic id per tool_use (``agtool-<n>`` by
156
+ document order, stable across re-imports so the dedup key holds), and each
157
+ following tool/error step is paired FIFO and folded back as a tool_result.
158
+ """
159
+ messages: list[dict[str, Any]] = []
160
+ model = _antigravity_model(all_lines)
161
+ cur: Optional[dict[str, Any]] = None
162
+ pending: list[tuple[str, Optional[str], dict[str, Any]]] = []
163
+ tool_seq = 0
164
+
165
+ def new_assistant(ts: Optional[str]) -> dict[str, Any]:
166
+ turn = {
167
+ "role": "assistant",
168
+ "created_at": ts,
169
+ "content_text": "",
170
+ "content_blocks": [],
171
+ "provider_message_id": ts or "",
172
+ "provider_data": {"provider": "antigravity", "model": model},
173
+ }
174
+ messages.append(turn)
175
+ return turn
176
+
177
+ def handle_assistant(line: dict, ts: Optional[str], content: str) -> None:
178
+ nonlocal cur, tool_seq
179
+ tool_calls = [tc for tc in (line.get("tool_calls") or []) if isinstance(tc, dict)]
180
+ if not content and not tool_calls:
181
+ return
182
+ cur = new_assistant(ts)
183
+ if content:
184
+ cur["content_blocks"].append({"type": "text", "text": content, "start_timestamp": ts})
185
+ for tc in tool_calls:
186
+ tid = f"agtool-{tool_seq}"
187
+ tool_seq += 1
188
+ name = tc.get("name") or "unknown"
189
+ cur["content_blocks"].append({
190
+ "type": "tool_use", "id": tid, "name": name,
191
+ "input": _antigravity_tool_args(tc.get("args")),
192
+ "start_timestamp": ts,
193
+ })
194
+ pending.append((tid, name, cur))
195
+
196
+ def handle_outcome(line: dict, kind: str, ts: Optional[str], content: str) -> None:
197
+ # An empty outcome still records that a tool completed/errored — and dropping
198
+ # it would leave its tool_use unpaired, so the NEXT outcome would mis-pair to
199
+ # it FIFO. Keep it: pop the pending call (honest empty content), never drop.
200
+ nonlocal tool_seq
201
+ if pending:
202
+ tid, call_name, owner = pending.pop(0)
203
+ else:
204
+ owner = cur if cur is not None else new_assistant(ts)
205
+ tid, call_name = f"agorphan-{tool_seq}", None
206
+ tool_seq += 1
207
+ owner["content_blocks"].append({
208
+ "type": "tool_result",
209
+ "tool_use_id": tid,
210
+ "name": call_name or str(line.get("type") or "tool").lower(),
211
+ "content": content,
212
+ "is_error": kind == "error",
213
+ "start_timestamp": ts,
214
+ })
215
+
216
+ for line in new_lines:
217
+ if not isinstance(line, dict):
218
+ continue
219
+ kind = _antigravity_kind(line)
220
+ ts = line.get("created_at")
221
+
222
+ if kind is None:
223
+ # A step the importer doesn't model (setting change, system notice, a
224
+ # future kind). Preserve it unless it's a genuinely-empty record with
225
+ # nothing to keep.
226
+ if line:
227
+ messages.append(_antigravity_preserved_message(line, ts))
228
+ continue
229
+
230
+ content = _antigravity_content(line)
231
+
232
+ if kind == "user":
233
+ if not content:
234
+ continue
235
+ cur = None
236
+ messages.append({
237
+ "role": "user",
238
+ "created_at": ts,
239
+ "content_text": content,
240
+ "content_blocks": [],
241
+ "provider_message_id": ts or "",
242
+ "provider_data": {"provider": "antigravity"},
243
+ })
244
+ elif kind == "assistant":
245
+ handle_assistant(line, ts, content)
246
+ else:
247
+ handle_outcome(line, kind, ts, content)
248
+
249
+ # Keep every user turn, plus any turn carrying SOME content (blocks or text);
250
+ # only a truly-empty non-user turn (no blocks, no text) is dropped.
251
+ return [
252
+ m for m in messages
253
+ if m["role"] == "user" or m["content_blocks"] or m.get("content_text", "").strip()
254
+ ]