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,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
|