/** * Memory Cleanup * * TTL 및 품질 기반 메모리 정리를 수행합니다. */ import type { LifecycleConfig, Memory } from '../types/index.js'; import type { MemoryDatabase } from '../db/database.js'; /** * #90 (ADR-053 T1): 자동삭제 금지 카테고리 — ADR-048 섹션 7.2 Hard Rules의 구현. * * ⚠️ ADR-048 섹션 7.2가 `{security, incident, decision, rule/policy, retrospective}` 보호를 * 명시했는데 실제 필터는 `skill`·`rule` 두 개뿐이었다(2026-08-03 발견). * * 🔴 **단, 실질 갭은 `decision` 1종이다** (재검증 정정). `incident`·`security`·`retrospective`는 * `MemoryCategory` 유니온(`types/index.ts:11-20`)에 **존재하지 않아** 저장 자체가 불가능하다 * (DB 실측 0행). 여기 남겨 두는 것은 그 카테고리가 추가될 때를 위한 전방 호환이며, * **지금 무보호였던 실 데이터는 `decision` 59행**이다. * * 🔴 정렬 기준 `scoreQuality`가 **access 프록시**(#22 / ADR-026 — access_count가 지배)라 * "드물게 참조되지만 사라지면 안 되는" 자산이 정확히 최하위로 분류된다. 즉 스코어에 맡기면 * 보호해야 할 것부터 지워진다 — 그래서 스코어보다 **우선하는** Hard Rule이어야 한다. */ export declare const CLEANUP_PROTECTED_CATEGORIES: ReadonlySet; /** * #90 (ADR-053 T1): 자동삭제 금지 태그 — 세션 맥락 인계 자산. * * `findRecentCheckpoint`(`session-start.ts:654`)가 **보장 주입**하는 슬롯의 원료다. * 이것이 지워지면 다음 세션이 직전 맥락을 잃는다 — goal("세션 간 맥락 유지") 직결. * * 🔴 **적용 지점 6곳** (2026-08-05 #91에서 2 → 6 확대): * 무효화 3곳 — `consolidation.ts`(conflict 폐기) / `post-tool-use.ts`(DELETE) / `session-end.ts`(DELETE) * 삭제 3곳 — 본 파일 soft-invalid 물리삭제(신설) / 2단계 lowQuality / 3단계 forced * * ⚠️ `consolidation.ts`의 conflict 분기는 **현재 도달 불가**다 — `detectConflict`의 한국어 패턴이 * `\b금지\b` 형태인데 JS `\b`는 `[A-Za-z0-9_]` 기준이라 한글에서 경계가 서지 않는다(실증 `false`). * 영문 패턴도 실 checkpoint 19건에 0건. 선제 방어이며, 정규식 정정은 선재 결함으로 별도 ticket. * * 🔴 **무효화가 물리 삭제보다 먼저 goal을 깬다** (#91 실측). `listMemories`가 기본으로 * `invalid_at IS NULL`을 걸므로(database.ts:1022), `invalid_at`이 설정되는 순간 검색·주입에서 빠진다. * 30일 뒤 물리 삭제는 그 다음 문제다. * * 🔴 **v1.1(2026-08-03) 주석의 stale 2건을 정정한다**: * (1) ~~"soft-invalidate `invalid_at` 0건이라 배제"~~ → **실측 208건**(그중 `context` 32, * `session-checkpoint` 태그 2). 그 경로는 배제된 적이 없고 지금 발동 중이었다. * (2) ~~"전체 7,126 / child 3,624 → 비교값 3,502 < 5,000"~~ → 「미진입」은 **구조적 성질이 아니라 * 시점 관측**이다. 판정식은 `getMemoryCount(projectPath, true) > maxMemories`이고 * 그 카운트는 `parent_id IS NULL`(child 제외)이다. 절대값을 적지 마라 — 이 저장소에서 * **세 번 stale**해졌다(v1.1 3,502 → #91 4,449 → 반나절 뒤 4,523). * 참고 관측: 2026-08-05 여유 약 500 미만, 일 증가 ~200. **수일 내 진입한다.** * * ⚠️ 그리고 「28건 저장 vs DB 2건」이라는 소실 자체가 **smoke 테스트 산출물**이었다 * (`scripts/smoke_e2e.sh:55`가 `transcript` 페이로드의 유일 생산자, 저장→삭제 3~20초 teardown). * 실사용 PreCompact checkpoint는 역사상 0건이다 — `shared.ts:78`이 `transcript_path` 대신 * `transcript`를 읽어 766회 중 738회가 skip됐다. 상세는 ADR-053 섹션 7. */ export declare const CLEANUP_PROTECTED_TAGS: ReadonlySet; /** * 자동삭제(품질 미달 / maxMemories 강제) 대상에서 제외해야 하는가. * * ⚠️ **단일 source.** cleanup 2단계(품질 미달)와 3단계(강제 삭제) 양쪽이 이 함수를 쓴다 — * 한쪽만 막으면 다른 쪽으로 샌다(code-impact 섹션 1: 보안 helper 3+ 사이트 단일 source). * * TTL 삭제(1단계)에는 적용하지 않는다. TTL은 시간 기반 의도된 만료(기본 90일 / semantic 180일)이며, * 여기에까지 보호를 걸면 DB 무한 증가 경로가 열린다. */ export declare function isProtectedFromCleanup(memory: { category?: string; tags?: string[]; }): boolean; /** * 회차 삭제 예산. 분모는 **활성 parent 수**(상한 게이트가 보는 그 카운트, D6). * TTL(1단계)은 이 예산 밖이다 — 시간 기반 의도된 만료라 별도 계약(ADR-054 참조). */ export declare function computeThrottleCap(activeParentCount: number): number; /** * #95 (ADR-054 D1): 삭제 대상 선정 — cleanup 2단계(품질 미달)와 3단계(강제)의 **단일 계약**. * * 🔴 **왜 한 함수인가.** 이전에는 3단계만 `slice(0, excess)`로 초과분만 지우고 2단계는 * 필터만 있어 **조건 만족 전건**을 지웠다(같은 함수 안 비대칭). 게이트(`> maxMemories`)는 * **진입 여부만** 판정하고 진입 후 삭제량은 임계와 무관했다. * * ⚠️ 규모 근거는 **DB 사본 위 반사실 시뮬레이션**이다(운영에서 일어난 사건이 아니다): * 구코드를 사본에 실행하면 필요 회수 309에 **2,998 삭제**(9.7배), 독립 재현으로는 * 필요 170에 eligible 1,297(7.6배). 실 운영 tombstone 일별 최대는 **751건/일**이고 * 2,998 규모 이벤트는 기록에 없다. * * 🔴 **그리고 2단계에만 가드를 넣으면 안 된다.** `remainingCount`가 2단계 후 재계산되므로, * 2단계가 가드로 덜 지우면 **3단계가 정확히 그 부족분을 가드 없이 채운다** — defense가 * 아니라 **지연**이다. 그래서 두 단계가 같은 함수를 쓴다 * (`isProtectedFromCleanup` 주석의 *"한쪽만 막으면 다른 쪽으로 샌다"* 와 같은 정신). */ export interface DeletionSelectionOptions { /** 후보 모집단. 창 절단 없이 전체를 넘긴다 (ADR-054 D2) */ candidates: Memory[]; config: LifecycleConfig; /** #68 (ADR-050): 미push(pending) 보호 id */ protectedIds: Set; /** 회수 목표 = 활성 parent 수 − maxMemories */ excess: number; /** * 이번 **회차**에 남은 삭제 예산 (D4). 2·3단계가 나눠 쓴다 — * 단계마다 재계산하면 상한이 사실상 2배가 된다(`computeThrottleCap` 주석). */ budget: number; /** * 지정 시 이 점수 **미만**만 후보 (2단계 lowQuality). * 미지정이면 전체가 후보이고 품질 최하위부터 (3단계 forced). */ qualityThreshold?: number; now?: Date; } export interface DeletionSelection { picked: Memory[]; /** * 관측용. D1이 삭제 **양**을 `excess`로 결정론화하므로 잔여 위험은 양이 아니라 **선택**이다. */ diagnostics: { /** 가드·필터를 통과한 후보 수 (상한 적용 전) */ eligible: number; /** 보호(카테고리·태그)로 제외된 수 */ blockedByProtection: number; /** age 가드로 제외된 수 (D3) */ blockedByAge: number; /** 미push(pending) 보호로 제외된 수 — #68 (ADR-050) */ blockedByPending: number; /** child라서 제외된 수 (Parent 삭제 시 CASCADE) */ skippedChildren: number; /** 이 호출에 주어진 잔여 회차 예산 (D4) */ budget: number; /** 예산이 실제로 잘랐는가 — true면 잔여는 다음 **회차** */ throttled: boolean; }; } export declare function selectDeletionTargets(options: DeletionSelectionOptions): DeletionSelection; /** * #95 (ADR-054 D5): TTL 만료 대상 선정 — 실 경로와 dry-run의 **단일 source**. * * 기준: `last_accessed_at`(없으면 `updated_at`) + tier별 차등. * 면제: `access_count >= 5`, `skill` 카테고리 (`getMemoriesOlderThan` SQL에서 제외). * Child는 개별 삭제 불가 — Parent 삭제 시 CASCADE. * * ⚠️ **`isProtectedFromCleanup`을 적용하지 않는다.** TTL은 시간 기반 의도된 만료이고 * 여기까지 보호를 걸면 DB 무한 증가 경로가 열린다(본 파일 `isProtectedFromCleanup` 주석). * 단 그 우회가 보호 태그 자산에 도달하는 시점(최고령 2026-06-11 → 2026-12-08)은 * **#97**로 분리돼 있다. */ export declare function selectExpiredTargets(db: MemoryDatabase, config: LifecycleConfig, protectedIds: Set, projectPath?: string): Memory[]; export interface CleanupResult { expired: number; lowQuality: number; forced: number; invalidated: number; total: number; } export interface CleanupOptions { /** * #68 (ADR-050): 서버로 아직 push되지 않은(sync_state='pending') 메모리를 삭제 대상에서 제외. * * ⚠️ **sync가 실제로 가능한 환경에서만 켜라.** `createMemory`는 모든 신규 메모리에 * sync_status='pending' 행을 동반 INSERT하므로(`database.ts:616`), autoSync가 꺼졌거나 * apiKey가 없는 환경에서 이걸 켜면 **모든 메모리가 영구 보호되어 cleanup이 완전히 무력화**된다. * * push할 서버가 없으면 그 메모리는 애초에 원격으로 갈 일이 없으므로 삭제해도 유실이 아니다. */ protectUnsynced?: boolean; } /** * 이 환경에서 SessionEnd push가 실제로 동작하는가 — `protectUnsynced` 판정용. * * ⚠️ **`pushOnSessionEnd`를 반드시 본다.** 이 세 플래그가 `session-end.ts`의 실제 push 조건과 * 1:1로 같아야 한다. 하나라도 빠지면 "push하지 않는데 보호는 하는" 상태가 되고, * `createMemory`가 모든 신규 메모리에 pending 행을 넣으므로(`database.ts`) **전 메모리가 * 영구 보호되어 cleanup이 100% no-op가 되고 DB가 무한 증가**한다. * * 특히 `pushOnSessionEnd: false`는 "세션 종료가 느리다"에 대한 사용자의 가장 자연스러운 * 대응이라 실제로 켜질 가능성이 높다 — 자기유발 트랩이다. */ export declare function isSyncActive(cfg: { autoSync?: { enabled?: boolean; pushOnSessionEnd?: boolean; }; server?: { apiKey?: string; }; }): boolean; /** * TTL 및 품질 기반 메모리 정리를 수행합니다. * * 정리 순서: * 1. TTL 초과 메모리 삭제 (ttlDays > 0인 경우) * 2. 메모리 개수 초과 시 품질 미달 메모리 삭제 * 3. 여전히 초과 시 품질 최하위부터 강제 삭제 (skill 제외) * * @param db - 메모리 데이터베이스 * @param config - 라이프사이클 설정 * @param projectPath - 프로젝트 경로 (지정 시 해당 프로젝트만 정리) * @returns 정리 결과 */ export declare function cleanupMemories(db: MemoryDatabase, config: LifecycleConfig, projectPath?: string, options?: CleanupOptions): CleanupResult; export interface DedupResult { /** 중복 그룹 수 (동일 content 2건 이상) */ groups: number; /** 삭제된 건수 (그룹당 최선 1건 보존) */ deleted: number; /** dry-run 시 삭제 예정 메모리 id 목록 */ candidates: string[]; } /** * 정확 중복(동일 content) 메모리를 정리합니다. (Sprint 24) * * 동일 content(trim) 그룹에서 1건만 보존: * - 보존 우선순위: accessCount 높은 것 → updatedAt 최신 * - chunk 구조(parentId 보유 child / chunkTotal 보유 parent)는 제외 (CASCADE 영향 회피) * - 삭제는 tombstone 경유 (db.deleteMemory) — backend sync 정합 유지 */ export declare function dedupExactDuplicates(db: MemoryDatabase, options?: { dryRun?: boolean; } & CleanupOptions): DedupResult; //# sourceMappingURL=cleanup.d.ts.map