Codex · 상태·메모리 영속성 (Rollout JSONL + state SQLite + Memories)
Codex · 상태·메모리 영속성 (Rollout JSONL + state SQLite + Memories)
한 줄 요약
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).
[!note] 한 문장으로 구분 Rollout = “이 세션에 뭐가 있었나”의 원본 재생 로그 · state = “내 세션들이 뭐가 있나”의 SQLite 색인 · Memories = “과거에서 뭘 배웠나”의 학습 위키. 셋은 파일도, 포맷도, 수명도 다르다.
핵심 정리
| 시스템 | 한 줄 정체 | 모델과 닿는 시점 |
|---|---|---|
| Rollout | 사건별 재생 로그(JSONL) | 매 turn append → 다음 세션에 통째 주입 |
| state | 스레드 메타 색인(SQLite) | 닿지 않음(앱 UI/CLI 조회용) |
| Memories | 압축된 장기 교훈(MD) | 다음 세션 시작 시 주입(기본 OFF) |
state는 SQLite 4개 파일로, Memories는 ~/.codex/memories/ 산출물로 나뉜다.
[!note]- 펼쳐보기: 전체 파일 목록 (state SQLite 4종 · Memories 산출물) state SQLite (모두
~/.codex아래)
state_5.sqlite— 스레드 메타 미러(목록·검색·정렬), agent_jobslogs_2.sqlite— 런타임 로그(파티션당 10 MiB / 1000행 상한)goals_1.sqlite— 스레드 골(thread_goals)memories_1.sqlite— 메모리 파이프라인 1단계 출력 + 작업 큐Memories 산출 파일 (
~/.codex/memories/)
MEMORY.md— 모델이 읽는 메모리 색인(2단계 통합이 생성)memory_summary.md— 통합 요약raw_memories.md— DB 1단계 출력 병합본(스레드ID 오름차순)rollout_summaries/<stem>.md— 스레드별 롤아웃 요약extensions/<name>/instructions.md— 소스별 해석 지침
설계 원칙 체크 포인트:
- Rollout이 진실 원본, state는 파생 캐시 — 손상 시 rollout 재스캔으로 복구
- Rollout 파일명은 콜론(
:) 금지 → 하이픈(-)으로 대체(FS 호환) - 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.
[!note]- 펼쳐보기: 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. ② 경로를 연/월/일 폴더로 나누고 파일명의 콜론을 하이픈으로 치환.
[!note]- 펼쳐보기: 전체 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()); // 2025 dir.push(format!("{:02}", u8::from(timestamp.month()))); // 06 dir.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["버림 "]
ResponseItem: 메시지/추론/함수호출/패치만 true.Other·CompactionTrigger는 false.EventMsg:UserMessage·AgentMessage·TokenCount·TurnStarted/Complete·ContextCompacted만 true.AgentMessageContentDelta·ExecCommandBegin등은 false.
state의 threads 테이블 (조회용 색인)
threads 테이블은 rollout을 역참조(rollout_path)하고 정렬·필터에 필요한 메타만 담는다. 진단 코드는 절대 DB를 생성/수리하지 않는 read-only다.
[!note]- 펼쳐보기: threads 스키마 + audit read-only 코드 + 마이그레이션 안전장치
-- state/migrations/0001_threads.sql (요약) -- id(PK), rollout_path(대응 jsonl 절대경로 = state→rollout 역참조), -- created_at/updated_at(둘 다 DESC 인덱스, 정렬용), source, model_provider, -- cwd, title, sandbox_policy, approval_mode, tokens_used(default 0), -- has_user_event, archived/archived_at, git_sha/git_branch/git_origin_url// state/src/audit.rs — 진단용 read-only 행 (절대 생성/수리 안 함) pub struct ThreadStateAuditRow { pub id: String, pub rollout_path: PathBuf, pub archived: bool, pub source: String, pub model_provider: String, } // SqliteConnectOptions: create_if_missing(false).read_only(true)마이그레이션 안전장치:
lib.rs는SQLITE_VERSION_NUMBER >= 3_051_003(WAL-reset 손상 픽스 포함)을 컴파일타임에 assert. 마이그레이션은 버전+체크섬으로 검증하되ignore_missing:true로 “DB가 나보다 최신”(다른 신버전 바이너리가 이미 마이그레이션함) 경우를 허용한다.
Memories 파이프라인 (2단계 추출)
스레드별로 교훈을 뽑고(1단계) → 전역에서 통합·중복제거(2단계) → 다음 세션에서 주입(read)한다.
flowchart LR RO["rollout 본문"] -- "스레드 한가" --> S1["1단계 추출<br/>Low·동시성8<br/>컨텍스트 70% truncate"] S1 --> RAW["raw_memory /<br/>rollout_summary"] RAW --> S2["2단계 통합<br/>Medium 서브에이전트<br/>diff 보고 삭제분 제거"] S2 --> MD["MEMORY.md /<br/>memory_summary.md"] MD -- "use_memories ON" --> RD["다음 세션 주입<br/>+ 출처 표기"]
- 1단계(extract, 스레드별): 스레드가 한가해지면 rollout 본문을 모델(ReasoningEffort=Low, 동시성 8)이 요약 →
stage1_outputs.raw_memory/rollout_summary저장. 입력은 컨텍스트의 70%로 truncate. - 2단계(consolidate, 전역): 여러 스레드의 raw 메모리를 통합 서브에이전트(Medium)가
MEMORY.md/memory_summary.md로 정리·중복제거. 워크스페이스 diff(phase2_workspace_diff.md)를 먼저 읽고 삭제된 증거 기반 메모리는 제거. - 주입(read):
use_memories가 켜져 있으면 다음 세션에서 모델이MEMORY.md를 읽고, 사용 시 출처를 표기.
[!note]- 펼쳐보기: raw_memories.md 재구성 코드 + 인용 토큰 형식
// memories/write/src/storage.rs — 1단계 출력 → raw_memories.md 재구성 body.push_str("Merged stage-1 raw memories (stable ascending thread-id order):\n\n"); for memory in retained { writeln!(body, "## Thread `{}`", memory.thread_id)?; writeln!(body, "updated_at: {}", memory.source_updated_at.to_rfc3339())?; writeln!(body, "cwd: {}", memory.cwd.display())?; writeln!(body, "rollout_path: {}", memory.rollout_path.display())?; let rollout_summary_file = format!("{}.md", rollout_summary_file_stem(memory)); writeln!(body, "rollout_summary_file: {rollout_summary_file}")?; body.push_str(memory.raw_memory.trim()); } tokio::fs::write(raw_memories_file(root), body).await# 모델이 메모리를 근거로 댈 때 답변에 끼우는 인용 토큰 # memories/read/src/citations.rs <oai-mem-citation> <citation_entries> MEMORY.md:1-2|note=[x] ← path:line_start-line_end|note=[...] </citation_entries> <rollout_ids> 019cc2ea-1dff-7902-8d40-c8f6e5d83cc4 </rollout_ids> </oai-mem-citation>
[!note] Memories 게이트 기본 OFF(
[features] memories=true필요). active/단명 세션 제외, 비밀 redaction, rate-limit 잔량이 임계 미만이면 백그라운드 패스 스킵,disable_on_external_context면 MCP/웹검색 쓴 스레드 제외.
직접 만들 때: 최소 rollout writer (개념 복제)
append-only 기록 + policy 게이트 + 첫 session_meta.id를 정본으로 쓰는 resume — 핵심 3요소만 담은 재현이다.
[!note]- 펼쳐보기: 전체 Python 코드 (minimal_rollout.py)
# minimal_rollout.py — rollout JSONL append + resume 개념 재현 import json, os, datetime, uuid def now(): return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + f"{datetime.datetime.now().microsecond//1000:03d}Z" def session_path(home, thread_id): d = datetime.datetime.now() sub = os.path.join(home, "sessions", f"{d.year}", f"{d.month:02d}", f"{d.day:02d}") os.makedirs(sub, exist_ok=True) fname = f"rollout-{d.strftime('%Y-%m-%dT%H-%M-%S')}-{thread_id}.jsonl" # ':' 금지 return os.path.join(sub, fname) PERSIST = {"session_meta","response_item","event_msg","turn_context","compacted"} # policy 게이트 def append(path, type_, payload): if type_ not in PERSIST: return with open(path, "a") as f: # append-only f.write(json.dumps({"timestamp": now(), "type": type_, "payload": payload}) + "\n") f.flush() def resume(path): # 한 줄씩 파싱, 첫 session_meta.id가 정본 items, thread_id = [], None for line in open(path): line = line.strip() if not line: continue rec = json.loads(line) if thread_id is None and rec["type"] == "session_meta": thread_id = rec["payload"]["id"] items.append(rec) return thread_id, items # items → 다음 프롬프트 컨텍스트 if __name__ == "__main__": home = os.path.expanduser("~/.mycodex"); tid = str(uuid.uuid4()) p = session_path(home, tid) append(p, "session_meta", {"id": tid, "cwd": os.getcwd(), "source": "cli"}) append(p, "event_msg", {"type": "user_message", "message": "fix bug"}) append(p, "response_item", {"type": "function_call", "name": "shell"}) append(p, "event_msg", {"type": "agent_message_content_delta", "delta": "x"}) # PERSIST 아님 → 무시 print(resume(p))
요약 & 셀프체크
3줄 요약:
- Rollout(JSONL 속기록)은 매 turn 선별 append되고, 다음 세션 resume 시 대화 이력으로 통째 복원된다.
- state(SQLite)는 빠른 조회용 색인일 뿐 모델과 직접 닿지 않으며, 망가지면 rollout에서 재구축한다.
- Memories(MD 위키)는 본문이 아닌 교훈만 2단계로 압축해 다음 세션에 싸게 주입한다(기본 OFF).
스스로 답해보기:
- 같은 사건도 Rollout엔 남고 state엔 안 남을 수 있다. 왜? (힌트: 진실 원본 vs 파생 색인, policy 게이트)
- Rollout 파일명에 콜론(
:) 대신 하이픈(-)을 쓰는 이유는? - Memories가 rollout 본문 전체를 끌어오지 않고 “교훈만” 저장하도록 설계한 이유는?
연결
[!tip] 근거 파일 (소스 교차검증)
rollout/src/recorder.rs— RolloutRecorder, Create/Resume params, precompute_log_file_info(경로·파일명), JsonlWriter(RolloutLineRef·flush), load_rollout_items/get_rollout_history(resume)rollout/src/policy.rs— is_persisted_rollout_item / should_persist_response_item / should_persist_event_msg (기록 선별)rollout/src/lib.rs— SESSIONS_SUBDIR, INTERACTIVE_SESSION_SOURCES, 공개 APIprotocol/src/protocol.rs— RolloutLine, RolloutItem(enum tag/payload), SessionMeta, SessionMetaLine, GitInfostate/src/lib.rs— DB 파일명 상수(state_5/logs_2/goals_1/memories_1), SQLITE_HOME_ENV, SQLite 버전 assertstate/src/runtime.rs— RuntimeDbSpec(STATE/LOGS/GOALS/MEMORIES_DB), path=codex_home.join, StateRuntime::initstate/src/migrations.rs— 4개 Migrator, runtime_migrator(ignore_missing)state/src/audit.rs— ThreadStateAuditRow, read-only SELECTstate/migrations/0001_threads.sql— threads 테이블 스키마state/memory_migrations/0001_memories.sql— stage1_outputs / jobs 테이블memories/write/src/lib.rs— memory_root, 산출물 파일명, stage_one/stage_two 상수memories/write/src/storage.rs— raw_memories.md / rollout_summaries 재구성, file_stemmemories/write/src/prompts.rs— stage_one_input / consolidation 프롬프트 렌더, 70% truncatememories/read/src/lib.rs,usage.rs,citations.rs— memory_root, MemoriesUsageKind, citation 파싱thread-store/src/store.rs,lib.rs— ThreadStore 트레이트(create/resume/append/load_history), InMemory vs Local/mnt/d/6study/10_프레임워크분석/_원문아카이브/codex/62_memories.md— 공식 Memories 문서(OFF 기본, use_memories/generate_memories, 저장 위치, /memories)- 경로 루트:
/home/seunghyeong/harness-work/codex/codex-rs/