Codex는 한 세션의 기억을 속기록(Rollout) · 색인장부(state) · 학습위키(Memories) 세 갈래로 따로 보관한다.
AI가 “어제 한 일을 이어서” 하고 “예전에 배운 걸” 써먹게 하려면, 무엇을 어디에 어떻게 남기는지를 알아야 직접 만들거나 디버깅할 수 있다.
그림
flowchart TD
subgraph 세션진행중["세션 진행 중"]
T["turn 사건<br/>(사용자말·모델답·도구호출)"] --> P{"policy:<br/>남길 가치 있나?"}
P -- "예" --> R["('Rollout JSONL<br/>한 줄씩 추가')"]
R --> X["apply_rollout_item<br/>(메타만 추려서)"] --> S["('state SQLite<br/>threads 색인')"]
end
R -- "스레드가 한가해지면" --> M1["1단계 추출<br/>스레드별 교훈 뽑기"]
M1 --> M2["2단계 통합<br/>MEMORY.md로 정리"]
subgraph 다음세션["다음 세션 시작"]
RES["Resume:<br/>로그 다시 읽기"] --> H["복원된 대화 이력"]
MEM["use_memories:<br/>MEMORY.md 읽기"] --> CTX["모델 프롬프트"]
H --> CTX
end
R -.."재개".-> RES
M2 -.."주입".-> MEM
쉽게 풀기
회사 비유로 보면 기억은 세 종류의 문서다.
flowchart LR
R["Rollout<br/>회의 속기록<br/>(JSONL 원본)"]
S["state<br/>캐비닛 장부<br/>(SQLite 색인)"]
M["Memories<br/>사내 위키<br/>(MD 교훈집)"]
R -- "메타만 베껴" --> S
R -- "교훈만 추려" --> M
1) Rollout = 회의 속기록. 세션 중 모든 사건(사용자 말·모델 답·도구 호출·추론)을 발생 순서대로 한 줄씩 받아적는다(~/.codex/sessions/2025/06/15/rollout-….jsonl, JSON Lines). 이것만 있으면 회의를 그대로 다시 틀어(resume) 이어갈 수 있다.
단, 속기사는 다 적지 않는다 — “음…” 같은 잡음(스트리밍 중간 글자, 명령 시작/끝 신호)은 빼고 재생에 꼭 필요한 발언만 적는다. 이 선별 규칙이 policy.rs다.
2) state = 캐비닛 장부(색인). 속기록이 수천 개 쌓이면 목록·정렬·검색을 매번 전부 펼쳐 읽기엔 느리다. 그래서 회의의 제목·날짜·작업폴더 같은 메타만 SQLite에 거울처럼 베껴둔다. 빠른 조회용 색인일 뿐 진짜 원본은 속기록이며, 장부가 망가져도 속기록에서 다시 만든다.
3) Memories = 사내 위키(교훈집). 본문이 아니라 여러 회의에서 반복 학습한 교훈(이 사람 스타일, 이 프로젝트의 함정)만 추려 마크다운으로 정리한다. 다음 세션 시작 시 모델이 이 얇은 위키를 읽는다. 본문 전체를 끌어오면 컨텍스트가 폭발하므로 교훈만 압축해 싸게 주입한다. 기본은 꺼져 있다(OFF).
한 문장으로 구분
Rollout = “이 세션에 뭐가 있었나”의 원본 재생 로그 · state = “내 세션들이 뭐가 있나”의 SQLite 색인 · Memories = “과거에서 뭘 배웠나”의 학습 위키. 셋은 파일도, 포맷도, 수명도 다르다.
핵심 정리
시스템
한 줄 정체
모델과 닿는 시점
Rollout
사건별 재생 로그(JSONL)
매 turn append → 다음 세션에 통째 주입
state
스레드 메타 색인(SQLite)
닿지 않음(앱 UI/CLI 조회용)
Memories
압축된 장기 교훈(MD)
다음 세션 시작 시 주입(기본 OFF)
state는 SQLite 4개 파일로, Memories는 ~/.codex/memories/ 산출물로 나뉜다.
resume 시 파일에서 처음 만난 SessionMeta의 id를 thread_id 정본으로 사용
Memories는 교훈만, 사용 시 <oai-mem-citation>으로 출처 표기
실제 예시
Rollout 한 줄의 구조
한 줄은 { "timestamp", "type", "payload" } 구조다. type은 session_meta / response_item / inter_agent_communication / compacted / turn_context / event_msg 중 하나이며, 첫 줄은 항상 session_meta(스레드 ID·cwd·CLI버전·git정보 등)다. 검사는 jq -C . rollout-….jsonl.
펼쳐보기: Rollout JSONL 실제 3줄
# ~/.codex/sessions/2025/05/07/rollout-2025-05-07T17-24-21-<thread_id>.jsonl{"timestamp":"2025-05-07T17:24:21.001Z","type":"session_meta","payload":{"id":"5973b6c0-94b8-487b-a530-2aeb6098ae0e","timestamp":"2025-05-07T17:24:21.000Z","cwd":"/home/me/proj","originator":"codex_cli","cli_version":"0.x","source":"cli","model_provider":"openai","git":{"branch":"main"}}}{"timestamp":"2025-05-07T17:24:25.300Z","type":"event_msg","payload":{"type":"user_message","message":"fix the bug"}}{"timestamp":"2025-05-07T17:24:30.900Z","type":"response_item","payload":{"type":"function_call","name":"shell","arguments":"..."}}
직렬화·파일명 생성 코드
핵심은 두 가지다. ① 매 줄을 timestamp + item으로 flatten해 직렬화 후 flush. ② 경로를 연/월/일 폴더로 나누고 파일명의 콜론을 하이픈으로 치환.
펼쳐보기: 전체 Rust 코드 (recorder.rs — 직렬화 + 경로/파일명)
// codex-rs/rollout/src/recorder.rs#[derive(serde::Serialize)]struct RolloutLineRef<'a> { timestamp: String, #[serde(flatten)] item: &'a RolloutItem,}impl JsonlWriter { async fn write_rollout_item(&mut self, rollout_item: &RolloutItem) -> std::io::Result<()> { let timestamp_format: &[FormatItem] = format_description!( "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z" ); let timestamp = OffsetDateTime::now_utc() .format(timestamp_format) .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; let line = RolloutLineRef { timestamp, item: rollout_item }; self.write_line(&line).await // serde_json::to_string + '\n' + write_all + flush }}
// recorder.rs (precompute_log_file_info)let mut dir = config.codex_home().to_path_buf();dir.push(SESSIONS_SUBDIR); // "sessions"dir.push(timestamp.year().to_string()); // 2025dir.push(format!("{:02}", u8::from(timestamp.month()))); // 06dir.push(format!("{:02}", timestamp.day())); // 15// 콜론 불가 FS 호환 위해 ':' 대신 '-'let date_str = timestamp.format(format!("[year]-[month]-[day]T[hour]-[minute]-[second]"))?;let filename = format!("rollout-{date_str}-{conversation_id}.jsonl");let path = dir.join(filename);
무엇을 기록할지 거르는 게이트 (policy.rs)
모든 사건을 적지 않는다. is_persisted_rollout_item이 문지기다 — 재생에 필요한 본문만 통과시키고, 수명주기 잡음은 버린다.
flowchart TD
E["사건 발생"] --> G{"is_persisted_<br/>rollout_item"}
G -- "메시지·추론·함수호출·패치<br/>UserMessage·AgentMessage<br/>TokenCount·Turn시작/완료" --> Y["기록 "]
G -- "스트리밍 델타·ExecBegin<br/>Other·CompactionTrigger" --> N["버림 "]
2단계(consolidate, 전역): 여러 스레드의 raw 메모리를 통합 서브에이전트(Medium)가 MEMORY.md/memory_summary.md로 정리·중복제거. 워크스페이스 diff(phase2_workspace_diff.md)를 먼저 읽고 삭제된 증거 기반 메모리는 제거.
주입(read): use_memories가 켜져 있으면 다음 세션에서 모델이 MEMORY.md를 읽고, 사용 시 출처를 표기.