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,324 @@
|
|
|
1
|
+
"""Codex (`~/.codex`) incremental session import + line-stream assembler.
|
|
2
|
+
|
|
3
|
+
The ``_codex_*`` / ``_build_codex_messages`` helpers are pure functions over the
|
|
4
|
+
line dicts; the orchestration is wired onto the shared scaffold + ``assemble_events``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from thread_archive._thread_import import DefaultEventBuilder
|
|
14
|
+
|
|
15
|
+
from ._events import assemble_events
|
|
16
|
+
from ._line_stream import import_line_stream_session
|
|
17
|
+
from ._result import IncrementalImportResult
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def import_codex_session_incremental(session_path, source_id: str, *, session=None) -> IncrementalImportResult:
|
|
23
|
+
"""Import a Codex session JSONL into the archive's event log.
|
|
24
|
+
|
|
25
|
+
Codex writes a different JSONL shape from Claude Code: ``event_msg.user_message``
|
|
26
|
+
/ ``event_msg.agent_message`` are the canonical transcript, and
|
|
27
|
+
``response_item.function_call*`` / ``custom_tool_call*`` are tool events.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _do_import(sess, thread_id, all_lines, new_lines, meta):
|
|
31
|
+
call_names, call_inputs = _codex_call_maps(all_lines)
|
|
32
|
+
messages = _build_codex_messages(new_lines, meta, call_names, call_inputs)
|
|
33
|
+
return assemble_events(sess, thread_id, messages, DefaultEventBuilder())
|
|
34
|
+
|
|
35
|
+
return import_line_stream_session(
|
|
36
|
+
source="codex",
|
|
37
|
+
source_id=source_id,
|
|
38
|
+
session_path=session_path,
|
|
39
|
+
session=session,
|
|
40
|
+
not_found_msg=f"Codex session file not found: {session_path}",
|
|
41
|
+
prepare=lambda all_lines, _path: _codex_session_meta(all_lines),
|
|
42
|
+
has_importable_content=_codex_has_importable_content,
|
|
43
|
+
make_title=lambda all_lines, _meta: _codex_title(all_lines),
|
|
44
|
+
make_source_metadata=lambda meta: {"cwd": meta["cwd"]} if meta.get("cwd") else None,
|
|
45
|
+
import_lines=_do_import,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _codex_session_meta(lines: list[dict]) -> dict[str, Any]:
|
|
50
|
+
for line in lines:
|
|
51
|
+
if line.get("type") != "session_meta":
|
|
52
|
+
continue
|
|
53
|
+
payload = line.get("payload")
|
|
54
|
+
return payload if isinstance(payload, dict) else {}
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _codex_model(meta: dict[str, Any]) -> str:
|
|
59
|
+
model = meta.get("model")
|
|
60
|
+
if isinstance(model, str) and model.strip():
|
|
61
|
+
return model.strip()
|
|
62
|
+
return "codex"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _codex_has_importable_content(lines: list[dict]) -> bool:
|
|
66
|
+
for line in lines:
|
|
67
|
+
payload = line.get("payload")
|
|
68
|
+
if not isinstance(payload, dict):
|
|
69
|
+
continue
|
|
70
|
+
if line.get("type") == "event_msg" and payload.get("type") in {"user_message", "agent_message"}:
|
|
71
|
+
if payload.get("message"):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _codex_first_user_line(lines: list[dict]) -> Optional[str]:
|
|
77
|
+
"""First non-empty line of the first ``event_msg``/``user_message``, or None."""
|
|
78
|
+
for line in lines:
|
|
79
|
+
payload = line.get("payload")
|
|
80
|
+
if not isinstance(payload, dict):
|
|
81
|
+
continue
|
|
82
|
+
if line.get("type") == "event_msg" and payload.get("type") == "user_message":
|
|
83
|
+
msg = str(payload.get("message") or "").strip()
|
|
84
|
+
if msg:
|
|
85
|
+
return next((part.strip() for part in msg.splitlines() if part.strip()), "")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _codex_title(lines: list[dict]) -> str:
|
|
90
|
+
meta = _codex_session_meta(lines)
|
|
91
|
+
thread_name = meta.get("thread_name")
|
|
92
|
+
if isinstance(thread_name, str) and thread_name.strip():
|
|
93
|
+
return thread_name.strip()[:100]
|
|
94
|
+
first = _codex_first_user_line(lines)
|
|
95
|
+
if first is not None:
|
|
96
|
+
return (first[:97] + "...") if len(first) > 100 else first
|
|
97
|
+
return "Codex Session"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _codex_call_input(payload: dict[str, Any], payload_type: str) -> Optional[dict[str, Any]]:
|
|
101
|
+
"""Extract a tool call's input dict, or None when there's nothing to store."""
|
|
102
|
+
if payload_type == "custom_tool_call":
|
|
103
|
+
raw_input = payload.get("input")
|
|
104
|
+
if isinstance(raw_input, dict):
|
|
105
|
+
return raw_input
|
|
106
|
+
if raw_input is not None:
|
|
107
|
+
return {"input": str(raw_input)}
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
raw_args = payload.get("arguments")
|
|
111
|
+
if isinstance(raw_args, str) and raw_args:
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(raw_args)
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
return {"arguments": raw_args}
|
|
116
|
+
return parsed if isinstance(parsed, dict) else None
|
|
117
|
+
if isinstance(raw_args, dict):
|
|
118
|
+
return raw_args
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _codex_call_maps(lines: list[dict]) -> tuple[dict[str, str], dict[str, dict[str, Any]]]:
|
|
123
|
+
names: dict[str, str] = {}
|
|
124
|
+
inputs: dict[str, dict[str, Any]] = {}
|
|
125
|
+
for line in lines:
|
|
126
|
+
payload = line.get("payload")
|
|
127
|
+
if not isinstance(payload, dict):
|
|
128
|
+
continue
|
|
129
|
+
if line.get("type") != "response_item":
|
|
130
|
+
continue
|
|
131
|
+
payload_type = payload.get("type")
|
|
132
|
+
if payload_type not in {"function_call", "custom_tool_call"}:
|
|
133
|
+
continue
|
|
134
|
+
call_id = payload.get("call_id")
|
|
135
|
+
if not isinstance(call_id, str) or not call_id:
|
|
136
|
+
continue
|
|
137
|
+
name = payload.get("name")
|
|
138
|
+
if isinstance(name, str) and name:
|
|
139
|
+
names[call_id] = name
|
|
140
|
+
call_input = _codex_call_input(payload, payload_type)
|
|
141
|
+
if call_input is not None:
|
|
142
|
+
inputs[call_id] = call_input
|
|
143
|
+
return names, inputs
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _codex_reasoning_text(payload: dict) -> str:
|
|
147
|
+
parts: list[str] = []
|
|
148
|
+
for key in ("summary", "content"):
|
|
149
|
+
v = payload.get(key)
|
|
150
|
+
if isinstance(v, list):
|
|
151
|
+
for b in v:
|
|
152
|
+
if isinstance(b, dict) and isinstance(b.get("text"), str):
|
|
153
|
+
parts.append(b["text"])
|
|
154
|
+
elif isinstance(b, str):
|
|
155
|
+
parts.append(b)
|
|
156
|
+
elif isinstance(v, str):
|
|
157
|
+
parts.append(v)
|
|
158
|
+
return "\n".join(t for t in parts if t).strip()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _codex_user_message(payload: dict[str, Any], ts: Optional[str]) -> Optional[dict[str, Any]]:
|
|
162
|
+
message = str(payload.get("message") or "")
|
|
163
|
+
if not message:
|
|
164
|
+
return None
|
|
165
|
+
return {
|
|
166
|
+
"role": "user",
|
|
167
|
+
"created_at": ts,
|
|
168
|
+
"content_text": message,
|
|
169
|
+
"content_blocks": [],
|
|
170
|
+
"provider_message_id": payload.get("turn_id") or ts or "",
|
|
171
|
+
"provider_data": {"provider": "codex"},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _codex_tool_use_block(
|
|
176
|
+
payload: dict[str, Any], ts: Optional[str],
|
|
177
|
+
call_names: dict[str, str], call_inputs: dict[str, dict[str, Any]],
|
|
178
|
+
) -> Optional[dict[str, Any]]:
|
|
179
|
+
call_id = payload.get("call_id")
|
|
180
|
+
if not isinstance(call_id, str) or not call_id:
|
|
181
|
+
return None
|
|
182
|
+
return {
|
|
183
|
+
"type": "tool_use",
|
|
184
|
+
"id": call_id,
|
|
185
|
+
"name": call_names.get(call_id, payload.get("name") or "unknown"),
|
|
186
|
+
"input": call_inputs.get(call_id, {}),
|
|
187
|
+
"start_timestamp": ts,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _codex_tool_result_block(
|
|
192
|
+
payload: dict[str, Any], ts: Optional[str], call_names: dict[str, str],
|
|
193
|
+
) -> Optional[dict[str, Any]]:
|
|
194
|
+
call_id = payload.get("call_id")
|
|
195
|
+
if not isinstance(call_id, str) or not call_id:
|
|
196
|
+
return None
|
|
197
|
+
output = payload.get("output", "")
|
|
198
|
+
if not isinstance(output, str):
|
|
199
|
+
output = json.dumps(output, default=str)
|
|
200
|
+
return {
|
|
201
|
+
"type": "tool_result",
|
|
202
|
+
"tool_use_id": call_id,
|
|
203
|
+
"name": call_names.get(call_id, "unknown"),
|
|
204
|
+
"content": output,
|
|
205
|
+
"is_error": False,
|
|
206
|
+
"start_timestamp": ts,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _codex_preserved_block(
|
|
211
|
+
line_type: Any, payload_type: Any, payload: dict[str, Any], ts: Optional[str],
|
|
212
|
+
) -> dict[str, Any]:
|
|
213
|
+
"""Wrap a codex kind the importer doesn't model as a content block that survives
|
|
214
|
+
import — Archivist, not Filter.
|
|
215
|
+
|
|
216
|
+
A ``message`` response_item, web-search results, images, an unmodeled
|
|
217
|
+
``event_msg`` (token_count, task_started/complete, reasoning deltas, errors), or a
|
|
218
|
+
future line/payload kind would otherwise be dropped into oblivion. Keep the raw
|
|
219
|
+
payload plus its type. The block's ``type`` is namespaced (``codex_<kind>``) so it
|
|
220
|
+
isn't one of the builder's modeled block types, which makes the builder preserve
|
|
221
|
+
it verbatim as a ``content_block`` event. The builder stores the whole block
|
|
222
|
+
under the event's ``data`` key (a dedup-content key), so the raw payload is
|
|
223
|
+
covered by the dedup hash and re-imports stay idempotent."""
|
|
224
|
+
kind = payload_type or line_type or "unknown"
|
|
225
|
+
return {
|
|
226
|
+
"type": f"codex_{kind}",
|
|
227
|
+
"codex_type": payload_type,
|
|
228
|
+
"codex_line_type": line_type,
|
|
229
|
+
"raw": payload,
|
|
230
|
+
"start_timestamp": ts,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _codex_assistant_block(
|
|
235
|
+
line_type: Any,
|
|
236
|
+
payload_type: Any,
|
|
237
|
+
payload: dict[str, Any],
|
|
238
|
+
ts: Optional[str],
|
|
239
|
+
call_names: dict[str, str],
|
|
240
|
+
call_inputs: dict[str, dict[str, Any]],
|
|
241
|
+
) -> Optional[dict[str, Any]]:
|
|
242
|
+
if line_type == "event_msg":
|
|
243
|
+
if payload_type == "agent_message":
|
|
244
|
+
message = str(payload.get("message") or "")
|
|
245
|
+
return {"type": "text", "text": message, "start_timestamp": ts} if message else None
|
|
246
|
+
# user_message is consumed upstream; any other event_msg kind is preserved
|
|
247
|
+
# rather than dropped.
|
|
248
|
+
return _codex_preserved_block(line_type, payload_type, payload, ts)
|
|
249
|
+
|
|
250
|
+
if line_type == "response_item":
|
|
251
|
+
if payload_type == "reasoning":
|
|
252
|
+
text = _codex_reasoning_text(payload)
|
|
253
|
+
return {"type": "thinking", "text": text, "start_timestamp": ts} if text else None
|
|
254
|
+
if payload_type in ("function_call", "custom_tool_call"):
|
|
255
|
+
return _codex_tool_use_block(payload, ts, call_names, call_inputs)
|
|
256
|
+
if payload_type in ("function_call_output", "custom_tool_call_output"):
|
|
257
|
+
return _codex_tool_result_block(payload, ts, call_names)
|
|
258
|
+
# A `message` response_item, web-search results, images, or any future kind.
|
|
259
|
+
return _codex_preserved_block(line_type, payload_type, payload, ts)
|
|
260
|
+
|
|
261
|
+
# `session_meta` is consumed as thread metadata (cwd/title/model) upstream; any
|
|
262
|
+
# OTHER line type (turn_context, compacted, a future kind) is preserved so no
|
|
263
|
+
# provider record is silently dropped on import.
|
|
264
|
+
if line_type == "session_meta":
|
|
265
|
+
return None
|
|
266
|
+
return _codex_preserved_block(line_type, payload_type, payload, ts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _build_codex_messages(
|
|
270
|
+
lines: list[dict],
|
|
271
|
+
meta: dict[str, Any],
|
|
272
|
+
call_names: dict[str, str],
|
|
273
|
+
call_inputs: dict[str, dict[str, Any]],
|
|
274
|
+
) -> list[dict[str, Any]]:
|
|
275
|
+
"""Assemble codex's interleaved line stream into canonical NormalizedMessages."""
|
|
276
|
+
messages: list[dict[str, Any]] = []
|
|
277
|
+
model = _codex_model(meta)
|
|
278
|
+
cur: Optional[dict[str, Any]] = None
|
|
279
|
+
|
|
280
|
+
def flush() -> None:
|
|
281
|
+
nonlocal cur
|
|
282
|
+
if cur is not None and cur["content_blocks"]:
|
|
283
|
+
messages.append(cur)
|
|
284
|
+
cur = None
|
|
285
|
+
|
|
286
|
+
def assistant(ts: Optional[str]) -> dict[str, Any]:
|
|
287
|
+
nonlocal cur
|
|
288
|
+
if cur is None:
|
|
289
|
+
cur = {
|
|
290
|
+
"role": "assistant",
|
|
291
|
+
"created_at": ts,
|
|
292
|
+
"content_text": "",
|
|
293
|
+
"content_blocks": [],
|
|
294
|
+
"provider_message_id": ts or "",
|
|
295
|
+
"provider_data": {"provider": "codex", "model": model},
|
|
296
|
+
}
|
|
297
|
+
return cur
|
|
298
|
+
|
|
299
|
+
for line in lines:
|
|
300
|
+
if not isinstance(line, dict):
|
|
301
|
+
continue
|
|
302
|
+
payload = line.get("payload")
|
|
303
|
+
if not isinstance(payload, dict):
|
|
304
|
+
continue
|
|
305
|
+
line_type = line.get("type")
|
|
306
|
+
payload_type = payload.get("type")
|
|
307
|
+
ts = line.get("timestamp")
|
|
308
|
+
|
|
309
|
+
if line_type == "event_msg" and payload_type == "user_message":
|
|
310
|
+
user_msg = _codex_user_message(payload, ts)
|
|
311
|
+
if user_msg is None:
|
|
312
|
+
continue
|
|
313
|
+
flush()
|
|
314
|
+
messages.append(user_msg)
|
|
315
|
+
continue
|
|
316
|
+
|
|
317
|
+
block = _codex_assistant_block(
|
|
318
|
+
line_type, payload_type, payload, ts, call_names, call_inputs
|
|
319
|
+
)
|
|
320
|
+
if block is not None:
|
|
321
|
+
assistant(ts)["content_blocks"].append(block)
|
|
322
|
+
|
|
323
|
+
flush()
|
|
324
|
+
return messages
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Claude Cowork (local agent-mode) session import.
|
|
2
|
+
|
|
3
|
+
Cowork writes a Claude-Code-shaped ``audit.jsonl`` per agent task under
|
|
4
|
+
``~/Library/Application Support/Claude/local-agent-mode-sessions/<user>/<org>/local_<id>/``,
|
|
5
|
+
with a sibling ``local_<id>.json`` carrying the human title. Two quirks vs a plain
|
|
6
|
+
CC session, handled by :func:`_normalize_cowork_line`:
|
|
7
|
+
|
|
8
|
+
- every line carries an ``_audit_timestamp``; ``timestamp`` (which the parser keys
|
|
9
|
+
``created_at`` + dedup off) is only on a subset — so backfill it; and
|
|
10
|
+
- each user submission appears twice (once at submit, once as an ``isReplay`` echo
|
|
11
|
+
with its own timestamp that defeats the parser's dedup) — so replays are rewritten
|
|
12
|
+
to a type the parser ignores, keeping the line-count watermark aligned with the file.
|
|
13
|
+
|
|
14
|
+
The normalized lines then run through the standard CC import path under
|
|
15
|
+
``source="cowork"`` (so continuation detection, which is CC-only, is skipped), with
|
|
16
|
+
the title taken from the metadata file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from thread_archive._thread_import import DefaultEventBuilder
|
|
27
|
+
from thread_archive._thread_import.parsers.claude_code import ClaudeCodeParser
|
|
28
|
+
|
|
29
|
+
from .._store import get_session
|
|
30
|
+
from ._read import parse_session_lines, read_source_bytes
|
|
31
|
+
from ._result import IncrementalImportResult
|
|
32
|
+
from .claude_code import _import_cc
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
SOURCE = "cowork"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_cowork_line(line: dict) -> dict:
|
|
40
|
+
"""Backfill ``timestamp`` from ``_audit_timestamp``; neuter ``isReplay`` echoes.
|
|
41
|
+
|
|
42
|
+
A replay is rewritten to ``cowork_replay`` (a type the CC parser ignores) rather
|
|
43
|
+
than dropped, so the line-count import watermark stays aligned with the raw file."""
|
|
44
|
+
if line.get("isReplay"):
|
|
45
|
+
return {"type": "cowork_replay", "_audit_timestamp": line.get("_audit_timestamp")}
|
|
46
|
+
ts = line.get("timestamp")
|
|
47
|
+
audit_ts = line.get("_audit_timestamp")
|
|
48
|
+
if not ts and audit_ts:
|
|
49
|
+
return {**line, "timestamp": audit_ts}
|
|
50
|
+
return line
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_cowork_title(metadata_path: Optional[Path]) -> Optional[str]:
|
|
54
|
+
"""The human title from a sibling ``local_{id}.json`` Cowork metadata file."""
|
|
55
|
+
if metadata_path is None or not metadata_path.exists():
|
|
56
|
+
return None
|
|
57
|
+
try:
|
|
58
|
+
with open(metadata_path, "r", encoding="utf-8") as f:
|
|
59
|
+
data = json.load(f)
|
|
60
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
61
|
+
logger.warning("Cowork metadata read error %s: %s", metadata_path.name, e)
|
|
62
|
+
return None
|
|
63
|
+
title = data.get("title")
|
|
64
|
+
if isinstance(title, str) and title.strip():
|
|
65
|
+
return title.strip()[:200]
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def import_cowork_session_incremental(
|
|
70
|
+
audit_path,
|
|
71
|
+
source_id: str,
|
|
72
|
+
metadata_path=None,
|
|
73
|
+
parser: Optional[ClaudeCodeParser] = None,
|
|
74
|
+
builder: Optional[DefaultEventBuilder] = None,
|
|
75
|
+
*,
|
|
76
|
+
session=None,
|
|
77
|
+
) -> IncrementalImportResult:
|
|
78
|
+
"""Import a Cowork ``audit.jsonl`` incrementally (normalize → CC import path)."""
|
|
79
|
+
audit_path = Path(audit_path)
|
|
80
|
+
if not audit_path.exists():
|
|
81
|
+
raise FileNotFoundError(f"Cowork audit file not found: {audit_path}")
|
|
82
|
+
|
|
83
|
+
parser = parser or ClaudeCodeParser()
|
|
84
|
+
builder = builder or DefaultEventBuilder()
|
|
85
|
+
# The cursor proves its append against the raw file bytes; the lines it cursors are
|
|
86
|
+
# the normalized ones (a 1:1 map over the parsed lines, so the counts stay aligned).
|
|
87
|
+
source_bytes = read_source_bytes(audit_path)
|
|
88
|
+
# A bare-scalar JSONL line parses to a non-dict; skip it so `.get()` can't crash.
|
|
89
|
+
lines = [
|
|
90
|
+
_normalize_cowork_line(ln)
|
|
91
|
+
for ln in parse_session_lines(source_bytes, audit_path.name)
|
|
92
|
+
if isinstance(ln, dict)
|
|
93
|
+
]
|
|
94
|
+
title = _read_cowork_title(Path(metadata_path)) if metadata_path is not None else None
|
|
95
|
+
|
|
96
|
+
if session is not None:
|
|
97
|
+
return _import_cc(
|
|
98
|
+
session, source_id, lines, source_bytes, parser, builder,
|
|
99
|
+
source=SOURCE, title_override=title,
|
|
100
|
+
)
|
|
101
|
+
with get_session() as s:
|
|
102
|
+
result = _import_cc(
|
|
103
|
+
s, source_id, lines, source_bytes, parser, builder,
|
|
104
|
+
source=SOURCE, title_override=title,
|
|
105
|
+
)
|
|
106
|
+
s.commit()
|
|
107
|
+
return result
|