Max Narditdata & ai systems
← Articles
8 mindev

Importing Claude Code memory into Codex 0.165: enable memories before the first import, or the profile stays on v1

Copying the notes is the easy part. Every Claude Code file survives intact, and what Codex loads at session start is a model-written route to it, inside a summary with a hard size cap. Two default-off switches and one stray directory decide which memory pipeline those files land in.

Contents

The Codex CLI 0.165.0 release notes, out on October 27, give the change one line: memory v2 is now the default for fresh profiles, and Codex can import “external-agent memories” into it. External agent here means Claude Code. The importer reads the auto memory Claude Code keeps per project, copies it into the Codex memory workspace, and queues a model pass that folds it into the summary Codex injects into every new session.

The line leaves out the flag and the order. The import sits behind a feature flag that is off and marked as under development, so on a stock install /import does not offer memory at all. And whether you end up on v2 depends on the configuration the first import runs under: with only the import flag on, the import itself creates the directory that keeps your profile on v1.

I read the source at the rust-v0.165.0 tag and then ran the import through codex app-server against a throwaway home directory with three fake Claude Code projects. Everything below comes from one of those two, and I say which.

Can Codex 0.165 import Claude Code memory out of the box?

No. The memory row of the import flow appears only when the external_agent_memory_import feature is on, and in features/src/lib.rs it is Stage::UnderDevelopment with default_enabled: false. Memory itself is a separate feature, memories, which is stable but also off by default.

In the run, detection on a clean profile returned only the recent chat sessions. After adding the flag to config.toml, a Memory item appeared. Set both features together, before any import:

toml
[features]
memories = true
external_agent_memory_import = true

If you send an import request for memory without the flag, the app server refuses it with external agent memory import is disabled. In the TUI, /import puts Memory in the home section as one checkbox, ticked by default along with everything else it detected.

Which memory version does Codex use after importing Claude Code memory?

When memories.version is unset, Codex defaults to v2 only if the memories feature is on, memories.dual_write is not explicitly false, and ~/.codex/memories, the v1 directory, does not exist. That is the whole of “fresh profiles default to v2” in core/src/config/mod.rs. Otherwise it falls back to v1. An explicit memories.version always wins.

The importer has its own rule: it always writes ~/.codex/memories_v2, and it also writes ~/.codex/memories when the resolved version is v1 or that directory already exists. The sequence most people will try goes wrong. You turn on only the import flag, because that is the one the import needs. With memories off, the version resolves to v1, the import writes both namespaces, and ~/.codex/memories now exists. When you later turn memories on, Codex sees a v1 workspace and keeps you on v1.

That is what the run showed. With only the import flag set, the import created both memories and memories_v2. On a second clean profile with memories = true in the same file, it created memories_v2 alone. The app-server integration test encodes the same matrix, five cases from “new v2 user” to “legacy workspace”.

One setting reads the other way from its name. memories.dual_write = false does not stop the import writing two copies. It is the opt-out from the v2 default, and keeps a fresh profile on v1. Real dual-write, where startup runs both pipelines, happens only with version = "v1" and dual_write = true, and dual_write defaults to false.

Setting version = "v2" under [memories] pins the startup pipeline to v2, but it does not enable memory, and it does not stop the import from also writing to ~/.codex/memories if that directory exists. If you already have one, the import keeps both copies current.

What does the Codex importer copy from Claude Code’s memory folder?

Every Markdown file under each project’s memory/ directory, byte for byte. Claude Code keeps auto memory in ~/.claude/projects/<project>/memory/: a MEMORY.md index plus one topic file per memory, with frontmatter. The importer walks that tree recursively, takes regular files ending in .md in any letter case, and skips symbolic links, both linked files and linked directories.

Each project lands in its own folder with one extra file:

~/.codex/memories_v2/extensions/external_agent_import/
├── instructions.md
└── resources/
    └── -work-app-a/
        ├── scope.json        {"cwd":"/home/you/work/app-a"}
        ├── MEMORY.md
        ├── build.md
        └── notes/deep.md

In the run, cmp found the copied topic file identical to the source, frontmatter included, and the nested notes/deep.md kept its path. A symlinked linked.md in the same folder did not arrive. The folder name is the Claude Code project key, the escaped path you see under ~/.claude/projects. Your MEMORY.md is copied as it is, and the consolidation instructions forbid editing anything under resources/. Nothing is written back to Claude Code.

Which Claude Code projects does the Codex importer skip?

Any project for which it cannot find a working directory. Claude Code’s memory folder does not record which directory it belongs to, so the importer reads the project’s session transcripts, newest first, and takes the cwd of the first usable one. Usable means the transcript has a conversation message with a timestamp, was not replaced by a continuation, and names an absolute directory that still exists. That becomes scope.json. A project with no usable session is not offered for a first import.

Two ordinary situations produce that. Claude Code deletes old session transcripts after its cleanupPeriodDays retention period, but keeps the memory directory, so a project you have not opened in a while can have memory and no transcripts. And a project whose directory you moved or deleted has transcripts pointing at nothing. My fixture had one of each. Neither was listed, and when I added them to the request by hand, the result reported both as failures:

selected memory was not found: -work-app-b
selected memory was not found: -work-app-c

A project that was imported earlier and has since lost its transcripts keeps its existing copy; the importer only skips refreshing it.

The source is normally $HOME/.claude. The importer does not read Claude Code’s own location overrides, so if you moved auto memory with autoMemoryDirectory, or run Claude Code under CLAUDE_CONFIG_DIR, it does not look there. Per the Claude Code docs, all worktrees of a repository share one memory directory. The importer scopes it to whichever checkout the newest usable session ran in, which may be a worktree rather than the main repository.

This script is a heuristic for the cwd half of that logic, so you can see which projects have a usable directory before you start. The authoritative list is what detection offers; the script does not compare against copies that already exist.

python
import json, os, pathlib
 
def usable_cwd(session):
    cwd, has_message = None, False
    try:
        lines = session.read_text(errors="ignore").splitlines()
    except OSError:
        return None
    for line in lines:
        try:
            rec = json.loads(line.strip())
        except ValueError:
            continue
        if not isinstance(rec, dict):
            continue
        if rec.get("type") == "continued-in" and rec.get("continuedInSessionId"):
            return None
        if cwd is None and isinstance(rec.get("cwd"), str):
            cwd = rec["cwd"]
        if isinstance(rec.get("message"), dict) and rec.get("timestamp"):
            has_message = True
    if cwd and has_message and os.path.isabs(cwd) and os.path.isdir(cwd):
        return os.path.realpath(cwd)
    return None
 
root = pathlib.Path.home() / ".claude" / "projects"
for proj in sorted(root.iterdir()) if root.is_dir() else []:
    mem = proj / "memory"
    if proj.is_symlink() or mem.is_symlink() or not mem.is_dir():
        continue
    files = [f for f in mem.rglob("*") if f.suffix.lower() == ".md"
             and f.is_file() and not f.is_symlink()]
    sessions = [s for s in proj.glob("*.jsonl") if s.is_file() and not s.is_symlink()]
    sessions.sort(key=os.path.getmtime, reverse=True)
    cwd = next((c for s in sessions if (c := usable_cwd(s))), None)
    print("CWD FOUND" if cwd and files else "NO CWD   ", len(files), proj.name, cwd or "")

On the fixture it printed one CWD FOUND and two NO CWD lines, matching what detection offered.

What does Codex consolidation do with your Claude Code MEMORY.md?

It reads it and writes routes to it into a separate file. After copying, the importer queues a consolidation job, a model pass that writes memory_summary.md. The v2 consolidation prompt says that file “will be injected at the beginning of every new session”, and the validator rejects a summary that does not start with the line v1, is 10,000 bytes or more, or lacks any of these headings:

## User Profile
## User preferences
## General Tips
## What's in Memory

The importer adds its own rules in instructions.md. These four bullets, quoted whole, decide what happens to a project you curated by hand:

- For each project, always read its source `MEMORY.md` first when it exists.
  Use it to add or update the project's memory entry in the active pipeline's
  output files.
- Treat every other source `*.md` file as detailed supporting evidence analogous
  to a rollout summary. Do not flatten its full contents into Codex `MEMORY.md`
  or `memory_summary.md`. Keep the detail in the imported resource, add a concise
  pointer in `memory_summary.md` when useful, and read the resource progressively
  when a later task needs that topic.
- In `memory_summary.md`, represent imported project memory only as a compact
  route under `## What's in Memory`. Do not copy its contents into
  `## User Profile`, `## User preferences`, or `## General Tips`, even with a
  project-scope qualifier.
- Imported resources have no rollout `updated_at`. When no reliable source date
  exists, route them under `### Older Memory Topics`; do not invent a date or use
  the consolidation date.

So the instructions ask the model to turn each Claude Code project into a compact route, when it judges one useful, and to file it under Older Memory Topics unless it finds a date it trusts. All of that competes for one 10,000-byte summary with every other imported project and with Codex’s own memory. A preference you wrote down once in Claude Code does not become a Codex preference, because the rules forbid lifting imported content into the profile sections. Whether the model treats the modified timestamp Claude Code writes into frontmatter as a reliable date is left to the model. Nothing in the code parses it.

The rule is defensible: a preference recorded for one repository should not turn global because it was copied. It does mean the import is not a migration of how you work. It is a library of project notes with a catalogue entry for some of them, and the entries are written by a model. I made a related argument in Agent memory as Markdown documents: the index is the retrieval layer. Here your index survives on disk, and the one Codex actually loads is somebody else’s summary of it.

Consolidation runs in the memory startup pipeline, which returns early when the memories feature is off. With only the import flag on, the files arrive and nothing reads them. I did not run consolidation, so I cannot show what the routes look like. That part is the prompt and the validator, not an observation.

What happens when you re-run the Codex memory import?

It synchronizes the projects you select. For each one, the importer deletes the project’s folder under resources/ and writes it again from the source, so a file you removed in Claude Code disappears from Codex too. In the run, deleting build.md from the fake project and importing again removed it from both namespaces. Detection also looks at the copies Codex already holds: a copied project that no longer exists in Claude Code is offered again, and importing it deletes the copy. A project whose copy is already identical is not offered at all.

The sync covers only the copies. What Codex learns in its own sessions lives apart, in rollout summaries, and is never written back to Claude Code. Each import queues a new consolidation, which applies the source changes to the shared summary and drops claims that only a deleted file supported. The two memories of one project still drift apart from the first Codex session onward. If you run both tools on the same repository (the splits people use), decide which tool’s memory is the record before you schedule re-imports.

Why does a Codex memory import fail with “memory workspace is busy”?

Because a consolidation or another import holds the workspace lock. Each namespace has a lock file next to it, ~/.codex/memories_v2.lock and ~/.codex/memories.lock, and the import takes every lock it needs before it changes anything. If one is held, it stops with:

memory workspace is busy; retry after consolidation or another import finishes

Nothing has been written at that point, so there is no half-imported v1 copy to clean up. Retry once the consolidation finishes. Pull request #56462 closed the race between import and consolidation.

What to check before importing Claude Code memory into Codex

Try it on a scratch profile first. Point CODEX_HOME at an empty directory, put both features in that directory’s config.toml, and start codex from a disposable working directory, because other import items can add files to the current project. In /import, choose Customize selection and leave only Memory ticked. Then look at what lands in memories_v2/extensions/external_agent_import/resources/ before you do the same on your real profile. The import reads ~/.claude and does not modify it.

On the real profile, check three things:

  1. Put memories = true and the import flag in config.toml before the first import. If ~/.codex/memories already exists, automatic v2 is off the table; set memories.version = "v2" if you want the v2 pipeline anyway.
  2. Run the script above. Every NO CWD line is a project whose memory will not come across, usually because its transcripts were cleaned up. Starting a conversation in that project with Claude Code writes a new transcript with a live cwd.
  3. Read the full project list before you approve it. The TUI shows the count and only the first four project keys, and its one checkbox imports every detected project into one profile and one summary. scope.json plus a sentence in the instructions are all that keep one project’s notes out of another’s sessions. If some of those projects belong to other people, or simply should not share a summary, use a separate CODEX_HOME for them, or the app-server API, which takes an explicit list of project keys.

In this import, the defaults are two off switches and one directory that should not exist yet.

01 / Discussion

No comment section here — all discussions happen on X.

Max Nardit@mnardit

02 / More articles