~18 min · master-chief, context-compaction, pagination, sync
Level 0구경꾼
0 XP0/33 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete
재생에선 빠지고, 기록에선 안 빠지고
압축이 행을 가져가도 지우진 않아. 행은 메시지 테이블에 그대로 있고, 표시만 붙어. compacted_at은 언제인지, compacted_by는 이제 어느 요약 행이 그 행들을 대신하는지 말해. 요약 행은 남긴 첫 턴 바로 앞 시각으로 들어가. 그래서 평평하게 늘어놓으면 옛 행, 요약, 최근 턴 순이 돼. 작은 테이블 하나가 화면에서 요약이 어느 보관 행 뒤에 오는지, 몇 행을 대신하는지 적어 둬. 모델이 걷는 사슬이 안 끊기게, 남긴 행 중에 부모가 빠져나간 건 전부 요약 밑으로 다시 매달려. 재생과, 재생을 고치는 치유는 compacted_at이 빈 행만 읽어. 키로 행을 찾는 건 전부 여전히 찾아. 다시 보낸 턴이 두 번 안 돌게 막는 요청 id 확인, 보고가 두 번 안 꽂히게 막는 보고 확인, 턴 상태, 대화 안을 가리키는 Vestige의 포인터까지. 대화를 지우면 압축된 행도 같이 지워져.
지우는 게 치른 값: 콜드 리뷰 4번 지적
첫 압축은 행의 JSON을 옆 테이블에 보관하고 행을 지웠어. 그러자 키로 찾는 건 전부 그 행을 잃었어. 리뷰어가 사슬을 따라갔어. 폰이 보낸 턴이 도착했냐고 물어. 요청 id로 찾으면 이제 아무것도 안 나와. 그래서 턴 상태가 404로 답하고, 클라이언트는 그걸 "도착 안 했음, 보내"로 읽어. 폰의 보낼함이 다시 보내. Chief 라우트의 중복 확인도 빗나가. 그리고 철 지난 지시가 또 돌아. 보고 확인도 같은 식으로 행을 잃어서, 같은 보고가 두 번 꽂힐 수 있었어. 리콜은 지금 대화를 빼고 찾아서, Chief의 보관된 부분은 Chief 안에서 닿을 수가 없었어. Vestige 포인터도 그 행들로 안 풀렸어. Chief 자신의 지시가 약속한 복구 경로가 바로 거기서 끊긴 거야. 고친 커밋(2154ed97)은 지우는 대신 표시하고, 시작할 때 옆 테이블을 메시지 테이블로 도로 접어 넣은 뒤 없애.
기록은 고치지 않고, 표시 줄을 더해
JSONL은 절대 다시 안 써. 압축은 pippa_compaction 줄 하나를 덧붙여. 브레인, 방식(model, digest, background 중 하나), 개수, 요약 행 id, 요약 본문 전체, 가져간 행들의 id가 들어 있어. 다이제스트를 올릴 땐 더 짧은 줄을 붙여. 방식은 upgrade고, 브레인과 요약 행 id와 새 본문만 있어. 개수도 행 id도 없어. 그 다이제스트가 대신하는 행은 다이제스트 자신의 표시 줄이 이미 적어 뒀으니까. 그 전엔 요약이 SQLite에만 있었고 (콜드 리뷰 13번), "내 기록 어디 갔어?"에 답할 흔적이 기록에 하나도 없었어.
리콜이 그 행들에 닿는다
압축된 행의 검색 벡터엔 compacted 표시가 붙어. 리콜은 보통 지금 대화를 건너뛰어. 그 재생은 이미 프롬프트에 있으니까. 이제 필터는 "다른 대화, 아니면 압축된 행"이라고 읽어. 그래서 끝나지 않는 대화도 자기 과거를 떠올릴 수 있어. 그다음 할 일은 Chief의 상시 지시가 말해. "When earlier context is missing after compaction, use Vestige recall and open the source records before asking Dad to repeat it." 행이 남아 있어야 이 문장이 참이 돼. 압축이 행을 지우던 땐 이 문장이 가리키는 게 아무것도 없었어.
보이는 기록은 안 줄어. 한 번에 다 오지도 않고
아빠가 스크롤하는 건 화면용 대화록이야. 보관된 행 전부를 오래된 순으로, 그다음 살아 있는 행. 한 번에 창 하나씩 나가. 기본은 최신 창, 위로 스크롤하면 어떤 행 앞의 창, 새로고침이면 쥔 행부터 끝까지. 창마다 메시지 최대 1,000개, JSON 512 KiB 중 먼저 닿는 데서 멈춰. 맞을 때까지 오래된 쪽 끝에서 행이 빠지니까, 최신 행은 언제나 와. 예산보다 큰 행 하나는 혼자 나가. WebUI는 300개, 피파 앱은 200개를 달라고 해. 압축 요약은 절대 페이지에 안 실려 (coop #374). 2026-10-09에 Chief의 최신 메시지 200개가 2.47 MB였어. 대부분이 524 KB짜리 요약 하나를 세 번(본문, 정규 텍스트, 음성용 텍스트) 보낸 거였고, 나머진 도구를 잔뜩 쓴 턴들이었어. 폰은 그 페이지를 거부하고, MacBook에서 한 턴들이 빠진 낡은 사본을 그렸어. 보관된 행이 페이지에 직접 실리니까, 요약은 대화의 일부가 아니라 재생 쪽 상태로 쳐. 페이지에선, 요약에 매달렸던 행이 요약 바로 앞의 보관 행에 매달려.
동기화는 바뀐 것만 읽는다
WebUI 새로고침은 최신 페이지를 읽고 화면이 이미 쥔 것에 이어 붙여. 새 페이지에 들어 있는 첫 번째 쥔 행을 찾고, 그 앞은 다 두고, 거기서부터 페이지를 받아. 안 만나면 쥔 시작점부터 다시 읽어. 그 전엔 (콜드 리뷰 13번) 바뀔 때마다 탭을 연 뒤의 전부를 다시 받았어. Telegram 폴링은 5초마다 (epoch, version)이라는 싼 질문 하나부터 던져. version은 바뀔 때마다 오르는 메모리 속 카운터고, epoch은 엔진이 켜질 때마다 새로 뽑는 무작위 값이야. 카운터는 재시작하면 0부터 다시 세니까, 재시작 전의 "버전 3"이 재시작 뒤엔 다른 내용일 수 있거든. 그대로면 건너뛰어. 바뀌었으면 최신 메시지 100개만 읽고, 읽은 메시지가 전부 처리된 뒤에야 버전을 기억해. 콜드 리뷰가 이건 점검해 보고 문제없다고 했어.
아카이브는 조각을 쓴다
매일 도는 대화 아카이브는 덧붙이기만 하는 Markdown이야. 대화를 지워도 살아남게 하려고 있어. 근데 매일 Chief를 통째로 복사했어. 크기가 Chief 나이의 제곱으로 늘었고, 리뷰어 추정으론 Chief 하나로 1년에 10 GB쯤이었어. 코퍼스 엔진은 사본마다 다 색인했고 (콜드 리뷰 9번). 지금은 대화 디렉터리마다 매니페스트가 있어서, 아카이브가 어떤 메시지 버전을 쥐고 있는지, 어떤 봉인 세그먼트를 읽었는지 적어 둬. 첫 쓰기는 대화 전체야. 그 뒤로는 새 메시지(그리고 고쳐진 메시지의 새 텍스트)만 담은 조각이야. 안 읽은 세그먼트만 읽고, 요약은 빼 (ef5d6138).
건네는 건 묶고, 간직하는 건 묶지 마. 재생, 페이지, 폴링, 아카이브 쓰기는 전부 상한이 있어. 기록은 없어. 모든 행은 키로 찾을 수 있고, 리콜이 닿고, 아빠가 스크롤해 올라가면 페이지에 있어.
Code
엔진 발췌 (충실하게 줄임, 대화 저장소): 지우지 않고 제자리에서 표시하는 행·python
now = datetime.now(timezone.utc).isoformat()
removed = set(remove_ids)
kept_ids = {m.id for m in to_keep}
# Out of the replay, never out of the record (coop #351): the rows
# stay in ``messages``, flagged, so keys and pointers still find
# them and a conversation delete still takes them with it.
cursor = await db.execute(
f"SELECT id, created_at FROM messages WHERE id IN ({placeholders})", remove_ids,
)
archived = [{"id": row[0], "created_at": row[1]} for row in await cursor.fetchall()]
await db.execute(
f"UPDATE messages SET compacted_at = ?, compacted_by = ? WHERE id IN ({placeholders}) AND compacted_at IS NULL",
(now, summary_id, *remove_ids),
)
# ... the summary row is inserted just before the first kept turn,
# and every kept row whose parent left is re-parented onto it.
엔진 발췌 (원문 그대로, 리콜 필터): 지금 대화는 건너뛰되, 압축된 행은 예외·python
clauses: list[dict] = []
if exclude_conversation_id:
# The current conversation's replay is already in the prompt; its
# compacted rows are not (coop #351), so they stay retrievable. A
# conversation that never ends (Master Chief) recalls its own past.
clauses.append({"$or": [
{"conversation_id": {"$ne": exclude_conversation_id}},
{"compacted": True},
]})
돌려볼 수 있는 최소 스케치: 같은 압축을 지우기로 한 번, 표시로 한 번. 그 뒤 재시도 키와 페이지가 각각 뭘 보는지·python
# A minimal sketch: compaction that flags rows out of the replay, against
# the first version's delete. Same rows, same retry key, two outcomes.
import sqlite3
def build() -> sqlite3.Connection:
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE messages (id TEXT PRIMARY KEY, seq REAL, role TEXT, content TEXT,"
" external_request_id TEXT, compacted_at TEXT, compacted_by TEXT)")
for i in range(6):
db.execute("INSERT INTO messages VALUES (?, ?, ?, ?, ?, NULL, NULL)",
(f"m{i}", i, "user" if i % 2 == 0 else "assistant", f"turn {i}",
f"phone-{i}" if i % 2 == 0 else None))
return db
def compact(db: sqlite3.Connection, old: list[str], *, delete: bool) -> None:
marks = ",".join("?" * len(old))
if delete: # the first version: archived elsewhere, gone from messages
db.execute(f"DELETE FROM messages WHERE id IN ({marks})", old)
else: # rows leave the replay, never the record
db.execute(f"UPDATE messages SET compacted_at = 'now', compacted_by = 's1'"
f" WHERE id IN ({marks})", old)
# The summary sorts just before the first kept row.
db.execute("INSERT INTO messages VALUES ('s1', 3.5, 'assistant', '[summary of m0..m3]', NULL, NULL, NULL)")
for delete in (True, False):
db = build()
compact(db, ["m0", "m1", "m2", "m3"], delete=delete)
replay = [r[0] for r in db.execute("SELECT id FROM messages WHERE compacted_at IS NULL ORDER BY seq")]
retry = db.execute("SELECT id FROM messages WHERE external_request_id = 'phone-0'").fetchone()
page = [r[0] for r in db.execute(
"SELECT id FROM messages WHERE content NOT LIKE '[summary%' ORDER BY seq")]
print("DELETE" if delete else "FLAG ", "| replay:", replay,
"| retry key phone-0 finds:", retry[0] if retry else "nothing, so 'may be sent'",
"| page:", page)
네 프로젝트에서 뭔가를 작게 유지하려고 행을 지우거나 옮기는 곳을 찾아. 정리 작업, 아카이브 테이블, '기록 줄이기' 단계 같은 거. 그 행을 키로 찾는 리더를 전부 적어 (중복 확인, 상태 엔드포인트, 외래 키, 검색 색인, 다른 시스템에서 오는 링크). 각자 빗나가면 뭘 하는지도 적어. 그다음 정리 작업을 지우기 대신 행에 표시해서 자주 도는 경로에서만 빼도록 바꾸고, 자주 도는 쿼리에 그 표시를 넣어. 리더마다 표시된 행을 여전히 찾는다는 테스트를 하나씩 써.
Hint
정리 함수만 말고, 테이블 이름이랑 조회 키로 쓰이는 컬럼 전부로 grep해. 탈이 나는 건 정리 작업을 본 적도 없는 누가 몇 달 뒤에 짠 리더야. 재시도 경로, 내보내기, 다른 앱에서 오는 링크 같은 거.
Progress
Progress is local-only — sign in to sync across devices.