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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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
|
+
]
|