Skip to content

Commit 882fb3f

Browse files
authored
Image parsing improvements (#42)
* Fuse anonymizer and secret redaction traversal * Fix image handling for Codex CLI in both user message and tool call * Fix image handling for Gemini CLI in both user message and tool call with new jsonl format * Fix image handling for OpenCode in both user message and tool call * Deduplicate image data for Claude Code * Let each provider define own keys exempted from anonymization * Keep text file content in user messages for OpenCode * Remove RELEASE_NOTES.md . Just write it in GitHub Releases.
1 parent 8649935 commit 882fb3f

20 files changed

Lines changed: 1373 additions & 180 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ The auto-generated HF README includes:
293293
- Did you export all data, especially:
294294
- tool call inputs and outputs
295295
- long inputs and outputs that may be saved somewhere else
296-
- binary content (may be encoded as base64) such as images. We do not apply anonymizer on binary content
296+
- binary content (may be encoded as base64) such as images, in both user messages and tool calls. We do not apply anonymizer on binary content
297297
- subagents
298298
- Does the coding agent automatically delete old sessions? How to prevent this?
299299

RELEASE_NOTES.md

Lines changed: 0 additions & 20 deletions
This file was deleted.

dataclaw/_cli/exporting.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
from .._workers import configured_workers
1818
from ..anonymizer import Anonymizer
1919
from ..parser import iter_project_sessions
20-
from ..secrets import redact_session
20+
from ..providers import get_provider_non_anon_string_keys
21+
from ..secrets import transform_session
2122
from ..session_tasks import ExportSessionTask, build_export_session_tasks, parse_export_session_task
2223
from .common import HF_TAG, REPO_URL, SKILL_URL, _format_token_count, _provider_dataset_tags
2324

@@ -197,8 +198,13 @@ def _export_session_task_worker(payload) -> _WorkerSessionResult:
197198
if not model or model == "<synthetic>":
198199
return _WorkerSessionResult(project_index=task.project_index, skipped_model=True)
199200

201+
session, n_redacted = transform_session(
202+
session,
203+
anonymizer,
204+
custom_strings=custom_strings,
205+
non_anon_string_keys=get_provider_non_anon_string_keys(task.source),
206+
)
200207
fingerprint = _gemini_dedupe_fingerprint(session, task.source)
201-
session, n_redacted = redact_session(session, custom_strings=custom_strings)
202208
stats = session.get("stats", {})
203209
input_tokens, output_tokens = _token_totals(stats)
204210
has_token_stats = isinstance(stats, dict) and ("input_tokens" in stats or "output_tokens" in stats)
@@ -283,13 +289,18 @@ def _export_to_jsonl_serial(
283289
skipped += 1
284290
continue
285291

292+
session, n_redacted = transform_session(
293+
session,
294+
anonymizer,
295+
custom_strings=custom_strings,
296+
non_anon_string_keys=get_provider_non_anon_string_keys(source),
297+
)
298+
total_redactions += n_redacted
299+
286300
fingerprint = _gemini_dedupe_fingerprint(session, source)
287301
if fingerprint is not None and fingerprint in seen_fingerprints:
288302
continue
289303

290-
session, n_redacted = redact_session(session, custom_strings=custom_strings)
291-
total_redactions += n_redacted
292-
293304
if fingerprint is not None:
294305
seen_fingerprints.add(fingerprint)
295306

dataclaw/parsers/claude.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
SOURCE = "claude"
2323
CLAUDE_DIR = Path.home() / ".claude"
2424
PROJECTS_DIR = CLAUDE_DIR / "projects"
25+
NON_ANON_STRING_KEYS = frozenset({"sourceToolAssistantUUID"})
2526

2627

2728
def discover_projects(projects_dir: Path | None = None) -> list[dict]:
@@ -181,7 +182,7 @@ def build_tool_result_output(
181182
if text is None:
182183
text = extract_tool_result_text(entry.get("toolUseResult"))
183184

184-
raw_result = sanitize_tool_use_result(entry.get("toolUseResult"), text)
185+
raw_result = sanitize_tool_use_result(entry.get("toolUseResult"), text, raw_content)
185186
source_tool_uuid = entry.get("sourceToolAssistantUUID")
186187
if isinstance(source_tool_uuid, str) and source_tool_uuid:
187188
if raw_result is None:
@@ -283,6 +284,7 @@ def normalize_tool_result_text(value: Any) -> str | None:
283284
def sanitize_tool_use_result(
284285
tool_use_result: Any,
285286
text: str | None,
287+
raw_content: Any = None,
286288
) -> dict[str, Any] | None:
287289
if tool_use_result is None:
288290
return None
@@ -295,7 +297,7 @@ def sanitize_tool_use_result(
295297
return None
296298
return {"text": sanitized_text}
297299

298-
sanitized = tool_use_result
300+
sanitized = drop_duplicate_tool_result_blobs(tool_use_result, raw_content)
299301
sanitized = drop_redundant_result_fields(sanitized)
300302
sanitized = drop_duplicate_text_fields(sanitized, text)
301303
pruned = prune_empty_values(sanitized)
@@ -306,6 +308,39 @@ def sanitize_tool_use_result(
306308
return {"value": pruned}
307309

308310

311+
def drop_duplicate_tool_result_blobs(tool_use_result: Any, raw_content: Any) -> Any:
312+
duplicate_blobs = collect_tool_result_blobs(raw_content)
313+
if not duplicate_blobs:
314+
return tool_use_result
315+
return drop_matching_base64_fields(tool_use_result, duplicate_blobs)
316+
317+
318+
def collect_tool_result_blobs(value: Any) -> set[str]:
319+
blobs: set[str] = set()
320+
if isinstance(value, dict):
321+
source = value.get("source")
322+
if isinstance(source, dict) and source.get("type") == "base64":
323+
data = source.get("data")
324+
if isinstance(data, str) and data:
325+
blobs.add(data)
326+
for item in value.values():
327+
blobs.update(collect_tool_result_blobs(item))
328+
elif isinstance(value, list):
329+
for item in value:
330+
blobs.update(collect_tool_result_blobs(item))
331+
return blobs
332+
333+
334+
def drop_matching_base64_fields(value: Any, duplicate_blobs: set[str], key: str | None = None) -> Any:
335+
if isinstance(value, dict):
336+
return {k: drop_matching_base64_fields(v, duplicate_blobs, k) for k, v in value.items()}
337+
if isinstance(value, list):
338+
return [drop_matching_base64_fields(item, duplicate_blobs) for item in value]
339+
if isinstance(value, str) and key == "base64" and value in duplicate_blobs:
340+
return None
341+
return value
342+
343+
309344
def drop_redundant_result_fields(value: Any) -> Any:
310345
if isinstance(value, dict):
311346
redundant_keys = set()
@@ -422,7 +457,7 @@ def parse_session_file(
422457
except OSError:
423458
return None
424459

425-
return make_session_result(metadata, messages, stats, anonymizer=anonymizer)
460+
return make_session_result(metadata, messages, stats)
426461

427462

428463
def find_subagent_sessions(project_dir: Path) -> list[Path]:
@@ -490,7 +525,7 @@ def parse_subagent_session(
490525
return None
491526

492527
metadata["session_id"] = resolve_subagent_session_id(session_dir, metadata["session_id"])
493-
return make_session_result(metadata, messages, stats, anonymizer=anonymizer)
528+
return make_session_result(metadata, messages, stats)
494529

495530

496531
def resolve_subagent_session_id(session_dir: Path, session_id: str) -> str:

dataclaw/parsers/codex.py

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import dataclasses
22
import logging
3+
import posixpath
34
from collections.abc import Iterable
4-
from pathlib import Path
5+
from pathlib import Path, PurePosixPath, PureWindowsPath
56
from typing import Any
67

78
from .. import _json as json
@@ -28,6 +29,7 @@
2829
CODEX_DIR = Path.home() / ".codex"
2930
CODEX_SESSIONS_DIR = CODEX_DIR / "sessions"
3031
CODEX_ARCHIVED_DIR = CODEX_DIR / "archived_sessions"
32+
NON_ANON_STRING_KEYS = frozenset({"wall_time"})
3133
UNKNOWN_CODEX_CWD = "<unknown-cwd>"
3234

3335
_PROJECT_INDEX: dict[str, list[Path]] = {}
@@ -174,6 +176,11 @@ def _build_codex_tool_result(payload: dict[str, Any]) -> dict[str, Any] | None:
174176

175177
if payload_type == "function_call_output":
176178
raw = payload.get("output", "")
179+
if isinstance(raw, list):
180+
return {"output": _build_codex_structured_tool_output(raw), "status": "success"}
181+
if not isinstance(raw, str):
182+
return {"output": {"raw": raw}, "status": "success"}
183+
177184
out: dict[str, Any] = {}
178185
lines = raw.splitlines()
179186
output_lines: list[str] = []
@@ -215,6 +222,40 @@ def _build_codex_tool_result(payload: dict[str, Any]) -> dict[str, Any] | None:
215222
return None
216223

217224

225+
def _build_codex_structured_tool_output(parts: list[Any]) -> dict[str, Any]:
226+
out: dict[str, Any] = {}
227+
text_parts: list[str] = []
228+
raw_parts: list[Any] = []
229+
230+
for part in parts:
231+
if not isinstance(part, dict):
232+
raw_parts.append(part)
233+
continue
234+
235+
part_type = part.get("type")
236+
if part_type in {"text", "output_text"}:
237+
text = part.get("text")
238+
if isinstance(text, str) and text.strip():
239+
text_parts.append(text.strip())
240+
raw_parts.append(part)
241+
continue
242+
243+
if part_type == "input_image":
244+
image_url = part.get("image_url")
245+
if isinstance(image_url, str) and image_url:
246+
image_part = _build_codex_image_part(image_url)
247+
if image_part is not None:
248+
raw_parts.append(image_part)
249+
continue
250+
raw_parts.append(part)
251+
252+
if text_parts:
253+
out["text"] = "\n\n".join(text_parts)
254+
if raw_parts:
255+
out["raw"] = {"content": raw_parts}
256+
return out
257+
258+
218259
def parse_session_file(
219260
filepath: Path,
220261
anonymizer: Anonymizer,
@@ -283,7 +324,7 @@ def parse_session_file(
283324
else:
284325
state.metadata["model"] = "codex-unknown"
285326

286-
return make_session_result(state.metadata, state.messages, state.stats, anonymizer=anonymizer)
327+
return make_session_result(state.metadata, state.messages, state.stats)
287328

288329

289330
def handle_session_meta(
@@ -353,18 +394,45 @@ def _build_codex_image_part(image_url: str) -> dict[str, Any] | None:
353394

354395

355396
def _build_codex_local_image_part(image_path: str, state: CodexParseState) -> dict[str, Any]:
356-
path = Path(image_path)
357-
if not path.is_absolute() and state.raw_cwd != UNKNOWN_CODEX_CWD:
358-
path = Path(state.raw_cwd) / path
397+
path = _resolve_codex_local_path(image_path, state.raw_cwd)
359398
return {
360399
"type": "image",
361400
"source": {
362401
"type": "url",
363-
"url": f"file://{path}",
402+
"url": _codex_file_url(path),
364403
},
365404
}
366405

367406

407+
def _is_windows_absolute_path(path: str) -> bool:
408+
return PureWindowsPath(path).is_absolute()
409+
410+
411+
def _is_posix_absolute_path(path: str) -> bool:
412+
return PurePosixPath(path).is_absolute()
413+
414+
415+
def _resolve_codex_local_path(image_path: str, cwd: str) -> str:
416+
if _is_windows_absolute_path(image_path) or _is_posix_absolute_path(image_path):
417+
return image_path
418+
if cwd == UNKNOWN_CODEX_CWD:
419+
return image_path
420+
if _is_windows_absolute_path(cwd):
421+
return str(PureWindowsPath(cwd) / image_path)
422+
if _is_posix_absolute_path(cwd):
423+
return posixpath.join(cwd, image_path.replace("\\", "/"))
424+
return str(Path(cwd) / image_path)
425+
426+
427+
def _codex_file_url(path: str) -> str:
428+
if _is_windows_absolute_path(path):
429+
return PureWindowsPath(path).as_uri()
430+
if _is_posix_absolute_path(path):
431+
return PurePosixPath(path.replace("\\", "/")).as_uri()
432+
normalized_path = path.replace("\\", "/")
433+
return f"file://{normalized_path}"
434+
435+
368436
def _extract_response_user_content_parts(payload: dict[str, Any]) -> list[dict[str, Any]]:
369437
content_parts: list[dict[str, Any]] = []
370438
for part in payload.get("content", []):

0 commit comments

Comments
 (0)