1493 lines
60 KiB
Python
1493 lines
60 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared local session-handoff engine and backwards-compatible Claude CLI.
|
|
|
|
Native readers and SDK adapters supply complete conversation items. This engine
|
|
validates and exports their bundles, then uses Codex's native external-session
|
|
importer to create a task with visible historical turns. The legacy command
|
|
still defaults to reading a Claude Code transcript; session_handoff.py requires
|
|
an explicit source host or a neutral bundle.
|
|
|
|
No model generates a handoff summary. Large imports may use Codex's native
|
|
compaction before the new task is returned.
|
|
"""
|
|
|
|
# Adapted from mem0ai/memo at aeeb1593284d1d2fca3b4bcf1e32ea10f71df549 (Apache-2.0).
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import os
|
|
import queue
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from dataclasses import asdict, dataclass, replace
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
FORMAT_VERSION = "mem0.session-handoff.v1"
|
|
DEFAULT_CODEX_HOME = Path(os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
|
|
DEFAULT_BUNDLE_DIR = Path.home() / ".mem0" / "handoffs"
|
|
IMPORT_COMPLETED_NOTIFICATION = "externalAgentConfig/import/completed"
|
|
IMAGE_EXTENSIONS = {
|
|
"image/gif": "gif",
|
|
"image/jpeg": "jpg",
|
|
"image/png": "png",
|
|
"image/webp": "webp",
|
|
}
|
|
|
|
|
|
class HandoffError(RuntimeError):
|
|
"""A source session cannot be transferred without losing state."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SourceInfo:
|
|
path: str
|
|
sha256: str
|
|
session_id: str
|
|
title: str
|
|
cwd: str
|
|
leaf_uuid: str
|
|
compact_boundary_uuid: str | None
|
|
first_imported_uuid: str
|
|
last_imported_uuid: str
|
|
codex_cwd: str | None = None
|
|
host: str = "claude-code"
|
|
|
|
|
|
@dataclass
|
|
class HandoffPlan:
|
|
source: SourceInfo
|
|
items: list[dict[str, Any]]
|
|
source_records: int
|
|
active_records: int
|
|
imported_records: int
|
|
hidden_reasoning_blocks_skipped: int
|
|
approximate_tokens: int
|
|
warnings: list[str]
|
|
|
|
def bundle(self) -> dict[str, Any]:
|
|
return {
|
|
"format": FORMAT_VERSION,
|
|
"source": asdict(self.source),
|
|
"items": self.items,
|
|
"counts": {
|
|
"source_records": self.source_records,
|
|
"active_records": self.active_records,
|
|
"imported_records": self.imported_records,
|
|
"responses_items": len(self.items),
|
|
"hidden_reasoning_blocks_skipped": self.hidden_reasoning_blocks_skipped,
|
|
"approximate_tokens": self.approximate_tokens,
|
|
},
|
|
"warnings": self.warnings,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CodexContextLimits:
|
|
model: str
|
|
context_window: int
|
|
usable_context_window: int
|
|
auto_compact_token_limit: int
|
|
max_context_window: int
|
|
max_usable_context_window: int
|
|
max_auto_compact_token_limit: int
|
|
|
|
|
|
def _stable_jsonl(path: Path) -> tuple[list[dict[str, Any]], str]:
|
|
before = path.stat()
|
|
raw = path.read_bytes()
|
|
after = path.stat()
|
|
if (before.st_size, before.st_mtime_ns) != (after.st_size, after.st_mtime_ns):
|
|
raise HandoffError(f"Source session changed while it was being read: {path}")
|
|
if raw and not raw.endswith(b"\n"):
|
|
raise HandoffError(
|
|
"The final JSONL record is incomplete. Finish or stop the active source response before transferring it."
|
|
)
|
|
|
|
records: list[dict[str, Any]] = []
|
|
for line_number, line in enumerate(raw.splitlines(), 1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise HandoffError(f"Invalid source JSONL at {path}:{line_number}: {exc}") from exc
|
|
if not isinstance(record, dict):
|
|
raise HandoffError(f"Source JSONL record is not an object at {path}:{line_number}.")
|
|
records.append(record)
|
|
if not records:
|
|
raise HandoffError(f"Source session is empty: {path}")
|
|
return records, hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def _resolve_session(value: str, projects_dir: Path) -> Path:
|
|
supplied = Path(value).expanduser()
|
|
if supplied.is_file():
|
|
return supplied.resolve()
|
|
|
|
matches = list(projects_dir.glob(f"*/{value}.jsonl"))
|
|
if not matches:
|
|
raise HandoffError(
|
|
f"No Claude session named {value!r} exists below {projects_dir}. "
|
|
"Pass the session ID or its full JSONL path."
|
|
)
|
|
if len(matches) != 1:
|
|
joined = "\n".join(f" {path}" for path in matches)
|
|
raise HandoffError(f"Session ID {value!r} is ambiguous:\n{joined}")
|
|
return matches[0].resolve()
|
|
|
|
|
|
def _active_chain(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
with_uuid = [
|
|
record for record in records if isinstance(record.get("uuid"), str) and record.get("isSidechain") is not True
|
|
]
|
|
if not with_uuid:
|
|
raise HandoffError("Claude session has no main-agent conversation records.")
|
|
|
|
by_uuid = {record["uuid"]: record for record in with_uuid}
|
|
leaf = with_uuid[-1]
|
|
chain: list[dict[str, Any]] = []
|
|
seen: set[str] = set()
|
|
current: dict[str, Any] | None = leaf
|
|
while current is not None:
|
|
uuid = current["uuid"]
|
|
if uuid in seen:
|
|
raise HandoffError(f"Claude session contains a parent cycle at {uuid}.")
|
|
seen.add(uuid)
|
|
chain.append(current)
|
|
parent_uuid = current.get("parentUuid")
|
|
if parent_uuid is None:
|
|
break
|
|
current = by_uuid.get(parent_uuid)
|
|
if current is None:
|
|
raise HandoffError(f"Claude's active branch references missing parent {parent_uuid}.")
|
|
chain.reverse()
|
|
return chain
|
|
|
|
|
|
def _after_latest_compaction(
|
|
chain: list[dict[str, Any]],
|
|
) -> tuple[list[dict[str, Any]], str | None]:
|
|
compact_index: int | None = None
|
|
for index, record in enumerate(chain):
|
|
if record.get("type") == "system" and record.get("subtype") == "compact_boundary":
|
|
compact_index = index
|
|
if compact_index is None:
|
|
imported = chain
|
|
compact_uuid = None
|
|
else:
|
|
imported = chain[compact_index + 1 :]
|
|
compact_uuid = chain[compact_index]["uuid"]
|
|
if not imported or imported[0].get("isCompactSummary") is not True:
|
|
raise HandoffError(f"Claude compaction {compact_uuid} has no following compact summary.")
|
|
imported = [record for record in imported if record.get("type") != "system"]
|
|
if not imported:
|
|
raise HandoffError("Claude's active state contains no transferable records.")
|
|
return imported, compact_uuid
|
|
|
|
|
|
def _tool_result_ids(record: dict[str, Any]) -> set[str]:
|
|
if record.get("type") != "user":
|
|
return set()
|
|
content = (record.get("message") or {}).get("content")
|
|
if not isinstance(content, list):
|
|
return set()
|
|
return {
|
|
str(block["tool_use_id"])
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "tool_result" and block.get("tool_use_id")
|
|
}
|
|
|
|
|
|
def _tool_call_ids(records: list[dict[str, Any]]) -> set[str]:
|
|
call_ids: set[str] = set()
|
|
for record in records:
|
|
if record.get("type") != "assistant":
|
|
continue
|
|
content = (record.get("message") or {}).get("content")
|
|
if not isinstance(content, list):
|
|
continue
|
|
call_ids.update(
|
|
str(block["id"])
|
|
for block in content
|
|
if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("id")
|
|
)
|
|
return call_ids
|
|
|
|
|
|
def _merge_parallel_tool_results(
|
|
active_records: list[dict[str, Any]], all_records: list[dict[str, Any]]
|
|
) -> list[dict[str, Any]]:
|
|
"""Restore sibling tool results that Claude stores outside the parent chain.
|
|
|
|
Parallel Claude tool calls form a fork: later calls remain on the parent
|
|
chain, while earlier results can be sibling records. Claude sends all of
|
|
those results back to the model. Insert them together immediately after the
|
|
assistant response that issued the calls.
|
|
"""
|
|
results_by_call: dict[str, list[tuple[int, dict[str, Any]]]] = {}
|
|
for source_index, record in enumerate(all_records):
|
|
for call_id in _tool_result_ids(record):
|
|
results_by_call.setdefault(call_id, []).append((source_index, record))
|
|
|
|
merged: list[dict[str, Any]] = []
|
|
inserted_result_uuids: set[str] = set()
|
|
index = 0
|
|
while index < len(active_records):
|
|
record = active_records[index]
|
|
record_uuid = str(record.get("uuid") or "")
|
|
if record_uuid in inserted_result_uuids:
|
|
index += 1
|
|
continue
|
|
if record.get("type") != "assistant":
|
|
merged.append(record)
|
|
index += 1
|
|
continue
|
|
|
|
message_id = (record.get("message") or {}).get("id")
|
|
group = [record]
|
|
index += 1
|
|
while index < len(active_records):
|
|
candidate = active_records[index]
|
|
candidate_id = (candidate.get("message") or {}).get("id")
|
|
if candidate.get("type") != "assistant" or not message_id or candidate_id != message_id:
|
|
break
|
|
group.append(candidate)
|
|
index += 1
|
|
merged.extend(group)
|
|
|
|
matching_results: list[tuple[int, dict[str, Any]]] = []
|
|
for call_id in _tool_call_ids(group):
|
|
matching_results.extend(results_by_call.get(call_id, []))
|
|
for _, result in sorted(matching_results, key=lambda pair: pair[0]):
|
|
result_uuid = str(result.get("uuid") or "")
|
|
if result_uuid and result_uuid not in inserted_result_uuids:
|
|
merged.append(result)
|
|
inserted_result_uuids.add(result_uuid)
|
|
return merged
|
|
|
|
|
|
def _image_payload(source: Any, context: str) -> tuple[str, str]:
|
|
if not isinstance(source, dict) or source.get("type") != "base64":
|
|
raise HandoffError(f"{context} is not stored as transferable base64 data.")
|
|
media_type = str(source.get("media_type") or "").lower()
|
|
data = source.get("data")
|
|
if media_type not in IMAGE_EXTENSIONS or not isinstance(data, str) or not data:
|
|
raise HandoffError(f"{context} has an unsupported or missing image type.")
|
|
return media_type, data
|
|
|
|
|
|
def _data_url_payload(image_url: Any, context: str) -> tuple[str, str]:
|
|
if not isinstance(image_url, str):
|
|
raise HandoffError(f"{context} has no transferable image data.")
|
|
match = re.fullmatch(r"data:([^;,]+);base64,(.+)", image_url, flags=re.DOTALL)
|
|
if not match:
|
|
raise HandoffError(f"{context} is not stored as transferable base64 data.")
|
|
media_type = match.group(1).lower()
|
|
if media_type not in IMAGE_EXTENSIONS:
|
|
raise HandoffError(f"{context} has unsupported image type {media_type!r}.")
|
|
return media_type, match.group(2)
|
|
|
|
|
|
def _save_image(
|
|
media_type: str,
|
|
encoded: str,
|
|
asset_dir: Path,
|
|
context: str,
|
|
) -> Path:
|
|
try:
|
|
payload = base64.b64decode(encoded, validate=True)
|
|
except (binascii.Error, ValueError) as exc:
|
|
raise HandoffError(f"{context} contains invalid base64 image data.") from exc
|
|
if not payload:
|
|
raise HandoffError(f"{context} contains an empty image.")
|
|
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
asset_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
path = asset_dir / f"{digest}.{IMAGE_EXTENSIONS[media_type]}"
|
|
if path.exists():
|
|
if hashlib.sha256(path.read_bytes()).hexdigest() != digest:
|
|
raise HandoffError(f"The existing handoff image is corrupted: {path}")
|
|
return path
|
|
|
|
descriptor, filename = tempfile.mkstemp(prefix=f".{path.name}.", dir=asset_dir)
|
|
temporary = Path(filename)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as stream:
|
|
stream.write(payload)
|
|
os.replace(temporary, path)
|
|
except OSError as exc:
|
|
temporary.unlink(missing_ok=True)
|
|
raise HandoffError(f"Could not save the handoff image at {path}: {exc}") from exc
|
|
return path
|
|
|
|
|
|
def _image_reference(
|
|
media_type: str,
|
|
encoded: str,
|
|
asset_dir: Path,
|
|
context: str,
|
|
) -> str:
|
|
path = _save_image(media_type, encoded, asset_dir, context)
|
|
return f"[Image saved at {path}]"
|
|
|
|
|
|
def _tool_result_text(value: Any, asset_dir: Path, context: str) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, (int, float, bool)):
|
|
return str(value)
|
|
if isinstance(value, list):
|
|
parts: list[str] = []
|
|
for part in value:
|
|
if isinstance(part, dict) and part.get("type") == "text":
|
|
parts.append(str(part.get("text", "")))
|
|
elif isinstance(part, dict) and part.get("type") == "image":
|
|
media_type, encoded = _image_payload(part.get("source"), context)
|
|
parts.append(_image_reference(media_type, encoded, asset_dir, context))
|
|
else:
|
|
parts.append(json.dumps(part, ensure_ascii=False, separators=(",", ":")))
|
|
return "\n".join(part for part in parts if part)
|
|
if isinstance(value, dict) and value.get("type") == "image":
|
|
media_type, encoded = _image_payload(value.get("source"), context)
|
|
return _image_reference(media_type, encoded, asset_dir, context)
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _message(role: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
|
|
return {"type": "message", "role": role, "content": parts}
|
|
|
|
|
|
def _attachment_item(record: dict[str, Any]) -> dict[str, Any] | None:
|
|
attachment = record.get("attachment")
|
|
if not isinstance(attachment, dict):
|
|
raise HandoffError(f"Claude attachment {record.get('uuid')} has no payload.")
|
|
|
|
attachment_type = attachment.get("type")
|
|
filename = str(attachment.get("filename") or attachment.get("displayPath") or "unknown")
|
|
content = attachment.get("content")
|
|
if attachment_type == "file" and isinstance(content, dict):
|
|
file_payload = content.get("file") if content.get("type") == "text" else None
|
|
if isinstance(file_payload, dict) and isinstance(file_payload.get("content"), str):
|
|
text = file_payload["content"]
|
|
display = str(file_payload.get("filePath") or filename)
|
|
wrapped = f'<claude_attachment path="{display}">\n{text}\n</claude_attachment>'
|
|
return _message("user", [{"type": "input_text", "text": wrapped}])
|
|
|
|
if attachment_type == "image" and isinstance(content, dict):
|
|
image_url = content.get("image_url") or content.get("data")
|
|
if isinstance(image_url, str) and image_url.startswith("data:"):
|
|
return _message("user", [{"type": "input_image", "image_url": image_url}])
|
|
|
|
if attachment_type in {"file", "image"}:
|
|
raise HandoffError(f"Claude {attachment_type} attachment {record.get('uuid')} has an unsupported payload.")
|
|
|
|
# Claude also records its own skill list, tool availability, permissions,
|
|
# token reminders, hooks, and task status as attachments. Those configure
|
|
# Claude's harness; they are not part of the user's project conversation and
|
|
# must not become user messages in Codex.
|
|
return None
|
|
|
|
|
|
def _assistant_items(records: list[dict[str, Any]], calls: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
|
|
items: list[dict[str, Any]] = []
|
|
skipped_reasoning = 0
|
|
text_parts: list[dict[str, Any]] = []
|
|
|
|
def flush_text() -> None:
|
|
if text_parts:
|
|
items.append(_message("assistant", list(text_parts)))
|
|
text_parts.clear()
|
|
|
|
for record in records:
|
|
content = (record.get("message") or {}).get("content", [])
|
|
if isinstance(content, str):
|
|
text_parts.append({"type": "output_text", "text": content})
|
|
continue
|
|
if not isinstance(content, list):
|
|
raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid content.")
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
raise HandoffError(f"Claude assistant record {record.get('uuid')} has invalid block.")
|
|
kind = block.get("type")
|
|
if kind == "thinking" or kind == "redacted_thinking":
|
|
skipped_reasoning += 1
|
|
continue
|
|
if kind == "text":
|
|
text_parts.append({"type": "output_text", "text": str(block.get("text", ""))})
|
|
continue
|
|
if kind == "tool_use":
|
|
flush_text()
|
|
call_id = str(block.get("id") or "")
|
|
name = str(block.get("name") or "")
|
|
if not call_id or not name:
|
|
raise HandoffError(f"Claude tool call in {record.get('uuid')} has no ID or name.")
|
|
if call_id in calls:
|
|
raise HandoffError(f"Claude tool call ID is duplicated: {call_id}")
|
|
calls[call_id] = name
|
|
items.append(
|
|
{
|
|
"type": "function_call",
|
|
"call_id": call_id,
|
|
"name": name,
|
|
"arguments": json.dumps(
|
|
block.get("input", {}),
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
),
|
|
}
|
|
)
|
|
continue
|
|
raise HandoffError(f"Unsupported Claude assistant block {kind!r} in {record.get('uuid')}.")
|
|
flush_text()
|
|
return items, skipped_reasoning
|
|
|
|
|
|
def _user_items(record: dict[str, Any], calls: dict[str, str], completed_calls: set[str]) -> list[dict[str, Any]]:
|
|
if record.get("isMeta") is True:
|
|
return []
|
|
content = (record.get("message") or {}).get("content")
|
|
if isinstance(content, str):
|
|
return [_message("user", [{"type": "input_text", "text": content}])]
|
|
if not isinstance(content, list):
|
|
raise HandoffError(f"Claude user record {record.get('uuid')} has invalid content.")
|
|
|
|
items: list[dict[str, Any]] = []
|
|
user_parts: list[dict[str, Any]] = []
|
|
|
|
def flush_user() -> None:
|
|
if user_parts:
|
|
items.append(_message("user", list(user_parts)))
|
|
user_parts.clear()
|
|
|
|
for block in content:
|
|
if not isinstance(block, dict):
|
|
raise HandoffError(f"Claude user record {record.get('uuid')} has invalid block.")
|
|
kind = block.get("type")
|
|
if kind == "text":
|
|
user_parts.append({"type": "input_text", "text": str(block.get("text", ""))})
|
|
continue
|
|
if kind == "image":
|
|
source = block.get("source") or {}
|
|
if source.get("type") == "base64" and source.get("data") and source.get("media_type"):
|
|
user_parts.append(
|
|
{
|
|
"type": "input_image",
|
|
"image_url": f"data:{source['media_type']};base64,{source['data']}",
|
|
}
|
|
)
|
|
continue
|
|
raise HandoffError(f"Claude image in {record.get('uuid')} is not stored as transferable base64 data.")
|
|
if kind == "tool_result":
|
|
flush_user()
|
|
call_id = str(block.get("tool_use_id") or "")
|
|
if not call_id:
|
|
raise HandoffError(f"Claude tool result in {record.get('uuid')} has no call ID.")
|
|
if call_id not in calls:
|
|
raise HandoffError(f"Claude tool result {call_id} has no matching call in the active state.")
|
|
if call_id in completed_calls:
|
|
raise HandoffError(f"Claude tool result is duplicated: {call_id}")
|
|
completed_calls.add(call_id)
|
|
items.append(
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": call_id,
|
|
"name": calls[call_id],
|
|
"output": block.get("content"),
|
|
}
|
|
)
|
|
continue
|
|
raise HandoffError(f"Unsupported Claude user block {kind!r} in {record.get('uuid')}.")
|
|
flush_user()
|
|
return items
|
|
|
|
|
|
def _responses_items(records: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], int]:
|
|
items: list[dict[str, Any]] = []
|
|
calls: dict[str, str] = {}
|
|
completed_calls: set[str] = set()
|
|
skipped_reasoning = 0
|
|
|
|
index = 0
|
|
while index < len(records):
|
|
record = records[index]
|
|
record_type = record.get("type")
|
|
if record_type == "assistant":
|
|
message_id = (record.get("message") or {}).get("id")
|
|
group = [record]
|
|
index += 1
|
|
while index < len(records):
|
|
candidate = records[index]
|
|
if candidate.get("type") != "assistant":
|
|
break
|
|
candidate_id = (candidate.get("message") or {}).get("id")
|
|
if not message_id or candidate_id != message_id:
|
|
break
|
|
group.append(candidate)
|
|
index += 1
|
|
assistant_items, skipped = _assistant_items(group, calls)
|
|
items.extend(assistant_items)
|
|
skipped_reasoning += skipped
|
|
continue
|
|
if record_type == "user":
|
|
items.extend(_user_items(record, calls, completed_calls))
|
|
elif record_type == "attachment":
|
|
attachment_item = _attachment_item(record)
|
|
if attachment_item is not None:
|
|
items.append(attachment_item)
|
|
elif record_type not in {"system"}:
|
|
raise HandoffError(f"Unsupported model-visible Claude record {record_type!r} at {record.get('uuid')}.")
|
|
index += 1
|
|
|
|
unfinished = sorted(set(calls) - completed_calls)
|
|
if unfinished:
|
|
joined = ", ".join(unfinished[:5])
|
|
raise HandoffError(
|
|
f"Claude's active state ends with unfinished tool call(s): {joined}. "
|
|
"Finish or stop the Claude turn before transferring it."
|
|
)
|
|
if not items:
|
|
raise HandoffError("Claude's active state produced no Codex history items.")
|
|
return items, skipped_reasoning
|
|
|
|
|
|
def _without_image_payloads(value: Any) -> Any:
|
|
if isinstance(value, list):
|
|
return [_without_image_payloads(item) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
|
|
cleaned = {key: _without_image_payloads(item) for key, item in value.items()}
|
|
if cleaned.get("type") == "input_image" and isinstance(cleaned.get("image_url"), str):
|
|
cleaned["image_url"] = "[Image saved locally during handoff]"
|
|
if cleaned.get("type") == "image" and isinstance(cleaned.get("source"), dict):
|
|
source = dict(cleaned["source"])
|
|
if source.get("type") == "base64" and "data" in source:
|
|
source["data"] = "[Image saved locally during handoff]"
|
|
cleaned["source"] = source
|
|
return cleaned
|
|
|
|
|
|
def _token_count(value: Any) -> int:
|
|
text = json.dumps(_without_image_payloads(value), ensure_ascii=False, separators=(",", ":"))
|
|
try:
|
|
import tiktoken
|
|
|
|
return len(tiktoken.get_encoding("o200k_base").encode(text))
|
|
except ImportError:
|
|
return (len(text) + 3) // 4
|
|
|
|
|
|
def build_plan(session: str, projects_dir: Path) -> HandoffPlan:
|
|
path = _resolve_session(session, projects_dir)
|
|
records, sha256 = _stable_jsonl(path)
|
|
chain = _active_chain(records)
|
|
imported, compact_uuid = _after_latest_compaction(chain)
|
|
imported = _merge_parallel_tool_results(imported, records)
|
|
items, skipped_reasoning = _responses_items(imported)
|
|
|
|
session_id = next(
|
|
(str(record["sessionId"]) for record in reversed(records) if record.get("sessionId")),
|
|
path.stem,
|
|
)
|
|
title = next(
|
|
(
|
|
str(record["customTitle"])
|
|
for record in reversed(records)
|
|
if record.get("type") == "custom-title" and record.get("customTitle")
|
|
),
|
|
f"Claude session {session_id[:8]}",
|
|
)
|
|
cwd = next(
|
|
(str(record["cwd"]) for record in chain if record.get("cwd")),
|
|
"",
|
|
)
|
|
if not cwd:
|
|
raise HandoffError("Claude session does not record its working directory.")
|
|
|
|
warnings: list[str] = []
|
|
if skipped_reasoning:
|
|
warnings.append(f"Skipped {skipped_reasoning} Claude hidden-reasoning block(s); they are not portable.")
|
|
|
|
source = SourceInfo(
|
|
path=str(path),
|
|
sha256=sha256,
|
|
session_id=session_id,
|
|
title=title,
|
|
cwd=str(Path(cwd).resolve()),
|
|
leaf_uuid=chain[-1]["uuid"],
|
|
compact_boundary_uuid=compact_uuid,
|
|
first_imported_uuid=imported[0]["uuid"],
|
|
last_imported_uuid=imported[-1]["uuid"],
|
|
codex_cwd=_git_root(Path(cwd)),
|
|
)
|
|
return HandoffPlan(
|
|
source=source,
|
|
items=items,
|
|
source_records=len(records),
|
|
active_records=len(chain),
|
|
imported_records=len(imported),
|
|
hidden_reasoning_blocks_skipped=skipped_reasoning,
|
|
approximate_tokens=_token_count(items),
|
|
warnings=warnings,
|
|
)
|
|
|
|
|
|
def _write_private(path: Path, body: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
try:
|
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
stream.write(body)
|
|
os.replace(temporary, path)
|
|
finally:
|
|
Path(temporary).unlink(missing_ok=True)
|
|
|
|
|
|
def write_bundle(plan: HandoffPlan, path: Path) -> Path:
|
|
path = path.expanduser().resolve()
|
|
_write_private(path, json.dumps(plan.bundle(), ensure_ascii=False))
|
|
return path
|
|
|
|
|
|
def _validate_items(items: Any) -> None:
|
|
if not isinstance(items, list) or not items:
|
|
raise HandoffError("Handoff bundle contains no history items.")
|
|
calls: dict[str, str] = {}
|
|
completed: set[str] = set()
|
|
saw_user = False
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
raise HandoffError("Invalid handoff history item.")
|
|
kind = item.get("type")
|
|
status = item.get("status")
|
|
if (
|
|
not isinstance(kind, str)
|
|
or (status is not None and not isinstance(status, str))
|
|
or status in {"incomplete", "in_progress"}
|
|
):
|
|
raise HandoffError("Invalid or incomplete handoff history item.")
|
|
if kind == "message":
|
|
role = item.get("role")
|
|
parts = item.get("content")
|
|
if (
|
|
not isinstance(role, str)
|
|
or role not in {"user", "assistant"}
|
|
or not isinstance(parts, list)
|
|
or not parts
|
|
):
|
|
raise HandoffError("Invalid handoff message role or content.")
|
|
saw_user = saw_user or role == "user"
|
|
for part in parts:
|
|
if not isinstance(part, dict) or not isinstance(part.get("type"), str):
|
|
raise HandoffError("Invalid handoff message part.")
|
|
if part.get("type") in {"input_text", "output_text"} and isinstance(part.get("text"), str):
|
|
continue
|
|
if part.get("type") == "input_image":
|
|
_, encoded = _data_url_payload(part.get("image_url"), "Handoff image")
|
|
try:
|
|
if not base64.b64decode(encoded, validate=True):
|
|
raise ValueError("empty image")
|
|
except (ValueError, binascii.Error) as exc:
|
|
raise HandoffError("Invalid handoff image data.") from exc
|
|
continue
|
|
raise HandoffError("Unsupported handoff message part.")
|
|
elif kind == "function_call":
|
|
call_id, name, arguments = item.get("call_id"), item.get("name"), item.get("arguments")
|
|
if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
|
|
raise HandoffError("Invalid handoff tool call ID or name.")
|
|
if call_id in calls or not isinstance(arguments, str):
|
|
raise HandoffError("Duplicate or invalid handoff tool call.")
|
|
try:
|
|
json.loads(arguments)
|
|
except json.JSONDecodeError as exc:
|
|
raise HandoffError("Handoff tool arguments are not JSON.") from exc
|
|
calls[call_id] = name
|
|
elif kind == "function_call_output":
|
|
call_id = item.get("call_id")
|
|
if not isinstance(call_id, str) or call_id not in calls or call_id in completed:
|
|
raise HandoffError("Unmatched or duplicate handoff tool result.")
|
|
if "output" not in item:
|
|
raise HandoffError("Handoff tool result has no output.")
|
|
completed.add(call_id)
|
|
item.setdefault("name", calls[call_id])
|
|
else:
|
|
raise HandoffError(f"Unsupported handoff item type: {kind!r}.")
|
|
if set(calls) != completed:
|
|
raise HandoffError("Session has unfinished tool calls; finish or stop the source turn before handoff.")
|
|
if not saw_user:
|
|
raise HandoffError("Handoff contains no user message.")
|
|
|
|
|
|
def plan_from_bundle(payload: Any) -> HandoffPlan:
|
|
formats = {FORMAT_VERSION, "mem0.claude-to-codex.v1", "memo.claude-to-codex.v1"}
|
|
if not isinstance(payload, dict) or not isinstance(payload.get("format"), str) or payload["format"] not in formats:
|
|
raise HandoffError("Unsupported handoff bundle format.")
|
|
source_payload = payload.get("source")
|
|
items = payload.get("items")
|
|
warnings = payload.get("warnings", [])
|
|
if not isinstance(source_payload, dict) or not isinstance(warnings, list):
|
|
raise HandoffError("Handoff bundle has no source or has invalid warnings.")
|
|
_validate_items(items)
|
|
try:
|
|
fields = dict(source_payload)
|
|
legacy = payload["format"] != FORMAT_VERSION
|
|
fields.setdefault("host", "claude-code" if legacy else "")
|
|
for key in ("host", "session_id", "title", "cwd"):
|
|
if not isinstance(fields.get(key), str) or not fields[key].strip():
|
|
raise ValueError(f"invalid source field: {key}")
|
|
if not re.fullmatch(r"[a-z][a-z0-9-]*", fields["host"]):
|
|
raise ValueError("invalid source host")
|
|
fields.setdefault("path", f"{fields['host']}:{fields['session_id']}")
|
|
fields.setdefault("sha256", hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
|
|
fields.setdefault("leaf_uuid", str(len(items)))
|
|
fields.setdefault("first_imported_uuid", "1")
|
|
fields.setdefault("last_imported_uuid", str(len(items)))
|
|
fields.setdefault("compact_boundary_uuid", None)
|
|
source = SourceInfo(**fields)
|
|
for key, value in asdict(source).items():
|
|
if value is None and key in {"codex_cwd", "compact_boundary_uuid"}:
|
|
continue
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"invalid source field: {key}")
|
|
if not re.fullmatch(r"[0-9a-f]{64}", source.sha256):
|
|
raise ValueError("invalid source digest")
|
|
counts = payload.get("counts", {})
|
|
return HandoffPlan(
|
|
source=source,
|
|
items=items,
|
|
source_records=int(counts.get("source_records", len(items))),
|
|
active_records=int(counts.get("active_records", len(items))),
|
|
imported_records=int(counts.get("imported_records", len(items))),
|
|
hidden_reasoning_blocks_skipped=int(counts.get("hidden_reasoning_blocks_skipped", 0)),
|
|
approximate_tokens=_token_count(items),
|
|
warnings=[str(warning) for warning in warnings],
|
|
)
|
|
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
raise HandoffError("Handoff bundle is incomplete or has invalid source fields.") from exc
|
|
|
|
|
|
def load_bundle(path: Path) -> HandoffPlan:
|
|
try:
|
|
text = sys.stdin.read() if str(path) == "-" else path.expanduser().resolve().read_text(encoding="utf-8")
|
|
return plan_from_bundle(json.loads(text))
|
|
except json.JSONDecodeError as exc:
|
|
raise HandoffError(f"Invalid handoff bundle JSON: {path}") from exc
|
|
|
|
|
|
def _git_root(cwd: Path) -> str | None:
|
|
completed = subprocess.run(
|
|
["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
check=False,
|
|
)
|
|
if completed.returncode != 0:
|
|
return None
|
|
root = Path(completed.stdout.strip()).resolve()
|
|
return str(root) if root.is_dir() else None
|
|
|
|
|
|
def _with_cwd(plan: HandoffPlan, cwd: Path | None) -> HandoffPlan:
|
|
source_cwd = Path(plan.source.cwd).expanduser().resolve()
|
|
target = (
|
|
cwd.expanduser().resolve()
|
|
if cwd
|
|
else Path(plan.source.codex_cwd).expanduser().resolve()
|
|
if plan.source.codex_cwd
|
|
else Path(_git_root(source_cwd) or source_cwd)
|
|
)
|
|
if not target.is_dir():
|
|
raise HandoffError(f"Codex working directory does not exist: {target}")
|
|
return replace(plan, source=replace(plan.source, cwd=str(source_cwd), codex_cwd=str(target)))
|
|
|
|
|
|
def _codex_cwd(plan: HandoffPlan) -> str:
|
|
return plan.source.codex_cwd or plan.source.cwd
|
|
|
|
|
|
def _default_bundle_path(plan: HandoffPlan) -> Path:
|
|
session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip(".-")[:80] or "session"
|
|
name = f"{session}-{plan.source.sha256[:12]}.json"
|
|
return DEFAULT_BUNDLE_DIR / name
|
|
|
|
|
|
def _codex_context_limits(codex_home: Path) -> CodexContextLimits:
|
|
try:
|
|
import tomllib
|
|
except ImportError as exc:
|
|
raise HandoffError("Creating a Codex task requires Python 3.11 or newer; rerun with python3.11.") from exc
|
|
|
|
codex_home = codex_home.expanduser().resolve()
|
|
config_path = codex_home / "config.toml"
|
|
cache_path = codex_home / "models_cache.json"
|
|
try:
|
|
config = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
raise HandoffError(f"Cannot read Codex configuration at {config_path}: {exc}") from exc
|
|
try:
|
|
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise HandoffError(f"Cannot read Codex model metadata at {cache_path}: {exc}") from exc
|
|
|
|
model = str(config.get("model") or "")
|
|
models = cache.get("models") if isinstance(cache, dict) else None
|
|
if not isinstance(models, list):
|
|
raise HandoffError(f"Codex model metadata has no model list: {cache_path}")
|
|
model_info = next(
|
|
(
|
|
item
|
|
for item in models
|
|
if isinstance(item, dict)
|
|
and (
|
|
item.get("slug") == model
|
|
or item.get("model") == model
|
|
or (not model and item.get("is_default") is True)
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
if not isinstance(model_info, dict):
|
|
raise HandoffError(f"Codex model {model!r} is missing from {cache_path}; refresh Codex's model list.")
|
|
model = str(model_info.get("slug") or model_info.get("model") or model)
|
|
|
|
cached_context = model_info.get("context_window")
|
|
cached_max = model_info.get("max_context_window") or cached_context
|
|
if not isinstance(cached_context, int) or not isinstance(cached_max, int):
|
|
raise HandoffError(f"Codex model {model!r} does not report its context limits.")
|
|
configured_context = config.get("model_context_window")
|
|
context_window = min(configured_context, cached_max) if isinstance(configured_context, int) else cached_context
|
|
effective_percent = model_info.get("effective_context_window_percent", 95)
|
|
if not isinstance(effective_percent, int) or not 1 <= effective_percent <= 100:
|
|
raise HandoffError(f"Codex model {model!r} reports an invalid effective context percentage.")
|
|
|
|
context_auto_limit = context_window * 9 // 10
|
|
configured_auto_limit = config.get("model_auto_compact_token_limit")
|
|
auto_compact_limit = (
|
|
min(configured_auto_limit, context_auto_limit) if isinstance(configured_auto_limit, int) else context_auto_limit
|
|
)
|
|
return CodexContextLimits(
|
|
model=model,
|
|
context_window=context_window,
|
|
usable_context_window=context_window * effective_percent // 100,
|
|
auto_compact_token_limit=auto_compact_limit,
|
|
max_context_window=cached_max,
|
|
max_usable_context_window=cached_max * effective_percent // 100,
|
|
max_auto_compact_token_limit=cached_max * 9 // 10,
|
|
)
|
|
|
|
|
|
class CodexAppServer:
|
|
"""Small JSON-RPC client for a one-off local Codex app-server process."""
|
|
|
|
def __init__(
|
|
self,
|
|
codex_bin: str = "codex",
|
|
context_window_override: int | None = None,
|
|
codex_home: Path = DEFAULT_CODEX_HOME,
|
|
) -> None:
|
|
resolved = shutil.which(codex_bin)
|
|
if not resolved:
|
|
raise HandoffError(f"Codex executable not found: {codex_bin}")
|
|
command = [resolved]
|
|
if context_window_override is not None:
|
|
command.extend(["-c", f"model_context_window={context_window_override}"])
|
|
command.extend(["app-server", "--stdio"])
|
|
self.process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
bufsize=1,
|
|
env={**os.environ, "CODEX_HOME": str(codex_home.expanduser().resolve())},
|
|
)
|
|
self._responses: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
self._notifications: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
self._stderr: list[str] = []
|
|
self._next_id = 1
|
|
threading.Thread(target=self._read_stdout, daemon=True).start()
|
|
threading.Thread(target=self._read_stderr, daemon=True).start()
|
|
try:
|
|
self.request(
|
|
"initialize",
|
|
{
|
|
"clientInfo": {
|
|
"name": "mem0_session_handoff",
|
|
"title": "Mem0 local session handoff",
|
|
"version": "0.1.0",
|
|
}
|
|
},
|
|
)
|
|
self.notify("initialized", {})
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def _read_stdout(self) -> None:
|
|
assert self.process.stdout is not None
|
|
for line in self.process.stdout:
|
|
try:
|
|
message = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if not isinstance(message, dict):
|
|
continue
|
|
if "id" in message:
|
|
self._responses.put(message)
|
|
elif "method" in message:
|
|
self._notifications.put(message)
|
|
|
|
def _read_stderr(self) -> None:
|
|
assert self.process.stderr is not None
|
|
for line in self.process.stderr:
|
|
self._stderr.append(line.rstrip())
|
|
|
|
def _send(self, payload: dict[str, Any]) -> None:
|
|
if self.process.poll() is not None:
|
|
error = "\n".join(self._stderr[-20:])
|
|
raise HandoffError(f"Codex app-server stopped unexpectedly.\n{error}")
|
|
assert self.process.stdin is not None
|
|
self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
self.process.stdin.flush()
|
|
|
|
def request(self, method: str, params: dict[str, Any], timeout: float = 30) -> Any:
|
|
request_id = self._next_id
|
|
self._next_id += 1
|
|
self._send({"method": method, "id": request_id, "params": params})
|
|
deadline = time.monotonic() + timeout
|
|
deferred: list[dict[str, Any]] = []
|
|
try:
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise HandoffError(f"Codex app-server timed out on {method}.")
|
|
try:
|
|
response = self._responses.get(timeout=remaining)
|
|
except queue.Empty as exc:
|
|
raise HandoffError(f"Codex app-server timed out on {method}.") from exc
|
|
if response.get("id") != request_id:
|
|
deferred.append(response)
|
|
continue
|
|
if "error" in response:
|
|
raise HandoffError(f"Codex {method} failed: {response['error']}")
|
|
return response.get("result")
|
|
finally:
|
|
for response in deferred:
|
|
self._responses.put(response)
|
|
|
|
def notify(self, method: str, params: dict[str, Any]) -> None:
|
|
self._send({"method": method, "params": params})
|
|
|
|
def wait_for_notification(
|
|
self,
|
|
method: str,
|
|
predicate: Any | None = None,
|
|
timeout: float = 600,
|
|
) -> dict[str, Any]:
|
|
deadline = time.monotonic() + timeout
|
|
deferred: list[dict[str, Any]] = []
|
|
try:
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise HandoffError(f"Codex app-server timed out waiting for {method}.")
|
|
try:
|
|
notification = self._notifications.get(timeout=remaining)
|
|
except queue.Empty as exc:
|
|
raise HandoffError(f"Codex app-server timed out waiting for {method}.") from exc
|
|
if notification.get("method") != method:
|
|
deferred.append(notification)
|
|
continue
|
|
params = notification.get("params")
|
|
if predicate is None or predicate(params):
|
|
return notification
|
|
deferred.append(notification)
|
|
finally:
|
|
for notification in deferred:
|
|
self._notifications.put(notification)
|
|
|
|
def wait_for_any_notification(
|
|
self,
|
|
methods: set[str],
|
|
predicate: Any | None = None,
|
|
timeout: float = 600,
|
|
) -> dict[str, Any]:
|
|
deadline = time.monotonic() + timeout
|
|
deferred: list[dict[str, Any]] = []
|
|
try:
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
joined = ", ".join(sorted(methods))
|
|
raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.")
|
|
try:
|
|
notification = self._notifications.get(timeout=remaining)
|
|
except queue.Empty as exc:
|
|
joined = ", ".join(sorted(methods))
|
|
raise HandoffError(f"Codex app-server timed out waiting for one of: {joined}.") from exc
|
|
if notification.get("method") not in methods:
|
|
deferred.append(notification)
|
|
continue
|
|
params = notification.get("params")
|
|
if predicate is None or predicate(params):
|
|
return notification
|
|
deferred.append(notification)
|
|
finally:
|
|
for notification in deferred:
|
|
self._notifications.put(notification)
|
|
|
|
def close(self) -> None:
|
|
if self.process.poll() is None:
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.process.kill()
|
|
self.process.wait(timeout=5)
|
|
|
|
def __enter__(self) -> "CodexAppServer":
|
|
return self
|
|
|
|
def __exit__(self, *_: Any) -> None:
|
|
self.close()
|
|
|
|
|
|
def _item_text(item: dict[str, Any], asset_dir: Path) -> tuple[str, str]:
|
|
"""Convert one Responses item to a complete visible import message."""
|
|
item_type = item.get("type")
|
|
if item_type == "message":
|
|
role = str(item.get("role") or "")
|
|
if role not in {"user", "assistant"}:
|
|
raise HandoffError(f"Codex's session importer cannot represent role {role!r}.")
|
|
parts: list[str] = []
|
|
for part in item.get("content") or []:
|
|
if not isinstance(part, dict):
|
|
raise HandoffError("A handoff message contains an invalid content item.")
|
|
part_type = part.get("type")
|
|
if part_type in {"input_text", "output_text"}:
|
|
parts.append(str(part.get("text") or ""))
|
|
elif part_type == "input_image":
|
|
media_type, encoded = _data_url_payload(part.get("image_url"), "A Claude message image")
|
|
parts.append(_image_reference(media_type, encoded, asset_dir, "A Claude message image"))
|
|
else:
|
|
raise HandoffError(f"Codex's session importer cannot represent content type {part_type!r}.")
|
|
text = "\n\n".join(part for part in parts if part)
|
|
if not text:
|
|
raise HandoffError("A handoff message contains no transferable text.")
|
|
return role, text
|
|
|
|
if item_type == "function_call":
|
|
name = html.escape(str(item.get("name") or "unknown"), quote=True)
|
|
call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
|
|
arguments = str(item.get("arguments") or "{}")
|
|
return (
|
|
"assistant",
|
|
f'<tool_call name="{name}" id="{call_id}">\n{arguments}\n</tool_call>',
|
|
)
|
|
|
|
if item_type == "function_call_output":
|
|
name = html.escape(str(item.get("name") or "unknown"), quote=True)
|
|
call_id = html.escape(str(item.get("call_id") or "unknown"), quote=True)
|
|
output = _tool_result_text(item.get("output"), asset_dir, f"Claude tool result {call_id}")
|
|
return (
|
|
"assistant",
|
|
f'<tool_result name="{name}" id="{call_id}">\n{output}\n</tool_result>',
|
|
)
|
|
|
|
raise HandoffError(f"Codex's session importer cannot represent item type {item_type!r}.")
|
|
|
|
|
|
def _native_import_records(plan: HandoffPlan, asset_dir: Path) -> list[dict[str, Any]]:
|
|
"""Build the Claude-shaped history consumed by Codex's native importer."""
|
|
cwd = _codex_cwd(plan)
|
|
records: list[dict[str, Any]] = [
|
|
{
|
|
"type": "custom-title",
|
|
"customTitle": plan.source.title,
|
|
"sessionId": plan.source.session_id,
|
|
}
|
|
]
|
|
saw_user = False
|
|
for index, item in enumerate(plan.items, 1):
|
|
role, text = _item_text(item, asset_dir)
|
|
saw_user = saw_user or role == "user"
|
|
records.append(
|
|
{
|
|
"type": role,
|
|
"sessionId": plan.source.session_id,
|
|
"uuid": f"mem0-handoff-{index}",
|
|
"cwd": cwd,
|
|
"isSidechain": False,
|
|
"message": {"role": role, "content": text},
|
|
}
|
|
)
|
|
if not saw_user:
|
|
raise HandoffError("The active Claude context contains no user message.")
|
|
return records
|
|
|
|
|
|
def _native_import_path(plan: HandoffPlan, claude_projects_dir: Path) -> Path:
|
|
source_key = hashlib.sha256(plan.source.path.encode("utf-8")).hexdigest()[:24]
|
|
safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
|
|
safe_session = safe_session[:80] or source_key
|
|
return claude_projects_dir.expanduser().resolve() / ".mem0-handoffs" / f"{safe_session}-{source_key}.jsonl"
|
|
|
|
|
|
def _write_native_import(plan: HandoffPlan, path: Path, asset_dir: Path) -> str:
|
|
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
body = "".join(
|
|
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
for record in _native_import_records(plan, asset_dir)
|
|
)
|
|
_write_private(path, body)
|
|
return hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _native_import_params(source_path: Path, cwd: str) -> dict[str, Any]:
|
|
return {
|
|
"migrationItems": [
|
|
{
|
|
"itemType": "SESSIONS",
|
|
"description": f"Transfer Claude session {source_path.name}",
|
|
"cwd": None,
|
|
"details": {
|
|
"plugins": [],
|
|
"sessions": [{"path": str(source_path), "cwd": cwd, "title": None}],
|
|
"mcpServers": [],
|
|
"hooks": [],
|
|
"subagents": [],
|
|
"commands": [],
|
|
},
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
def _thread_id_from_completion(params: Any, source_path: Path) -> str | None:
|
|
if not isinstance(params, dict):
|
|
return None
|
|
canonical = str(source_path.resolve())
|
|
for result in params.get("itemTypeResults") or []:
|
|
if not isinstance(result, dict) or result.get("itemType") != "SESSIONS":
|
|
continue
|
|
for success in result.get("successes") or []:
|
|
if not isinstance(success, dict):
|
|
continue
|
|
if success.get("source") in {None, canonical} and success.get("target"):
|
|
return str(success["target"])
|
|
return None
|
|
|
|
|
|
def _thread_id_from_ledger(codex_home: Path, source_path: Path, content_sha256: str) -> str | None:
|
|
ledger_path = codex_home.expanduser() / "external_agent_session_imports.json"
|
|
if not ledger_path.is_file():
|
|
return None
|
|
try:
|
|
ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
canonical = str(source_path.resolve())
|
|
matches = [
|
|
record
|
|
for record in ledger.get("records", [])
|
|
if isinstance(record, dict)
|
|
and record.get("source_path") == canonical
|
|
and record.get("content_sha256") == content_sha256
|
|
and record.get("imported_thread_id")
|
|
]
|
|
return str(matches[-1]["imported_thread_id"]) if matches else None
|
|
|
|
|
|
def _notification_thread_id(params: Any) -> str | None:
|
|
if not isinstance(params, dict):
|
|
return None
|
|
if params.get("threadId"):
|
|
return str(params["threadId"])
|
|
turn = params.get("turn")
|
|
if isinstance(turn, dict) and turn.get("threadId"):
|
|
return str(turn["threadId"])
|
|
return None
|
|
|
|
|
|
def _compact_imported_thread(
|
|
server: CodexAppServer,
|
|
thread_id: str,
|
|
) -> dict[str, Any] | None:
|
|
server.request("thread/resume", {"threadId": thread_id}, timeout=120)
|
|
server.request("thread/compact/start", {"threadId": thread_id}, timeout=30)
|
|
|
|
latest_usage: dict[str, Any] | None = None
|
|
saw_compaction_item = False
|
|
while True:
|
|
notification = server.wait_for_any_notification(
|
|
{"item/completed", "thread/tokenUsage/updated", "turn/completed", "error"},
|
|
lambda params: _notification_thread_id(params) in {None, thread_id},
|
|
timeout=600,
|
|
)
|
|
method = notification.get("method")
|
|
params = notification.get("params")
|
|
if method == "thread/tokenUsage/updated" and isinstance(params, dict):
|
|
token_usage = params.get("tokenUsage")
|
|
if isinstance(token_usage, dict):
|
|
latest_usage = token_usage
|
|
continue
|
|
if method == "item/completed" and isinstance(params, dict):
|
|
item = params.get("item")
|
|
if isinstance(item, dict) and item.get("type") == "contextCompaction":
|
|
saw_compaction_item = True
|
|
continue
|
|
if method == "error":
|
|
error = params.get("error") if isinstance(params, dict) else params
|
|
raise HandoffError(f"Codex could not compact the imported task: {error}")
|
|
if method == "turn/completed" and isinstance(params, dict):
|
|
turn = params.get("turn")
|
|
if not isinstance(turn, dict):
|
|
raise HandoffError("Codex returned an invalid compaction result.")
|
|
if turn.get("status") != "completed":
|
|
error = turn.get("error") or turn.get("status")
|
|
raise HandoffError(f"Codex could not compact the imported task: {error}")
|
|
if not saw_compaction_item:
|
|
raise HandoffError("Codex completed the compaction turn without a compaction item.")
|
|
return latest_usage
|
|
|
|
|
|
def _set_thread_name(
|
|
server: CodexAppServer,
|
|
thread_id: str,
|
|
name: str,
|
|
) -> None:
|
|
server.request(
|
|
"thread/name/set",
|
|
{"threadId": thread_id, "name": name},
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def create_codex_thread(
|
|
plan: HandoffPlan,
|
|
codex_bin: str = "codex",
|
|
codex_home: Path = DEFAULT_CODEX_HOME,
|
|
) -> dict[str, Any]:
|
|
limits = _codex_context_limits(codex_home)
|
|
should_compact = plan.approximate_tokens >= limits.auto_compact_token_limit
|
|
if should_compact and plan.approximate_tokens >= limits.max_auto_compact_token_limit:
|
|
raise HandoffError(
|
|
f"The active session state is approximately {plan.approximate_tokens:,} tokens. "
|
|
f"Codex cannot safely compact more than approximately "
|
|
f"{limits.max_auto_compact_token_limit:,} tokens in one request. "
|
|
"Compact in the source host and retry the handoff."
|
|
)
|
|
|
|
# Codex only imports sources staged under its native Claude home.
|
|
source_path = _native_import_path(plan, Path.home() / ".claude" / "projects")
|
|
safe_session = re.sub(r"[^A-Za-z0-9._-]+", "-", plan.source.session_id).strip("-")
|
|
asset_dir = (
|
|
codex_home.expanduser().resolve()
|
|
/ "external-agent-assets"
|
|
/ plan.source.host
|
|
/ (safe_session[:80] or "session")
|
|
)
|
|
content_sha256 = _write_native_import(plan, source_path, asset_dir)
|
|
try:
|
|
context_override = limits.max_context_window if should_compact else None
|
|
with CodexAppServer(codex_bin, context_override, codex_home=codex_home) as server:
|
|
response = server.request(
|
|
"externalAgentConfig/import",
|
|
_native_import_params(source_path, _codex_cwd(plan)),
|
|
timeout=120,
|
|
)
|
|
import_id = str((response or {}).get("importId") or "")
|
|
if not import_id:
|
|
raise HandoffError(f"Codex externalAgentConfig/import returned no import ID: {response!r}")
|
|
completed = server.wait_for_notification(
|
|
IMPORT_COMPLETED_NOTIFICATION,
|
|
lambda params: isinstance(params, dict) and params.get("importId") == import_id,
|
|
)
|
|
completed_params = completed.get("params")
|
|
thread_id = _thread_id_from_completion(completed_params, source_path)
|
|
if not thread_id:
|
|
thread_id = _thread_id_from_ledger(codex_home, source_path, content_sha256)
|
|
if not thread_id:
|
|
raise HandoffError(
|
|
"Codex finished importing the session but did not report the new task ID. "
|
|
f"Import result: {json.dumps(completed_params, ensure_ascii=False)}"
|
|
)
|
|
|
|
read = server.request(
|
|
"thread/read",
|
|
{"threadId": thread_id, "includeTurns": True},
|
|
)
|
|
thread = (read or {}).get("thread") if isinstance(read, dict) else None
|
|
if not isinstance(thread, dict):
|
|
raise HandoffError(f"Codex could not read imported task {thread_id}.")
|
|
turns = thread.get("turns") or []
|
|
preview = str(thread.get("preview") or "")
|
|
if not turns or not preview:
|
|
raise HandoffError(f"Codex imported task {thread_id}, but it has no visible history.")
|
|
|
|
compaction_usage = _compact_imported_thread(server, thread_id) if should_compact else None
|
|
_set_thread_name(server, thread_id, plan.source.title)
|
|
|
|
return {
|
|
"thread_id": thread_id,
|
|
"title": plan.source.title,
|
|
"cwd": _codex_cwd(plan),
|
|
"source_session_id": plan.source.session_id,
|
|
"source_host": plan.source.host,
|
|
"visible_turns": len(turns),
|
|
"preview": preview,
|
|
"responses_items_converted": len(plan.items),
|
|
"approximate_import_tokens": plan.approximate_tokens,
|
|
"target_model": limits.model,
|
|
"target_context_window": limits.context_window,
|
|
"target_usable_context_window": limits.usable_context_window,
|
|
"target_auto_compact_token_limit": limits.auto_compact_token_limit,
|
|
"compacted_before_return": should_compact,
|
|
"compaction_context_window": (limits.max_context_window if should_compact else None),
|
|
"compaction_token_usage": compaction_usage,
|
|
"model_invoked": should_compact,
|
|
}
|
|
finally:
|
|
source_path.unlink(missing_ok=True)
|
|
try:
|
|
source_path.parent.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _summary(plan: HandoffPlan) -> dict[str, Any]:
|
|
return {
|
|
"source": plan.source.path,
|
|
"source_host": plan.source.host,
|
|
"session_id": plan.source.session_id,
|
|
"title": plan.source.title,
|
|
"source_cwd": plan.source.cwd,
|
|
"codex_cwd": _codex_cwd(plan),
|
|
"leaf_uuid": plan.source.leaf_uuid,
|
|
"compact_boundary_uuid": plan.source.compact_boundary_uuid,
|
|
"source_records": plan.source_records,
|
|
"active_records": plan.active_records,
|
|
"imported_records": plan.imported_records,
|
|
"responses_items": len(plan.items),
|
|
"approximate_import_tokens": plan.approximate_tokens,
|
|
"warnings": plan.warnings,
|
|
}
|
|
|
|
|
|
def _command_output(result: dict[str, Any]) -> str:
|
|
title = str(result["title"])
|
|
cwd = str(result["cwd"])
|
|
project = Path(cwd).name or cwd
|
|
lines = [
|
|
f'Created Codex task "{title}".',
|
|
f"Task ID: {result['thread_id']}",
|
|
f"Project: {project}",
|
|
]
|
|
if result.get("compacted_before_return"):
|
|
lines.append("Codex compacted the transferred context before opening the task.")
|
|
lines.append(f'Open Codex and select "{title}" under {project}.')
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _parse_args(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--session", help="Native session transcript path (Claude also accepts its session ID)")
|
|
parser.add_argument(
|
|
"--source",
|
|
choices=("claude-code", "cursor", "codex", "kimi", "antigravity", "openclaw", "pi-agent"),
|
|
default=default_source,
|
|
)
|
|
parser.add_argument("--title", help="Override the imported task title")
|
|
source.add_argument("--bundle", type=Path, help="Previously exported handoff bundle")
|
|
parser.add_argument(
|
|
"--claude-projects-dir",
|
|
type=Path,
|
|
default=Path.home() / ".claude" / "projects",
|
|
)
|
|
parser.add_argument("--export", type=Path, help="Write a private reusable handoff bundle")
|
|
parser.add_argument(
|
|
"--cwd",
|
|
type=Path,
|
|
help="Use this existing directory instead of the source session's directory",
|
|
)
|
|
parser.add_argument("--create", action="store_true", help="Create the Codex task")
|
|
parser.add_argument(
|
|
"--target",
|
|
choices=("codex",),
|
|
default="codex",
|
|
help="Destination coding agent",
|
|
)
|
|
parser.add_argument(
|
|
"--command-output",
|
|
action="store_true",
|
|
help="Print the short result used by Mem0's user-facing command",
|
|
)
|
|
parser.add_argument("--codex-bin", default="codex")
|
|
parser.add_argument(
|
|
"--codex-home",
|
|
type=Path,
|
|
default=DEFAULT_CODEX_HOME,
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def main(argv: Iterable[str] | None = None, default_source: str | None = "claude-code") -> int:
|
|
args = _parse_args(argv, default_source)
|
|
try:
|
|
if args.bundle:
|
|
plan = load_bundle(args.bundle)
|
|
elif args.source == "claude-code":
|
|
plan = build_plan(args.session, args.claude_projects_dir)
|
|
else:
|
|
if not args.source:
|
|
raise HandoffError("--source is required with --session.")
|
|
from handoff_sources import read_source
|
|
|
|
plan = read_source(args.source, Path(args.session), cwd=args.cwd, title=args.title)
|
|
if args.title:
|
|
plan = replace(plan, source=replace(plan.source, title=args.title))
|
|
plan = _with_cwd(plan, args.cwd)
|
|
output: dict[str, Any] = {"plan": _summary(plan)}
|
|
if args.export:
|
|
output["bundle"] = str(write_bundle(plan, args.export))
|
|
if args.create:
|
|
try:
|
|
output["codex"] = create_codex_thread(
|
|
plan,
|
|
codex_bin=args.codex_bin,
|
|
codex_home=args.codex_home.expanduser(),
|
|
)
|
|
except (HandoffError, OSError, subprocess.SubprocessError) as exc:
|
|
fallback = args.export or _default_bundle_path(plan)
|
|
saved = write_bundle(plan, fallback)
|
|
raise HandoffError(f"{exc} The complete handoff was saved at {saved}.") from exc
|
|
if args.command_output:
|
|
if not args.create:
|
|
raise HandoffError("--command-output requires --create.")
|
|
print(_command_output(output["codex"]))
|
|
else:
|
|
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
return 0
|
|
except (HandoffError, OSError, subprocess.SubprocessError) as exc:
|
|
print(f"handoff failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|