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