지식위키

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_jobs
  • logs_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" } 구조다. typesession_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.rsSQLITE_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. 1단계(extract, 스레드별): 스레드가 한가해지면 rollout 본문을 모델(ReasoningEffort=Low, 동시성 8)이 요약 → stage1_outputs.raw_memory/rollout_summary 저장. 입력은 컨텍스트의 70%로 truncate.
  2. 2단계(consolidate, 전역): 여러 스레드의 raw 메모리를 통합 서브에이전트(Medium)가 MEMORY.md/memory_summary.md로 정리·중복제거. 워크스페이스 diff(phase2_workspace_diff.md)를 먼저 읽고 삭제된 증거 기반 메모리는 제거.
  3. 주입(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줄 요약:

  1. Rollout(JSONL 속기록)은 매 turn 선별 append되고, 다음 세션 resume 시 대화 이력으로 통째 복원된다.
  2. state(SQLite)는 빠른 조회용 색인일 뿐 모델과 직접 닿지 않으며, 망가지면 rollout에서 재구축한다.
  3. Memories(MD 위키)는 본문이 아닌 교훈만 2단계로 압축해 다음 세션에 싸게 주입한다(기본 OFF).

스스로 답해보기:

  • 같은 사건도 Rollout엔 남고 state엔 안 남을 수 있다. 왜? (힌트: 진실 원본 vs 파생 색인, policy 게이트)
  • Rollout 파일명에 콜론(:) 대신 하이픈(-)을 쓰는 이유는?
  • Memories가 rollout 본문 전체를 끌어오지 않고 “교훈만” 저장하도록 설계한 이유는?

연결

CX_개요 · _분석축_루브릭

[!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, 공개 API
  • protocol/src/protocol.rs — RolloutLine, RolloutItem(enum tag/payload), SessionMeta, SessionMetaLine, GitInfo
  • state/src/lib.rs — DB 파일명 상수(state_5/logs_2/goals_1/memories_1), SQLITE_HOME_ENV, SQLite 버전 assert
  • state/src/runtime.rs — RuntimeDbSpec(STATE/LOGS/GOALS/MEMORIES_DB), path=codex_home.join, StateRuntime::init
  • state/src/migrations.rs — 4개 Migrator, runtime_migrator(ignore_missing)
  • state/src/audit.rs — ThreadStateAuditRow, read-only SELECT
  • state/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_stem
  • memories/write/src/prompts.rs — stage_one_input / consolidation 프롬프트 렌더, 70% truncate
  • memories/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/