/** * Memory Database * * SQLite 기반 로컬 메모리 저장소. * better-sqlite3 (동기 API) 사용. */ import type { Memory, MemoryCreateInput, MemorySearchOptions, MemorySearchResult, MemoryCategory, MemoryTier, QuantizationLevel, ContradictionReview } from '../types/index.js'; import type { ProficiencyRecord, ExperienceEvent } from '../proficiency/types.js'; export type UsageChannel = 'hook_injection' | 'prompt_injection' | 'cli' | 'mcp'; export type UsageOp = 'inject' | 'search_hit' | 'get'; export interface UsageEventInput { memoryId: string; channel: UsageChannel; op: UsageOp; sessionId?: string | null; /** spool ingest 시 원 발생 시각 보존용 (ISO). 미지정 시 DB now */ createdAt?: string; } export interface UsageEvent { id: number; memoryId: string; channel: string; op: string; sessionId: string | null; createdAt: string; } export interface UsageSummary { memoryId: string; total: number; byChannel: Record; firstUsedAt: string | null; lastUsedAt: string | null; } /** * `~`는 셸이 확장하는 것이지 Node가 확장하지 않는다. config의 db.path는 `~/.a2a/memory.db` * 형태로 저장되므로, 그 경로를 파일시스템에 쓰기 전에 반드시 이 함수를 거쳐야 한다. * * #68 (ADR-050): shadow-rebuild가 이 함수를 쓰지 않고 raw path를 `resolve()`해서 * lock 파일 경로가 `/~/.a2a/...`가 되어 ENOENT → spawn 영구 실패했다. * db.path를 다루는 모든 경로는 이 단일 source를 경유할 것. */ export declare function resolvePath(p: string): string; export declare class MemoryDatabase { private db; private dbPath; private stmts; private quantizationLevel; constructor(dbPath?: string, quantizationLevel?: QuantizationLevel); /** * better-sqlite3 native binding을 로드합니다. * 로드 실패 시 명확한 에러 메시지와 해결 방법을 안내합니다. */ private static openDatabase; /** * SQLite integrity_check를 실행합니다. * 손상된 DB는 .corrupt로 이름을 변경하고 새 DB를 생성합니다. */ private checkIntegrity; /** * 손상된 DB를 .corrupt로 이름 변경 후 새 DB를 엽니다. */ private recoverCorruptDatabase; private stmt; initialize(): void; createMemory(input: MemoryCreateInput): Memory; getMemory(id: string): Memory | null; /** * 사용자 접근을 기록합니다 (access_count 증가). * 검색 결과 반환, 세션 주입 등 실제 "사용자 노출" 시점에만 호출하세요. */ recordAccess(id: string): void; /** * 원격에서 가져온 access_count와 병합합니다 (MAX 전략). * pull 시 서버의 enrichment 값을 로컬에 반영합니다. */ mergeAccessCount(id: string, remoteAccessCount: number): void; updateMemory(id: string, updates: Partial): Memory | null; /** * 메모리 삭제 시 child 테이블 동반 정리 (#35, ADR-045). * sync_status는 제외 — 미동기화 tombstone이 remote_id를 참조해 backend 삭제를 * 전파해야 하므로(queue.ts flushTombstones) 전파 완료 후 queue가 제거한다. * sync_status에 FK CASCADE를 다시 선언하지 말 것 — foreign_keys=ON 환경에서 * engine cascade가 삭제 시점에 발동해 전파가 끊긴다 (initialize()의 재구축이 제거함). */ private cleanupChildRows; /** * #111 (ADR-059 D2): chunk parent 판정 — 고아 child 방지의 단일 source. * * `chunk_total IS NOT NULL` 단독은 **child도 참**이다(`createMemoryWithChunks:2211`이 * child row에도 같은 chunkTotal을 넣는다). `parent_id IS NULL`을 AND로 묶어 * 코드베이스의 기존 표현(`listMemories:918` / `searchMemories:953` / * `searchByVector:997`)과 정렬한다. * * ⚠️ **근거의 정직한 한계** — 이 AND 조건은 **동작을 바꾸지 않는다**. * fault injection 실측(2026-08-13): 단독 판정으로 되돌려도 회귀 57건 전건 통과. * child에 `deleteWithChildren`가 불려도 `WHERE parent_id = `가 0건이라 * 결과가 같기 때문이다. 유지 근거는 (a) child 삭제마다 불필요한 트랜잭션 + * 쿼리 2회를 피하고, (b) 이 판정을 다른 곳에서 재사용할 때 틀리지 않게 하는 것. */ private isChunkParent; deleteMemory(id: string): boolean; countMemories(options?: { category?: MemoryCategory; tier?: MemoryTier; projectPath?: string; }): number; /** * #57 (ADR-048 Phase 1): 작업셋(active) 조건 단일 source — MERGE 사본(merged_into)과 * ARCHIVE(archived_at)는 기본 검색·주입에서 제외. 명시 옵션/refine CLI로만 접근. * * ⚠️ **`invalid_at`은 여기 넣지 않는다** (#113/ADR-062 D1). 아래 `searchActiveFilter` 참조. */ private activeFilter; /** * #113 (ADR-062): 검색·주입 경로 전용 필터 — `activeFilter` + `invalid_at IS NULL`. * * `ADR-017:108`이 *"invalid 메모리: 검색 결과에서 제외, 30일 후 물리 삭제"* 를 정했으나 * `listMemories`(`:993`)에만 구현돼 있었고 검색 3경로(`searchByFTS` / `searchByVector` / * `getNeighborEdges`)는 `activeFilter`만 써서 무효화된 메모리가 top-k를 경쟁했다 * (실측 2026-08-18: A2A 벡터 후보풀 top-1000의 **20.5%**가 invalid). * * 🔴 **`activeFilter` 자체에 넣지 않는 이유** (ADR-062 D1): * `listMemories`의 `includeInvalid: true` 경로가 무력화되는데, 그 **유일한 실사용처가 * `cleanup.ts:461`(30일 purge 후보 수집)**이라 물리삭제가 통째로 멈춘다. * 즉 ADR-017이 만든 옵션을 깨는 데 그치지 않고 같은 ADR의 「30일 물리 삭제」까지 파괴한다. * * 🔴 **조건부(옵션)로 만들지 않는 이유** (ADR-062 D2): * 호출자 6곳 전수에서 invalid를 원하는 caller가 **0건**이고, `searchByVector`가 * stmt 고정 키(`vector_search_project` / `vector_search_all`)를 쓰므로 조건부면 * **같은 키에 다른 SQL**이 바인딩된다 — `code-generation` 룰 6 위반 * (`#107`/ADR-058에서 실제로 터진 클래스. 파라미터 수가 같으면 예외조차 안 난다). * 상수 문자열이라 키당 SQL 1종이 유지된다. */ private searchActiveFilter; /** * #111 R3 (ADR-059 D5): chunk parent 제외 술어 — child + 독립 메모리만 남긴다. * * `isChunkParent`(:872)의 부정형이다. 판정 축이 `parent_id IS NULL AND chunk_total IS NOT NULL` * 인 이유는 `createMemoryWithChunks`가 **child row 에도 같은 `chunk_total` 을 넣기 때문**(:2211). * `chunk_total` 단독이면 child 도 참이 되어 오판한다. * * ⚠️ 같은 술어의 리터럴 사본이 `listMemories`(:974) / `searchByFTS` / `searchByVector` 에 * 아직 4곳 남아 있다. 이번 scope(goal 직결)가 아니라 통합은 후속 ticket — 여기를 고치면 * 그 4곳도 함께 봐야 한다. */ private static readonly NOT_CHUNK_PARENT; listMemories(options?: { limit?: number; offset?: number; category?: MemoryCategory; tier?: MemoryTier; projectPath?: string; excludeParents?: boolean; includeInvalid?: boolean; includeInactive?: boolean; tag?: string; }): Memory[]; searchByFTS(query: string, limit?: number, options?: { projectPath?: string; category?: string; tier?: string; }): MemorySearchResult[]; searchByVector(embedding: number[], limit?: number, minScore?: number, projectPath?: string): MemorySearchResult[]; search(options: MemorySearchOptions): MemorySearchResult[]; saveSession(sessionId: string, projectPath: string, messageCount: number): void; getSession(sessionId: string): { sessionId: string; projectPath: string; messageCount: number; processedAt: string; } | null; /** * 프로젝트 단위 마지막 consolidation 시각 조회 (Sprint 7 H1). * sessions 테이블의 last_consolidation_at 컬럼은 ALTER로 추가된 nullable. * 한 번도 consolidation 안 됐으면 null 반환. */ getLastConsolidationAt(projectPath: string): string | null; /** * 프로젝트 단위 마지막 consolidation 시각 갱신 (Sprint 7 H1). * runConsolidation 직후 호출. 가장 최근 session 1건만 갱신 (전체 갱신 불필요). */ markConsolidationDone(projectPath: string): void; createContradictionReview(review: { id: string; newMemoryId: string; existingMemoryId: string; classification: string; confidence: number; rawCosine: number; reason: string; }): void; listContradictionReviews(status?: 'pending' | 'approved' | 'rejected'): ContradictionReview[]; getContradictionReview(id: string): ContradictionReview | null; resolveContradictionReview(id: string, status: 'approved' | 'rejected'): void; /** ADR-043: approve 시 old 메모리에 deprecated_by 기록 + quality_score × decayFactor 감쇠 (완전 삭제 X — 점진 강등) */ deprecateMemory(oldMemoryId: string, newMemoryId: string, decayFactor: number): void; getSessionCount(projectPath?: string): number; getStats(): { totalMemories: number; totalSessions: number; dbSizeBytes: number; categoryCounts: Record; }; integrityCheck(): boolean; vacuum(): void; close(): void; setSyncStatus(memoryId: string, remoteId: string | null, state: 'pending' | 'synced' | 'conflict'): void; getSyncStatus(memoryId: string): { remoteId: string | null; syncedAt: string; syncState: string; } | null; /** * #68 (ADR-050): cleanup이 삭제해선 안 되는 메모리 id 집합. * * 서버로 아직 push되지 않은(sync_state='pending') 메모리를 cleanup이 지우면, * flushTombstones가 remoteId 없는 tombstone을 조용히 폐기하여(queue.ts) 영구 유실된다. * * 명시적 'pending' 행만 본다 — sync_status 행 자체가 없는 경우(autoSync 미사용 환경)는 * 보호 대상이 아니다. 그것까지 보호하면 autoSync를 끈 사용자의 cleanup이 전면 무력화된다. * * child가 pending이면 parent의 CASCADE 삭제도 막아야 하므로 parent id를 함께 담는다. */ getSyncProtectedIds(): Set; getPendingSyncMemories(limit?: number): Memory[]; /** * Stale parent FK 자동 복구 (Sprint 9 S9-0) * * backend가 "Parent memory not found: parent_id 'X'"로 reject하면 client의 * sync_status.remote_id=X가 stale. 그 parent의 sync_status를 pending reset + * 해당 parent를 가진 children도 pending으로 되돌려 다음 flush에서 자연 복구. * * @returns 영향받은 행 수 (parent 1건 + children N건) */ resetSyncByStaleRemoteId(staleRemoteId: string): number; /** * ADR-041: Backend lifecycle 정리됨 마킹 (sync_state='cleaned') * * memory_get → 404 응답 시 backend가 consolidate_working_memory/cleanup_old_memories/ * cleanup_archived로 정리한 것으로 간주. sync_state='cleaned'로 마킹하여 **재push 차단** * (resetSyncByStaleRemoteId와 달리 'pending' reset 안 함 — 무한루프 차단). * * remote_id는 보존 (추적용 — 추후 backend audit에 활용 가능). * * @returns 영향받은 행 수 (0 또는 1) */ markSyncStatusCleaned(memoryId: string): number; /** * ADR-041: cleaned 마킹된 메모리 수 (관측용) */ getCleanedSyncCount(): number; findMemoryByRemoteId(remoteId: string): Memory | null; getSyncedMemoryCount(): number; getLastSyncedAt(): string | null; addSyncHistory(operation: string, memoryId: string, status?: string, details?: string): void; getSyncHistory(options?: { memoryId?: string; limit?: number; }): Array<{ id: number; operation: string; memoryId: string; timestamp: string; status: string; details: string | null; }>; addTombstone(memoryId: string): void; getTombstones(limit?: number): Array<{ memoryId: string; deletedAt: string; synced: boolean; }>; getUnsyncedTombstones(): Array<{ memoryId: string; deletedAt: string; }>; markTombstoneSynced(memoryId: string): void; /** * sync_status row 제거 (#35, ADR-045) — tombstone 전파 완료(synced=1) 후 * queue.flushTombstones가 호출. 삭제 시점에는 보존되어야 한다 (remote_id lookup 경로). */ deleteSyncStatus(memoryId: string): void; saveEmbeddingHash(memoryId: string, hash: string): void; saveVectorClock(memoryId: string, clockState: Record): void; getVectorClock(memoryId: string): Record | null; getAllVectorClocks(): Array<{ memoryId: string; clockState: Record; }>; saveEmbedding(memoryId: string, embedding: number[]): void; getEmbedding(memoryId: string): number[] | null; /** * #43: 저장 quantization level에 따른 embedding 복원 단일 source — * getEmbedding(단건)과 searchByVector(후보 배치 JOIN)가 공유한다. * int8인데 stats가 없으면 복원 불가 → null (검색 경로에서는 후보 제외). */ private dequantizeEmbedding; /** 56b: embedding 보유 메모리 전체를 dequantize하여 batch 로드 (sim k-NN 입력). */ getAllEmbeddingRows(): Array<{ id: string; embedding: number[]; }>; /** type별 엣지 전체 교체 (clear + batch insert, 단일 트랜잭션). shadow 재계산용. */ replaceEdges(type: string, edges: Array<{ src: string; dst: string; weight: number; }>): number; /** * `#221` (ADR-093 D2): **src 단위 부분 교체** — 증분 rebuild 전용. * * `replaceEdges` 는 `DELETE WHERE type = ?` 로 테이블을 통째로 비우고 다시 쓴다. 전건이면 * 옳지만 증분에서는 **바뀌지 않은 엣지까지 전량 재기록**한다 — [검증] 2026-09-14 운영 사본 * (n=35,290 · sim 812,994): 한 회차의 **실제로 바뀐 src 가 0** 인데 812,994행을 지우고 다시 썼고 * 그 쓰기가 sim 소요의 **70%**(6,729~7,246ms)였다. * * 🔴 **`srcs` 는 「교체 대상」이지 「방출 대상」이 아니다.** 여기 들어온 src 의 기존 엣지를 * 전부 지우고 `edges` 에 담긴 것만 다시 넣는다 — 즉 `srcs` 에 있는데 `edges` 에 없는 src 는 * **엣지가 사라진다**(의도된 동작. 삭제·임베딩 소거 노드의 정리가 이 경로다). * * 🔴 **왜 전체 DELETE 를 대체할 때 GC 를 함께 넘겨야 하나** — 전건의 `DELETE WHERE type=?` 는 * 부수적으로 **유일한 쓰레기 수거**였다. `clearSince` 호출 사이트가 `embed --reindex` 하나뿐이라 * (`cli/commands/embed.ts`) 정상 운영에서 rebuild 는 **영구히 증분**이고, 「언젠가 전건이 돌아 * 쓸어 준다」가 성립하지 않는다. 호출부(`buildSimEdges`)가 `staleSrc`(rows 에 없는 src)와 * `danglingDstSrc`(rows 에 없는 dst 를 가리키는 엣지의 src)를 `srcs` 에 포함시켜야 한다. * * 🔴 **`computed_at` 의 의미가 바뀐다** — 전건은 전 행이 같은 값(마지막 교체 시각)이지만 * 이후에는 **행마다 「그 엣지가 마지막으로 쓰인 시각」** 이다. [검증] 읽는 코드는 0건이라 * 기능 영향이 없고, 오히려 「이번 실행이 실제로 쓴 행」을 세는 **외부 오라클**이 된다 * (ADR-093 DoD 3 — 코드 카운터와 독립이라 순환이 아니다). * * @param srcs 교체 대상 src id. 중복은 무해하다(DELETE 가 멱등). * @returns 실제로 INSERT 한 엣지 수 */ replaceEdgesForSrcs(type: string, srcs: Iterable, edges: Array<{ src: string; dst: string; weight: number; }>): number; /** * `#221` (ADR-093 D2): 단일 type 의 엣지 수. * * 🔴 부분 교체(`replaceEdgesForSrcs`) 도입으로 「이번에 쓴 수」와 「DB 총량」이 갈라졌다. * `SimBuildResult.edges` 는 **총량**이 계약이므로(운영 로그가 그 시계열을 읽는다) 증분 경로가 * 이 메서드로 총량을 다시 센다. `countEdgesByType` 은 전 type GROUP BY 라 이 자리에 과하다. */ countEdges(type: string): number; /** type별 엣지 카운트 (shadow 관찰). */ countEdgesByType(): Array<{ type: string; count: number; }>; /** 56e: type별 엣지 read — per-memory 신호 계산 입력 (sim uniqueness/generic/dup, ref/cooc local). */ /** * `#216` (ADR-092 D1): `since` 이후 **sim 이 바뀔 수 있는** 메모리 id. * * 세 채널의 합집합이다 — 하나라도 빠지면 **조용한 누락**이 된다: * 1. **임베딩이 있는데 sim src 에 없는 노드** — 신규 임베딩(신규 메모리 · backfill)과 * **재임베딩**을 함께 잡는다. 재임베딩은 `saveEmbedding` 이 sim src 행을 지워 * **스스로 이 채널에 들어온다**(D1-a). * 2. **삭제(tombstone)** — 삭제 노드는 `getAllEmbeddingRows` 에서 빠지지만 그 노드를 * dst 로 갖던 src 의 top-k 에 구멍이 남는다. 역방향 폐포에 쓰려면 id 가 필요하다. * 3. **`embedding` 이 NULL 로 지워졌는데 아직 sim 에 dst 로 남아 있는 노드** * (`#221` · `ADR-093` D0). 🔴 **이 채널이 없으면 증분이 전건과 등가가 아니다.** * * 🔴 **채널 3 이 없던 동안 무슨 일이 있었나** (`#216` 의 결함, `#221` 이 발견): * `updateMemory` 는 content 가 바뀌면 `wipe_embedding_on_content_change` 로 embedding 을 * **NULL 로 지운다**(위 852줄 근처, `skill→skill` 만 예외). 그 노드는 * - 채널 1 이 `embedding IS NOT NULL` 로 **배제**하고 * - tombstone 이 아니라 채널 2 도 아니며 * - 폐포(`edge-builder.ts:240` `if (!delta.has(e.dst)) continue`)가 그것을 dst 로 갖던 src 도 **못 끌어온다** * * 그 src 는 warm-seed 만 받는데 `edge-builder.ts:251` 의 `di === undefined` 가 그 엣지를 * **버리므로** top-k 에 **빈자리**가 남는다 — 고아가 남는 게 아니라 **자리가 비는** 것이다. * [검증] 재현(n=200 · 3건 wipe): 증분 **4,893** vs 전건 **4,925** — **32 엣지 / 31 src** 부족. * 채널 3 을 넣으면 `equal: true`. * * 🔴 **왜 dst 만인가 (src 를 넣지 않는 이유)** — `embedding` NULL 노드가 **src** 인 엣지는 * 그 노드가 `getAllEmbeddingRows`(= `WHERE embedding IS NOT NULL`)에 없어 **애초에 재방출되지 * 않는다**. 전건은 `replaceEdges` 의 전체 DELETE 가, 증분 부분 교체는 `staleSrc` 가 지운다. * **빈자리를 만드는 것은 dst 쪽뿐**이다. * * 🔴 **`memories` 전체에 `embedding IS NULL` 을 걸지 않는 이유** — 신규 메모리가 임베딩되기 * 전 구간까지 Δ 에 들어와 역방향 폐포로 증폭된다. **sim 에 dst 로 실재하는 것**만 잡는다. * * [검증] 비용(운영 DB, 2026-09-14): Δ **1,305 → 1,322 (+1.3%)**, 폐포로 끌려올 src **470**. * * 🔴 **`updated_at` 채널은 의도적으로 없다.** `saveEmbedding` 이 `updated_at` 을 안 바꾸므로 * 「`updated_at` 이 바뀐 것」은 **임베딩이 안 바뀐 것**이고 sim 에 영향이 없다. 그런데 그것을 * 채널로 두면 역방향 폐포를 통해 증폭된다 — 운영 실측(2026-09-14, n=35,215): * * | Δ 구성 | Δ | Δ⁺ | 증폭 | 계산 쌍 | * |---|---:|---:|---|---:| * | `updated_at` 포함 | 2,934 | **20,050** | 6.8배 | 81.4% | * | 제외(현행) | 1,364 | **1,364** | 1.0배 | **7.6%** | * * 그 채널의 1,529건이 전부 본문만 바뀐 노드였다. 「과잉은 안전」이 정확성에서는 참이지만, * 폐포가 그 과잉을 6.8배로 키워 **기능 자체를 무용하게** 만든다. * * 🔴 **비교는 `>=` 다 (`>` 아님)** — `datetime('now')` 는 **초 단위** 해상도라 * `since`(rebuild 시작 시각)와 **같은 초**에 일어난 삭제가 `>` 에서는 거짓이 되어 * **영구 누락**된다(다음 `since` 는 더 나중이므로 영영 안 잡힌다). * `>=` 는 그 초를 다시 포함할 뿐이라 **과잉이고 무해**하다. * * 🔴 **타임스탬프 정규화가 필수다** — `deleted_at` 은 공백 형식(`datetime('now')`)인데 * 외부 경로가 ISO(`…T…Z`)로 쓸 수 있다. SQLite 는 TEXT 사전순 비교라 * `'T'`(0x54) > `' '`(0x20) 이므로 정규화 없이 비교하면 **같은 날짜에서 항상 참**이 된다. */ getChangedMemoryIds(since: string): string[]; getEdges(type: string): Array<{ src: string; dst: string; weight: number; }>; /** 56e: per-memory 신호 계산 입력 (activation/dump). content는 LENGTH만 — 본문 미로딩. */ getSignalInputRows(): Array<{ id: string; access_count: number; created_at: string; last_accessed_at: string | null; content_length: number; quality_score: number | null; }>; /** 56e: per-memory 신호 컬럼 batch 기록 (shadow, 단일 트랜잭션 — 액션 0). */ updateSignals(updates: Array<{ id: string; activation: number; uniqueness: number; generic_penalty: number; dup_score: number; dump_score: number; reliability: number; cluster_id: string | null; }>, computedAt: string): number; /** 56e/g: 계산된 per-memory 신호 read (shadow 검증 + 56g 분포 관찰. computed된 것만). */ getComputedSignals(): Array<{ id: string; activation: number | null; uniqueness: number | null; generic_penalty: number | null; dup_score: number | null; dump_score: number | null; reliability: number | null; cluster_id: string | null; }>; /** 56f: rank 계산 입력 — 56e 신호 + teleport prior(quality/tier) + retention/prune 입력. content 미로딩. */ getRankInputRows(): Array<{ id: string; tier: string; category: string; quality_score: number | null; access_count: number; created_at: string; last_accessed_at: string | null; activation: number | null; uniqueness: number | null; generic_penalty: number | null; dup_score: number | null; dump_score: number | null; reliability: number | null; cluster_id: string | null; }>; /** * #65 (ADR-048 섹션 0.11): shadow 스코어 재계산 stale 판정 입력. * getRankInputRows와 동일 대상(전체 memories, 필터 없음)이라 `memoryrank IS NULL`이 * "rank 미계산 노드" 수와 정확히 일치 (신규 메모리 cold-start 신호 누적 측정). */ getShadowStaleness(): { latestScoresAt: string | null; nullCount: number; }; /** 56f: rank/retention/prune/decision batch 기록 (shadow, 단일 트랜잭션 — 액션 0). */ updateRanks(updates: Array<{ id: string; struct_rank: number; memoryrank: number; retention_score: number; prune_score: number; decision: string; }>, computedAt: string): number; /** * #57 (ADR-048 Phase 1 — 섹션 7.4/7.5): MERGE/ARCHIVE reversible 실행. * - MERGE: decision='MERGE' + cluster_id 클러스터별 canonical(argmax memoryrank·quality) * 1벌 유지, 나머지 merged_into=canonical + access_count는 canonical에 합산. * - ARCHIVE: decision='ARCHIVE' → archived_at 설정 (기본 작업셋 제외, 명시 조회 가능). * - hard rule 재검증(방어 심층 — DecisionGate가 이미 적용했으나 실행 시점 이중 가드): * 보호 카테고리/태그 메모리는 MERGE 사본 전환 대상에서 제외 (canonical 지위는 무관). * - 자동 DELETE 없음 — DELETE_CANDIDATE는 L1 승인 배치(#58) 전용 (섹션 7.5). * - revert 시 access_count 합산은 역산하지 않음 (사용 통계 통합값 유지 — 무해). */ applyPhase1Refine(dryRun?: boolean): { clusters: number; merged: number; archived: number; hardRuleSkipped: number; singletonClusters: number; }; /** * #58 (ADR-048 섹션 7.5): DELETE 라벨 항목을 L1 승인 큐에 등재. * proposed_action='DELETE'만 대상 — 실제 삭제는 executePruneBatch(사용자 L1 승인 후)에서만. */ /** * #111 (ADR-059 D4): chunk parent는 큐에 등재하지 않는다. * * `executePruneBatch`가 `deleteMemories`를 부르고, 그것이 #111 D2 이후 **children까지 * cascade**한다. 그래서 parent가 큐에 있으면 사용자는 「N건 삭제」를 승인했는데 * 실제 삭제는 **N + children**이 된다 — L1 승인의 blast radius가 표시와 어긋난다. * * 큐는 ContextRank가 저품질 **단건**을 고르는 경로이고 chunk parent는 문서 단위라 * 성격이 다르다. parent를 지워야 하면 `rm `가 이미 cascade한다(D2). * * child는 계속 등재된다 — `isChunkParent`가 `parent_id IS NULL`을 AND로 묶으므로 * child는 판정에 걸리지 않는다. (이 지점에서는 그 조건이 **실제 동작 차이**를 만든다. * D2/D3에서는 결과가 같아 변별력이 없었다 — fault injection F2/F4 실측.) * * 반환을 `number`에서 객체로 바꾼 이유: skip이 조용하면 사용자가 「등재가 왜 적지」를 * 알 수 없다. caller는 `cli/commands/contextrank.ts:189` 1곳뿐이라 비용이 없다. */ fillPruneQueue(entries: Array<{ memoryId: string; pruneScore: number | null; reason: string; }>): { inserted: number; skippedParents: number; }; listPruneQueue(status?: string): Array<{ memory_id: string; prune_score: number | null; reason: string | null; proposed_action: string; status: string; created_at: string; }>; /** * #58: L1 승인 후 batch 삭제 실행 — pending_approval + DELETE 항목을 deleteMemories로 * 삭제(child cascade + tombstone 전파는 기존 경로 재사용) 후 큐 status 갱신. * * ⚠️ **이 주석의 "child cascade"는 #111(ADR-059 D2) 이후에야 참이다.** 그 전까지 * `deleteMemories`에는 cascade가 없어 이 문장이 없는 계약을 주장하고 있었다. * 지금은 참이지만, 그래서 반대로 **승인 범위가 커진다** — parent 1건 승인이 * children까지 지운다. 그 때문에 `fillPruneQueue`가 parent를 큐에서 제외한다(D4). * ⚠️ 사용자 L1 승인 없이 호출 금지 (CLI --approve 게이트). */ executePruneBatch(): { deleted: number; }; /** #57: Phase 1 전체 되돌림 — merged_into/archived_at 해제 (access 합산은 유지). */ revertPhase1Refine(): { unmerged: number; unarchived: number; }; /** 56f/g: 계산된 rank/decision read (shadow 검증 + 56g 분포/상관 관찰). */ getComputedRanks(): Array<{ id: string; struct_rank: number | null; memoryrank: number | null; retention_score: number | null; prune_score: number | null; decision: string | null; }>; /** 56c: injection_hits.matched_memory_ids (JSON 배열) 중 2+ 매칭 세션 집합. */ getInjectionMatchedSets(): string[][]; /** 56c: memory_usage_events session_id별 공동 사용 메모리 집합 (2+). */ getUsageSessionSets(): string[][]; /** 56c: (memory_id, tag) 전체 페어 — tag-cooc + hub IDF/PPMI 필터 입력. */ getTagMemoryPairs(): Array<{ memory_id: string; tag: string; }>; /** 56d: memories.session_id 공유 2+ 메모리 집합 (temp 엣지 — 동일 세션 공동 생성, 대칭). */ getSessionSharedSets(): string[][]; /** * 56d: content 내 `[[...]]` 보유 메모리 (id, content) — ref 엣지 파싱 입력. * bash test 문법(`[[ -n ... ]]` 등) 제외 + slug→id resolve는 edge-builder에서 수행. * (memories에 name/slug 컬럼 부재 — ADR 섹션 15 Q3 미확정. resolve율 shadow 관찰.) */ getRefLinkSources(): Array<{ id: string; content: string; }>; /** * #212 (ADR-091): 전건 (id, content) 스트리밍 — ref 엣지 단일 패스용. * * `.all()` 이 아니라 `.iterate()` 인 이유: [검증] 같은 스캔에서 heap peak 65MB → **30MB** * (40ms 손해). 같은 프로세스의 `buildSimEdges` 가 임베딩 34,928 × 384 × 8B ≈ 107MB 를 쓰므로 * 피크를 낮게 유지할 값이 있다. * * 🔴 `stmt()` 캐시를 쓰지 않는다 — iterate 중에 같은 statement 를 다른 경로가 재사용하면 * 커서가 깨진다(프로젝트 `code-generation` 룰 6 의 변형). * * 종전 `findMemoryIdsContaining`(slug 마다 `content LIKE '%slug%'` 전건 스캔)은 이 메서드로 대체돼 * 제거됐다 — [검증] 그 경로가 `buildRefEdges` 소요의 **100.0%**(154,319 / 154,375ms)였다. */ iterateAllContents(): IterableIterator<{ id: string; content: string | null; }>; getMemoriesOlderThan(days: number): Memory[]; getMemoriesWithoutEmbedding(limit?: number): Memory[]; /** * #83 (ADR-048 섹션 0.13): SessionStart Step 1e 게이트용 경량 COUNT. * getMemoriesWithoutEmbedding과 동일 술어(embedding IS NULL) — 게이트↔워커 단일 source. */ countMemoriesWithoutEmbedding(): number; deleteMemories(ids: string[]): number; getMemoryCount(projectPath?: string, excludeChildren?: boolean): number; /** * #95 (ADR-054 D6): **삭제 가능한** parent 수 — cleanup 상한 게이트의 분모. * * 🔴 `getMemoryCount(_, true)`는 `WHERE parent_id IS NULL`만 보므로 * invalid / merged / archived parent까지 센다. 그런데 후보 모집단인 `listMemories`는 * 그것들을 **반환하지 않는다**(`invalid_at IS NULL` + `activeFilter()`). * 즉 게이트가 세는 집합과 지울 수 있는 집합이 달라서, **죽은 행이 상한 예산을 먹고** * 실효 상한만 낮아진다 — 초과의 원인이 「활성이 넘쳐서」가 아니라 * 「죽은 행이 자리를 차지해서」가 된다. * * 실측(2026-08-06): 5,309 중 343행이 삭제 불가 → 활성 parent 4,966 < 상한 5,000. * * 이 메서드는 `listMemories`의 **기본 필터와 같은 집합**을 센다(검증 계약 6). */ getActiveParentCount(projectPath?: string): number; /** * 레거시 category/tier 값을 새 스키마로 마이그레이션 * preference → convention, procedural → semantic */ migrateOldValues(): { categoriesUpdated: number; tiersUpdated: number; }; /** * Parent + Children 일괄 생성 (트랜잭션) */ createMemoryWithChunks(parent: MemoryCreateInput, children: MemoryCreateInput[]): string; /** * Parent의 모든 Child chunks 조회 (chunk_index 순) */ getChildChunks(parentId: string): Memory[]; /** * Child의 Parent 조회 */ getParentMemory(childId: string): Memory | null; /** * Parent-Child를 원자적으로 교체 (delete old + create new in single transaction) */ replaceMemoryWithChunks(oldParentId: string, newParent: MemoryCreateInput, newChildren: MemoryCreateInput[]): string; /** * Parent와 모든 Children 일괄 삭제 */ deleteWithChildren(parentId: string): number; /** * 특정 태그를 가진 메모리 전체 조회 (tag 인덱스 JOIN 기반) */ findMemoriesByTag(tag: string): Memory[]; private getRowId; private rowToMemory; private rowsToMemories; /** * #59 (ADR-048 Phase 4 Stage B): seed 이웃 확장 — 전역 PPR 산출물(memory_edges fused weight) 1-hop 참조. * 현 규모(≈4.5k)는 per-query Forward-Push 대신 배치 엣지 참조로 갈음 (ADR-048 섹션 8/10.1). * active 작업셋만 반환 (merged/archived 제외). 동적 placeholder라 stmt 캐시 미사용. */ getNeighborEdges(seedIds: string[], limit?: number): Array<{ dstId: string; weight: number; }>; /** #59: id 목록 배치 조회 — expand 후보 로딩 (rowsToMemories 경유로 매핑/embedding 복원 일관) */ getMemoriesByIds(ids: string[]): Memory[]; /** * PRAGMA integrity_check 실행 */ getIntegrityStatus(): boolean; /** * pending sync 메모리 수 조회 */ getPendingSyncCount(): number; /** * 메모리 ID 목록에 대해 sync_status row 부재분만 pending 상태로 idempotent INSERT * (Sprint 20 T2 — INSERT 누락 304건 silent skip 차단). * 이미 row가 있으면 OR IGNORE로 무시. */ ensurePendingSyncRows(ids: string[]): void; /** * sync_status row 없는 메모리 ID 수집 (Sprint 20 T2 backfill 용도). * 정상 운영 중에는 ensurePendingSyncRows로 즉시 보강되나, 본 메서드는 1회성 * cleanup 시나리오 (예: 기존 304건 누락분) 식별에 사용. */ findMemoriesWithoutSyncStatus(limit?: number): string[]; createProficiency(input: { skillMemoryId: string; skillName: string; projectPath?: string; }): ProficiencyRecord; getProficiency(id: string): ProficiencyRecord | null; getProficiencyBySkill(skillMemoryId: string): ProficiencyRecord | null; listProficiencies(options?: { projectPath?: string; limit?: number; }): ProficiencyRecord[]; updateProficiency(id: string, updates: { level?: number; experienceCount?: number; successRate?: number; actrActivation?: number; lastPracticedAt?: string; }): boolean; addExperienceEvent(event: { proficiencyId: string; timestamp: number; outcome: 'success' | 'partial' | 'failure'; difficulty: number; contextTags?: string[]; sessionId?: string; }): ExperienceEvent; getExperienceEvents(proficiencyId: string, options?: { limit?: number; order?: 'asc' | 'desc'; }): ExperienceEvent[]; getExperienceEventCount(proficiencyId: string): number; bufferEmotionMessage(message: string): void; getUnsentEmotionMessages(limit?: number): Array<{ id: string; message: string; }>; markEmotionMessagesSynced(ids: string[]): void; cleanupSyncedEmotionMessages(): number; saveSessionMetrics(metrics: { sessionId: string; extractionRate: number; dedupRatio: number; avgQuality: number; injectionEffectiveness: number; hotTierCount: number; totalMemories: number; }): void; getRecentSessionMetrics(limit?: number): Array<{ sessionId: string; extractionRate: number; dedupRatio: number; avgQuality: number; injectionEffectiveness: number; hotTierCount: number; totalMemories: number; createdAt: string; }>; /** * Record an injection event (SessionStart or UserPromptSubmit). * Returns the new injection log id. */ recordInjection(input: { sessionId: string; hookType: 'session_start' | 'user_prompt_submit'; injectedMemoryIds: string[]; /** ADR-019: 레거시 flat array 또는 신식 memory_id→keywords 맵 */ injectedKeywords: string[] | Record; totalInjected: number; abGroup?: string; }): string; /** * Record a keyword hit in PostToolUse. * ADR-019: matchedMemoryIds로 어떤 memory가 활용됐는지 기록. */ recordInjectionHit(injectionId: string, matchedKeywords: string[], toolName: string, matchedMemoryIds?: string[]): void; /** * ADR-019: 특정 injection의 matched memory 고유 수 집계. * matched_memory_ids JSON 배열에서 unique memory_id 개수 반환. */ countDistinctMatchedMemories(injectionId: string): number; recordUsageEvent(input: UsageEventInput): void; recordUsageEvents(inputs: UsageEventInput[]): void; getUsageEvents(memoryId: string, limit?: number): UsageEvent[]; getUsageSummary(memoryId: string): UsageSummary; /** * 주입(노출) 이벤트를 제외한 **실사용** 횟수 (ADR-066). * * `ADR-008` D-5: "자동 주입은 '노출'이지 '사용'이 아님". * `ADR-046` 원장의 `op` 가 이미 둘을 분리 기록하므로(`inject` vs `search_hit`) * 채널 **이름 목록을 열거하지 않고** `op != 'inject'` 여집합으로 센다 — * 새 주입 채널이 추가돼도 자동으로 제외된다. */ countNonInjectUsage(memoryId: string): number; getTopUsedMemories(limit?: number, sinceDays?: number): UsageSummary[]; /** MCP spool ingest용 — backend remote_id를 로컬 memory_id로 역매핑 (sync_status) */ resolveMemoryIdByRemoteId(remoteId: string): string | null; /** ledger 도입 이전 주입 이력 참조용 — injection_logs JSON 배열 포함 카운트 (단발 CLI 조회 전용) */ countLegacyInjections(memoryId: string): number; /** * Get the most recent injection log for a session. */ getLatestInjection(sessionId: string): { id: string; injectedKeywords: string; injectedMemoryIds: string[]; totalInjected: number; } | null; /** * Get all injection logs for a session (for full effectiveness calculation). */ getAllInjections(sessionId: string): Array<{ id: string; injectedKeywords: string; injectedMemoryIds: string[]; totalInjected: number; hitCount: number; }>; /** * Update effectiveness score at session end. */ updateInjectionEffectiveness(injectionId: string, effectiveness: number, hitCount: number): void; /** * Get all hit records for an injection log. */ getInjectionHits(injectionId: string): Array<{ id: string; toolName: string; }>; /** * Record extraction quality evaluation. */ recordExtractionEval(input: { memoryId: string; sessionId: string; significanceScore?: number; dedupAction?: string; tier?: string; }): void; /** * Purge injection_logs (and cascade hits) older than given days. */ purgeOldInjectionLogs(days?: number): number; } /** * FTS5 bm25 `rank` → 0~1 정규화 score (ADR-055 D1). * * FTS5 `rank`는 **음수이고 더 음수일수록 좋은 매치**다. 구 공식 * `Math.min(1, Math.max(0, 1 + rank/10))`은 rank ≤ -10 을 전부 0 으로 클램프해 * 상위 매치를 하위 매치보다 낮게 만들었고, `search()`가 그 score 로 재정렬하면서 * SQL `ORDER BY fts.rank` 순서를 파괴했다. * * `x/(1+x)`는 0~1 범위(기존 계약)를 유지하면서 단조 증가하므로 정렬 결과가 * SQL 순서와 일치한다. rank -13.44 → 0.9308 / -9.13 → 0.9013 / -1.19 → 0.5434. * * ⚠️ 스케일이 압축적이다(좋은 매치들이 0.9 부근에 몰린다). `minScore` 필터에 * 0.3 같은 직관값을 넣으면 거의 아무것도 걸러지지 않는다 — 실효 구간은 0.85~0.95. * `deprecated_by` 감쇠(`× 0.3`)도 이 스케일에서는 사실상 top-k 배제가 된다(ADR-043 append). */ export declare function normalizeFtsRank(rank: number): number; /** * 사용자/생성 쿼리 → FTS5 MATCH 표현식 (ADR-055 D2). * * raw 쿼리를 `MATCH ?`에 직결하면 두 가지가 깨진다: * 1. `hash:abc` / `ADR-054` / `a"b` / `(unclosed` / 리터럴 `AND` 가 FTS5 문법으로 * 해석돼 syntax error → 호출부 catch 가 삼켜 조용히 0건이 된다. * 2. 공백 구분 토큰이 암묵 AND 로 결합돼 8~10 토큰 generic 쿼리가 거의 매칭되지 않는다 * (SessionStart 주입 0건율 90.7% 의 주 원인). * * 각 토큰을 `"..."` 로 감싸면 특수문자가 리터럴이 되고, OR 로 결합하면 부분 매칭이 * 후보에 든다. 순위는 bm25 가 토큰 일치 수를 반영해 정한다(normalizeFtsRank 참조). * * - 1글자 이하 토큰 제외 — 기존 `user-prompt-submit.ts` 필터 동작 재현. * 단 **CJK 1글자는 허용**한다(`왜`/`안`/`못`). 영문 1글자(`a`)는 노이즈지만 * 한글 1글자는 검색어로 유효하다 — 실 DB 에서 `왜` 220건 / `나` 32건이 매칭된다. * - 말미 `*` 는 분리 후 재부착 — prefix 검색 유지 (`check*` == `"check"*` == 618건, * `"check*"` 는 348건으로 와일드카드가 죽는다) * * @returns MATCH 표현식. 유효 토큰이 0개면 `null`(호출부는 FTS 를 건너뛴다). */ export declare function buildFtsMatchQuery(raw: string): string | null; //# sourceMappingURL=database.d.ts.map