forget-connect 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,17 +11,30 @@ main context window is the scarcest resource, so the gate is strict:
11
11
  - the injection is an OFFER with trust lights; adoption stays the
12
12
  main-thread agent's judgment
13
13
  - fail-open, hard 5s timeout: forget being down must never slow a turn
14
+ - one more offer rides along (c79, rolling consolidation ③): when the digest
15
+ hook estimates context usage near the compaction threshold, a single line
16
+ suggests rebooting over compacting — once per episode, never forced
17
+ - 턴-유형 게이트 (2026-08-10): layer:self 격언은 구조적으로 제외하고,
18
+ 대화-국소 신호는 검색 전에 침묵하며, 기억-의존 신호는 recall 기어를
19
+ low→high로 승격한다. 참조 대상이 세션 대화 꼬리에 이미 있으면 침묵
20
+ (conversation-first). FORGET_TURNRECALL_GATE=off가 게이트·승격을 끄는
21
+ 대조 경로. 결정은 state/turnrecall_gate.jsonl에 남는다(주입률 분모).
14
22
 
15
- Known gap (deliberate): these injections are not yet linked to the outcome
16
- flywheel (search_memories creates no context trace). Wire when per-turn
17
- traces land server-side.
23
+ body A1+A2 (2026-08-06): per-turn traces landed server-side — the search
24
+ carries trace=turn_recall, the injection footer prints the trace_id, and
25
+ the agent's record_context_outcome habit finally has an address. The pick
26
+ gate also reads score_breakdown.vector: a hit that matches only lexically
27
+ ("프로토타입" pulling three unrelated prototypes, measured today) is noise
28
+ the semantic floor now drops.
18
29
  """
19
30
 
20
31
  from __future__ import annotations
21
32
 
22
33
  import json
23
34
  import os
35
+ import re
24
36
  import sys
37
+ import time
25
38
  import urllib.request
26
39
 
27
40
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -30,16 +43,133 @@ from forget_project import layered_filter, project_key_for_path, scope_disabled,
30
43
 
31
44
  FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
32
45
  STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
33
- SCORE_THRESHOLD = float(os.environ.get("FORGET_TURNRECALL_THRESHOLD", "0.45"))
46
+ # 0.45 → 0.33 (2026-08-23, 다국어 mpnet 전환 실측): 새 공간의 합성 점수 대역이
47
+ # 내려앉았다 — 관련 질의 상위 0.40~0.46, 무관 바닥 0.28. 구 문턱 0.45는 구 공간
48
+ # (무관조차 0.51 바닥)에선 전부 통과시키는 장식이었지만 새 공간에선 관련 픽을
49
+ # 반쯤 자른다. 허브성 오탐(무관인데 벡터 0.8)은 이 문턱이 아니라 평탄도 게이트가
50
+ # 잡는다 — 실측: 스포츠 무관 질의 프로필 간격 0.02 < MARGIN 0.03 → 침묵.
51
+ SCORE_THRESHOLD = float(os.environ.get("FORGET_TURNRECALL_THRESHOLD", "0.33"))
52
+ # 의미 바닥: 임베딩 성분의 절대 하한 (2차 가드 — 이 레짐에선 코사인이
53
+ # 0.87~0.91 띠에 포화돼 판별력이 약함을 실측으로 확인).
54
+ SEMANTIC_FLOOR = float(os.environ.get("FORGET_TURNRECALL_SEMANTIC_FLOOR", "0.30"))
55
+ # body A2의 실제 수술 (진단 정정 2026-08-06): 판별력은 절대값이 아니라
56
+ # 분포의 모양에 있다. 진짜 관련 질의는 봉우리를 만들고(top1-중앙값
57
+ # 0.054 실측), 소음 질의는 평지다(0.014 — "프로토타입" 3건 유출 사례).
58
+ # 평지면 이 턴은 기억을 고르지 못한 것 — 침묵이 정답.
59
+ FLATNESS_MARGIN = float(os.environ.get("FORGET_TURNRECALL_MARGIN", "0.03"))
34
60
  # Conflict-zone members get a looser gate: missing a plain recall costs
35
61
  # silence, but missing a corrected-zone alert is how incident #1 happened —
36
62
  # and being part of a supersede pair is itself strong prior evidence the
37
63
  # zone matters. Recalibrate both when the embedder switches to e5.
38
64
  CONFLICT_THRESHOLD = float(os.environ.get("FORGET_CONFLICT_THRESHOLD", "0.32"))
39
65
  MAX_RECALLS = 3
66
+ # c63: 인출 깊이와 주입 후보 집합을 분리한다. 평탄도 분포를 **자격 후보에서만**
67
+ # 재기 때문에 최소 개수 조건도 자격 수로 세는데, 깊이가 5뿐이면 무자격 후보
68
+ # (task_state claim·capture 포인터·충돌쌍) 2개로 자격이 3개까지 떨어져 게이트가
69
+ # 한 마디 없이 전면 정지한다. 점수는 결과 집합 크기에 불변임을 실측 확인했으므로
70
+ # (실제 turn_recall 질의 20건, 깊이 5 대 10에서 상위 5개 (id,score) 20/20 동일,
71
+ # spread 변화 0건 — c63_depth_invariance.py) 깊이 인상은 자[尺]를 바꾸지 않는다.
72
+ CANDIDATE_TOP_K = MAX_RECALLS + 7 # 분포 표본용 여유분까지 인출
73
+ PICK_POOL = MAX_RECALLS + 2 # 주입·충돌 후보 집합은 종전과 동일 (입력 집합 불변)
74
+ FLATNESS_WINDOW = MAX_RECALLS + 2 # 창을 고정해 중앙값 위치를 보존 (len//2는 4·5 모두 2)
75
+ FLATNESS_MIN_SAMPLES = 4
40
76
  MEMORY_CHAR_LIMIT = 160
41
77
  MIN_PROMPT_LEN = 8
42
78
 
79
+ # ── 턴-유형 게이트 (2026-08-10, 3연속 소음 사건의 처치) ─────────────────────
80
+ # 소음 해부(트레이스 selection_failure 3건): 2/3은 layer:self 격언이 어휘
81
+ # 인접으로 유출(self는 캡슐 "자기:" 라인이라는 전용 채널이 이미 있다), 1/3은
82
+ # 대화 안에서 이미 충족되는 질문("계획이 뭐였더라")에 타 맥락 기억이 응답.
83
+ # 게이트는 검색 **전에** 턴의 유형을 읽는다:
84
+ # 기억-의존 신호("예전에"·"지난번"·세션에 없는 고유명 등) → 진행 +
85
+ # recall low→high 승격 (적응형 기어 — 2026-08-10 정훈 승인 방향)
86
+ # 대화-국소 신호(지시대명사·절차 질문) → 검색 없이 침묵
87
+ # 참조 대상이 세션 대화 꼬리에 전부 있음 → 침묵 (conversation-first)
88
+ # FORGET_TURNRECALL_GATE=off 가 게이트·승격을 끈다 (전후 비교용 대조 경로).
89
+ # self 제외는 게이트가 아니라 설계 규칙이므로 대조 경로에서도 유지된다.
90
+ GATE_ENV = "FORGET_TURNRECALL_GATE"
91
+ TRANSCRIPT_TAIL_BYTES = 60_000 # 대화 꼬리 읽기 상한 — 세션이 커져도 상수 비용
92
+
93
+ _MEMORY_SIGNAL_RE = re.compile("|".join((
94
+ r"예전",
95
+ r"지난\s*번",
96
+ r"저번",
97
+ r"원래\s*(?:계획|하기로|하려|정했)",
98
+ r"전에\s*(?:말했|얘기했|정했|결정했|했었|하기로|하던)",
99
+ r"그때\s*(?:내가|우리가|정했|말했|했던|하던)",
100
+ r"(?:내|제|우리)가\s*(?!아까|방금)[^\n.?!]{0,24}(?:라고\s*했|라고\s*말했|했다고|하자고\s*했|말했|정했|결정했|골랐|시켰)",
101
+ r"(?:말했|정했|결정했|논의했|얘기했|합의했)던",
102
+ r"기로\s*했", # 결정-회상형: "…기로 했지/했잖아" — c104, 게이트가 핀 테스트 2건을 삼킨 수리
103
+ r"기억\s*(?:나|안\s*나|하|해|있|남)",
104
+ r"뭐라고\s*(?:했|말했|그랬)",
105
+ r"\bremember\b|\brecall\b",
106
+ r"\blast\s+(?:time|session|week)\b",
107
+ r"\bpreviously\b|\bback\s+then\b",
108
+ r"\bwe\s+(?:said|decided|agreed|discussed)\b",
109
+ r"\b(?:i|you)\s+(?:told|asked|mentioned|said|decided)\b",
110
+ )), re.IGNORECASE)
111
+
112
+ _LOCAL_SIGNAL_RE = re.compile("|".join((
113
+ r"이거|그거|저거|이것|그것|저것|이게|그게|저게|이건|그건|저건|이걸|그걸|저걸",
114
+ r"(?:이|그|저)\s(?:코드|파일|함수|부분|줄|에러|오류|버그|테스트|결과|로그|출력|화면|문장|단락|목록|커밋|브랜치|폴더|디렉토리)",
115
+ r"방금|아까|바로\s*(?:위|앞|전)|위에서|여기\s*(?:서|에)",
116
+ # '어떻게'는 절차형만 국소다 — "어떻게 됐/되고 있"(결과 회상)은 게이트를 넘어
117
+ # coverage/점수가 판단한다 (c104: bare '어떻게'가 "H100 어떻게 되고 있어?"를 침묵시킨 실측).
118
+ r"어떻게\s*(?:하|해|할|합|사용|쓰|만드|만들|짜|고치|설치|실행|돌리|동작|작동)",
119
+ r"설명\s*(?:해|좀|부탁)",
120
+ r"뭐\s*(?:였|랬)?더라",
121
+ r"무슨\s*(?:뜻|말|의미)",
122
+ r"왜\s*안\s*(?:되|돼)",
123
+ r"\bhow\s+(?:do|does|can|to)\b|\bexplain\b|\bwhat\s+does\b",
124
+ r"\bthis\s+(?:code|file|function|error|line|test|output)\b",
125
+ )), re.IGNORECASE)
126
+
127
+ _TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_.\-]+|[가-힣]{2,}|[0-9]{2,}")
128
+
129
+ # 질문·요청의 뼈대 어휘 — 참조 **대상**이 아니므로 coverage 계산에서 뺀다.
130
+ _STOPWORDS = {
131
+ "그리고", "그런데", "근데", "그래서", "하지만", "그럼", "그러면", "이제", "지금",
132
+ "오늘", "내일", "어제", "우리", "내가", "제가", "네가", "니가",
133
+ "이거", "그거", "저거", "이건", "그건", "저건", "이게", "그게", "저게",
134
+ "이걸", "그걸", "저걸", "이것", "그것", "저것", "여기", "저기", "거기",
135
+ "뭐야", "뭐지", "뭐냐", "뭐였지", "뭐였더라", "뭐더라", "무엇", "무슨",
136
+ "어떤", "어떻게", "어디", "언제", "누가", "누구", "얼마나",
137
+ "해줘", "해봐", "해라", "하자", "해주세요", "주세요", "부탁", "부탁해",
138
+ "다시", "한번", "계속", "먼저", "같이", "말고", "말이야",
139
+ "설명", "설명해", "설명해줘", "알려줘", "알려", "알아봐", "알아봐줘",
140
+ "말해줘", "보여줘", "확인", "확인해", "확인해줘", "정리", "정리해", "정리해줘",
141
+ "있어", "없어", "있는", "없는", "하는", "했던", "해서", "하면", "하고",
142
+ "해도", "할까", "될까", "되나", "됐다", "됐어",
143
+ "아니", "아니야", "맞아", "맞지", "그래", "그렇게", "이렇게", "저렇게",
144
+ "the", "a", "an", "and", "or", "but", "for", "with", "from", "this", "that",
145
+ "these", "those", "what", "which", "how", "why", "when", "where", "who",
146
+ "please", "can", "could", "should", "would", "will", "do", "does", "did",
147
+ "is", "are", "was", "were", "be", "been", "it", "its", "in", "on", "at",
148
+ "to", "of", "my", "our", "your", "me", "we", "you", "not", "no", "yes",
149
+ "ok", "okay", "let", "lets", "just", "now", "then", "explain", "show",
150
+ "tell", "check", "fix", "run", "use", "make", "add", "update", "help",
151
+ }
152
+
153
+
154
+ # 토큰 단위만 — 행 단위 명령 패턴은 한 줄 프롬프트의 자연어 꼬리까지 삼켜
155
+ # 무동작 폴백을 유발했다 (사이클 3 실측: hygiene이 항등함수였음).
156
+ _MACHINE_SPAN_RE = re.compile(
157
+ r"\bhttps?://\S+" # URL
158
+ r"|\b\w+@\d{1,3}(?:\.\d{1,3}){3}\b" # user@IP
159
+ r"|\b\d{1,3}(?:\.\d{1,3}){3}\b" # IP
160
+ r"|\b[0-9a-f]{12,}\b" # 긴 16진
161
+ r"|(?:^|\s)-\w\s+\d+\b" # 플래그+숫자 (-p 54985)
162
+ r"|(?:^|\s)(?:ssh|scp|curl|wget|rsync)(?=\s)") # 명령어 토큰만
163
+
164
+
165
+ def _hygiene(prompt: str) -> str:
166
+ """P-R-3 질의 위생: 기계 스팬은 회상 질의를 희석한다 (inc-004 —
167
+ ssh 행이 점수를 문턱 가장자리로 끌어내려 런마다 0~4건 요동).
168
+ 검색 질의에서만 벗긴다 — 게이트·coverage는 원문을 계속 본다."""
169
+ cleaned = _MACHINE_SPAN_RE.sub(" ", prompt)
170
+ cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
171
+ return cleaned if len(cleaned) >= MIN_PROMPT_LEN else prompt
172
+
43
173
 
44
174
  def _rpc(name: str, arguments: dict, timeout: int = 5) -> dict:
45
175
  payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
@@ -73,37 +203,316 @@ def _conflict_pair(item: dict) -> tuple[str, str] | None:
73
203
  return None
74
204
 
75
205
 
76
- def _seen_ids(session_id: str) -> tuple[set[str], str]:
77
- seen: set[str] = set()
206
+ def _injection_eligible(item: dict) -> bool:
207
+ """Could this candidate ever be offered as a plain turn recall?
208
+
209
+ Mirrors the structural drops in main()'s pick loop — the ones that hold
210
+ regardless of score: capture pointers, task_state ledger rows, self-layer
211
+ maxims, and supersede pairs (which travel the conflict path instead). Only
212
+ these candidates may shape the flatness distribution; letting an
213
+ ineligible item be the peak means the peak-detector measures something it
214
+ can never surface.
215
+ """
216
+ metadata = item.get("metadata") or {}
217
+ if metadata.get("hook"):
218
+ return False
219
+ if metadata.get("assertion_kind") == "task_state":
220
+ return False
221
+ if str(metadata.get("layer") or "").lower() == "self":
222
+ return False
223
+ if _conflict_pair(item):
224
+ return False
225
+ return True
226
+
227
+
228
+ def _gate_off() -> bool:
229
+ return str(os.environ.get(GATE_ENV, "")).strip().lower() in {"0", "off", "false"}
230
+
231
+
232
+ def _transcript_tail(path: str) -> str:
233
+ """세션 대화 꼬리의 텍스트(소문자). 못 읽으면 "" — 게이트는 증거 없이 안 움직인다."""
234
+ if not path:
235
+ return ""
236
+ try:
237
+ with open(path, "rb") as fh:
238
+ fh.seek(0, os.SEEK_END)
239
+ size = fh.tell()
240
+ offset = max(0, size - TRANSCRIPT_TAIL_BYTES)
241
+ fh.seek(offset)
242
+ blob = fh.read().decode("utf-8", "replace")
243
+ except Exception:
244
+ return ""
245
+ lines = blob.splitlines()
246
+ if offset and lines:
247
+ lines = lines[1:] # seek 절단으로 깨진 첫 줄은 버린다
248
+ texts: list[str] = []
249
+ for line in lines:
250
+ line = line.strip()
251
+ if not line:
252
+ continue
253
+ try:
254
+ entry = json.loads(line)
255
+ except Exception:
256
+ continue
257
+ _collect_text(entry, texts)
258
+ return "\n".join(texts).lower()
259
+
260
+
261
+ def _collect_text(node, out: list[str], depth: int = 0) -> None:
262
+ """중첩 어디에 있든 "text" 값과 문자열 content를 긁는다 (tool_result 포함)."""
263
+ if depth > 8:
264
+ return
265
+ if isinstance(node, dict):
266
+ text = node.get("text")
267
+ if isinstance(text, str):
268
+ out.append(text)
269
+ content = node.get("content")
270
+ if isinstance(content, str):
271
+ out.append(content)
272
+ for value in node.values():
273
+ if isinstance(value, (dict, list)):
274
+ _collect_text(value, out, depth + 1)
275
+ elif isinstance(node, list):
276
+ for value in node:
277
+ _collect_text(value, out, depth + 1)
278
+
279
+
280
+ def _salient_tokens(prompt: str) -> list[str]:
281
+ tokens: list[str] = []
282
+ for token in _TOKEN_RE.findall(prompt[:400]):
283
+ if token.lower() in _STOPWORDS or token in tokens:
284
+ continue
285
+ tokens.append(token)
286
+ if len(tokens) >= 24:
287
+ break
288
+ return tokens
289
+
290
+
291
+ def _found_in_text(token: str, text: str) -> bool:
292
+ """조사·어미가 붙은 한국어 토큰은 앞에서부터 줄여가며 어간 일치를 본다."""
293
+ low = token.lower()
294
+ if low in text:
295
+ return True
296
+ if re.fullmatch(r"[가-힣]+", token):
297
+ for cut in range(len(token) - 1, 1, -1):
298
+ if token[:cut] in text:
299
+ return True
300
+ return False
301
+
302
+
303
+ def _proper_like(token: str) -> bool:
304
+ """세션에 없으면 기억-의존을 시사하는 고유명꼴: 대문자/숫자 섞인 라틴 토큰."""
305
+ return bool(re.search(r"[A-Za-z]", token)) and bool(re.search(r"[A-Z0-9]", token))
306
+
307
+
308
+ def _classify_turn(prompt: str, transcript_path: str, crossed: bool) -> tuple[str, str | None]:
309
+ """(gate, gear). gear None = 검색 없이 침묵.
310
+
311
+ 우선순위: 기억-의존 신호(명시적 과거 호출·프로젝트 경계 넘기 요청)가
312
+ 대화-국소 신호를 이긴다 — "지난번에 정한 거 설명해줘"는 기억 질문이다.
313
+ 꼬리를 못 읽으면(첫 턴·비표준 호출자) coverage/novelty는 판단하지 않는다
314
+ — 증거 없는 침묵도, 증거 없는 승격도 하지 않는 fail-open.
315
+ """
316
+ if crossed or _MEMORY_SIGNAL_RE.search(prompt):
317
+ return "memory", "high"
318
+ tail = _transcript_tail(transcript_path)
319
+ tokens = _salient_tokens(prompt)
320
+ if tail and tokens:
321
+ missing = [token for token in tokens if not _found_in_text(token, tail)]
322
+ if not missing:
323
+ return "covered", None # 참조 대상이 전부 세션 안 — 대화가 답한다
324
+ if any(_proper_like(token) for token in missing):
325
+ return "novel", "high" # 세션에 없는 고유명 = 기억-의존 신호
326
+ if _LOCAL_SIGNAL_RE.search(prompt):
327
+ return "local", None
328
+ return "neutral", "low"
329
+
330
+
331
+ def _error_brief(exc: BaseException) -> str:
332
+ """예외를 원장 1행 크기로 — 종류(클래스명)가 앞, 메시지는 꼬리 (관측 59:
333
+ 종류 없는 search_error 13건은 원인 귀속이 불가능했다)."""
334
+ return f"{type(exc).__name__}: {exc}"[:160]
335
+
336
+
337
+ def _note_gate(session_id: str, gate: str, gear: str | None, action: str,
338
+ n_picks: int, n_conflicts: int, prompt: str,
339
+ error: str | None = None) -> None:
340
+ """게이트 결정을 원장에 1행 남긴다 — 주입률(주입/턴)의 분모이자 전후 비교 자료.
341
+ 컨텍스트에는 한 글자도 쓰지 않는다. error는 실패 행에만 붙는다 —
342
+ 성공 행의 스키마는 기존 원장 파서와 그대로 호환."""
343
+ try:
344
+ os.makedirs(STATE_DIR, exist_ok=True)
345
+ row = {
346
+ "at": int(time.time()),
347
+ "session": (session_id or "")[:8],
348
+ "gate": gate,
349
+ "gear": gear or "-",
350
+ "action": action,
351
+ "picks": n_picks,
352
+ "conflicts": n_conflicts,
353
+ "prompt_head": prompt[:80],
354
+ }
355
+ if error:
356
+ row["error"] = error
357
+ with open(os.path.join(STATE_DIR, "turnrecall_gate.jsonl"), "a", encoding="utf-8") as fh:
358
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
359
+ except Exception:
360
+ pass
361
+
362
+
363
+ def _threshold_notice(session_id: str) -> tuple[str, dict, str] | None:
364
+ """③의 소비자 — forget_digest가 세운 near_threshold를 재부팅 권고 1줄로.
365
+
366
+ 에피소드당 한 번: advised 마커는 플래그가 내려갈 때만 forget_digest가
367
+ 지우므로, 임계 위에 머무는 동안 매 턴 잔소리하지 않는다. 문구는
368
+ backlog_turns를 읽어 '소화 완료'를 과잉 주장하지 않고, 강제하지 않는다 —
369
+ 캡슐과 같은 제안 규약이다 (rolling-consolidation-stage1.md ③).
370
+ """
371
+ if not session_id:
372
+ return None
373
+ path = os.path.join(STATE_DIR, f"digest-{session_id}.json")
374
+ try:
375
+ with open(path, encoding="utf-8") as fh:
376
+ state = json.load(fh)
377
+ except Exception:
378
+ return None
379
+ if not state.get("near_threshold") or state.get("near_threshold_advised"):
380
+ return None
381
+ backlog = int(state.get("backlog_turns") or 0)
382
+ digested = (
383
+ "활성 창 밖 구간은 forget에 소화 완료"
384
+ if backlog == 0
385
+ else f"활성 창 밖 {backlog}턴은 다음 Stop에서 소화 예정"
386
+ )
387
+ ratio = float(state.get("est_ratio") or 0.0)
388
+ line = (
389
+ f"[forget 임계 — 컨텍스트 사용 추정 ~{round(ratio * 100)}%. {digested}. "
390
+ "컴팩션보다 재부팅(새 세션 + 캡슐 복원)이 낫다 — 제안이며 명령이 아님]"
391
+ )
392
+ return line, state, path
393
+
394
+
395
+ def _mark_threshold_advised(state: dict, path: str) -> None:
396
+ try:
397
+ state["near_threshold_advised"] = True
398
+ with open(path, "w", encoding="utf-8") as fh:
399
+ json.dump(state, fh, ensure_ascii=False)
400
+ except Exception:
401
+ pass
402
+
403
+
404
+ # ── 상황 좌석 (P-M-8, 2026-08-30) ──────────────────────────────────────────
405
+ # 실전 사고: «벤치마크로 증명할 수 있으려나»가 LME-V2 제출-대기 상태를 못
406
+ # 데려옴 — 개방 원장 랭킹도, 전용 풀 코사인도 미스(임베딩 과신 4연속 실측).
407
+ # 처방: 질문형 턴에서 서버 situation_recall(결정론 후보화+로컬 판독기)이
408
+ # 활성 트랙 1줄을 고른다. 픽이 없어도(침묵 턴) 이 줄은 나간다 — 그게 요점.
409
+ _SITUATION_Q_RE = re.compile(
410
+ r"[??]\s*$|려나|을까\b|할까\b|되나\b|볼\s*수|을\s*수\s*있|어때|가능\s*(?:해|할|한)"
411
+ # P-R-4 (inc-005/006): 서술형 의도도 상황을 연다 — «하자»는 질문만큼 상태를 필요로 한다
412
+ r"|하자\b|해보자\b|보자\b|싶어|싶은데|얘기한\s*거|말한\s*거")
413
+
414
+
415
+ def _situation_seat(session_id: str, prompt: str) -> str | None:
416
+ if str(os.environ.get("FORGET_SITUATION_SEAT", "")).strip().lower() in {"0", "off", "false"}:
417
+ return None
418
+ if not _SITUATION_Q_RE.search(prompt):
419
+ return None
420
+ state_path = os.path.join(STATE_DIR, f"{session_id}.situation.json") if session_id else ""
421
+ seen_tasks: list[str] = []
422
+ if state_path and os.path.exists(state_path):
423
+ try:
424
+ with open(state_path, encoding="utf-8") as fh:
425
+ seen_tasks = json.load(fh).get("tasks") or []
426
+ except Exception:
427
+ pass
428
+ try:
429
+ sit_args = {"query": prompt[:300]}
430
+ if os.environ.get("FORGET_REPLAY_AS_OF"):
431
+ sit_args["as_of"] = os.environ["FORGET_REPLAY_AS_OF"]
432
+ hit = (_rpc("situation_recall", sit_args, timeout=9) or {}).get("situation")
433
+ except Exception:
434
+ return None
435
+ if not hit or hit.get("task_id") in seen_tasks:
436
+ return None
437
+ if state_path:
438
+ try:
439
+ os.makedirs(STATE_DIR, exist_ok=True)
440
+ with open(state_path, "w", encoding="utf-8") as fh:
441
+ json.dump({"tasks": seen_tasks + [hit["task_id"]]}, fh, ensure_ascii=False)
442
+ except Exception:
443
+ pass
444
+ return (f"[forget 상황 — 이 질문이 가리키는 활성 트랙 '{hit['task_id']}': "
445
+ f"{str(hit.get('line') or '')[:150]} / 답하기 전 이 트랙의 최신 상태를 근거로 삼을 것]")
446
+
447
+
448
+ SUPPRESS_TTL_TURNS = int(os.environ.get("FORGET_SUPPRESS_TTL_TURNS", "40"))
449
+ REOFFER_SCORE_MARGIN = 0.05
450
+ REOFFER_MAX = 1
451
+ GREEN_REOFFER_MIN_TURNS = 10 # 직후 재부상은 메아리 — 레포 테스트가 잡은 소음 회귀의 수리
452
+
453
+
454
+ def _seen_ids(session_id: str) -> tuple[dict, str]:
455
+ """반복 억제 장부 (P-R-1, 2026-08-30): id → [제안 턴, 재부상 횟수].
456
+
457
+ inc-004 부검: 무TTL 억제가 장수 세션(주입 179건) 후반의 회상을 질식 —
458
+ H100 결정 시점에 A100 정책 기억(green)이 차단됐다. TTL(40턴)과 green
459
+ 고점수 재부상(1회)이 처방. 구판 장부(리스트)는 턴 0으로 승계 — 장수
460
+ 세션일수록 즉시 TTL 만료로 숨통이 트인다(의도된 방향).
461
+ """
462
+ book: dict = {"turn": 0, "seen": {}}
78
463
  offer_path = os.path.join(STATE_DIR, f"{session_id}.json")
79
464
  for candidate in (offer_path, offer_path + ".done"):
80
465
  if os.path.exists(candidate):
81
466
  try:
82
467
  with open(candidate, encoding="utf-8") as fh:
83
- seen.update(json.load(fh).get("memory_ids") or [])
468
+ for mid in json.load(fh).get("memory_ids") or []:
469
+ book["seen"].setdefault(mid, [0, 0])
84
470
  except Exception:
85
471
  pass
86
472
  turns_path = os.path.join(STATE_DIR, f"{session_id}.turns.json")
87
473
  if os.path.exists(turns_path):
88
474
  try:
89
475
  with open(turns_path, encoding="utf-8") as fh:
90
- seen.update(json.load(fh).get("injected") or [])
476
+ data = json.load(fh)
477
+ book["turn"] = int(data.get("turn") or 0)
478
+ injected = data.get("injected") or []
479
+ if isinstance(injected, dict):
480
+ for mid, val in injected.items():
481
+ book["seen"][mid] = list(val)[:2] if isinstance(val, list) else [0, 0]
482
+ else: # 구판 리스트 — 턴 0 승계
483
+ for mid in injected:
484
+ book["seen"].setdefault(mid, [0, 0])
91
485
  except Exception:
92
486
  pass
93
- return seen, turns_path
487
+ return book, turns_path
94
488
 
95
489
 
96
- def _remember_injected(turns_path: str, injected: list[str]) -> None:
97
- existing: list[str] = []
98
- if os.path.exists(turns_path):
99
- try:
100
- with open(turns_path, encoding="utf-8") as fh:
101
- existing = json.load(fh).get("injected") or []
102
- except Exception:
103
- pass
490
+ def _suppressed(book: dict, memory_id: str, light: str, score: float) -> bool:
491
+ """P-R-1 판정: TTL 안이면 억제 — 단 green이 고점수면 1회 재부상 허용."""
492
+ entry = book["seen"].get(memory_id)
493
+ if entry is None:
494
+ return False
495
+ offered_turn, reoffers = (entry + [0, 0])[:2]
496
+ if book["turn"] - int(offered_turn) >= SUPPRESS_TTL_TURNS:
497
+ return False
498
+ if (light == "green" and reoffers < REOFFER_MAX
499
+ and book["turn"] - int(offered_turn) >= GREEN_REOFFER_MIN_TURNS
500
+ and score >= SCORE_THRESHOLD + REOFFER_SCORE_MARGIN):
501
+ return False
502
+ return True
503
+
504
+
505
+ def _remember_injected(turns_path: str, injected: list[str], book: dict) -> None:
506
+ seen = dict(book.get("seen") or {})
507
+ for mid in injected:
508
+ prev = seen.get(mid)
509
+ if prev is not None:
510
+ seen[mid] = [book["turn"], int((list(prev) + [0, 0])[1]) + 1] # 재부상
511
+ else:
512
+ seen[mid] = [book["turn"], 0]
104
513
  os.makedirs(STATE_DIR, exist_ok=True)
105
514
  with open(turns_path, "w", encoding="utf-8") as fh:
106
- json.dump({"injected": existing + injected}, fh, ensure_ascii=False)
515
+ json.dump({"turn": book["turn"], "injected": seen}, fh, ensure_ascii=False)
107
516
 
108
517
 
109
518
  def main() -> None:
@@ -112,22 +521,99 @@ def main() -> None:
112
521
  session_id = str(hook_input.get("session_id") or "").strip()
113
522
  if len(prompt) < MIN_PROMPT_LEN or prompt.startswith(("/", "!", "<", "#")):
114
523
  return
115
- seen, turns_path = _seen_ids(session_id) if session_id else (set(), "")
524
+ seen_book, turns_path = _seen_ids(session_id) if session_id else ({"turn": 0, "seen": {}}, "")
525
+ seen_book["turn"] += 1 # 이 턴 번호 — 기록 시 함께 저장
116
526
  # Project boundary = privacy boundary: the other company's strategy must
117
527
  # not surface mid-session here just because the words rhyme. Crossing is
118
528
  # possible, but only when the user asks for it — and it says so when it does.
119
529
  project = None if scope_disabled() else project_key_for_path(hook_input.get("cwd") or os.getcwd())
120
530
  crossed = bool(project) and wants_cross_project(prompt)
121
- search_args: dict = {"query": prompt[:300], "top_k": MAX_RECALLS + 2}
531
+ # 턴-유형 게이트: 검색 전에 이 턴이 기억을 원하는 턴인지 읽는다 (2026-08-10).
532
+ if _gate_off():
533
+ gate, gear = "off", "low" # 대조 경로 — 게이트·승격 없이 종전 동작 그대로
534
+ else:
535
+ gate, gear = _classify_turn(prompt, str(hook_input.get("transcript_path") or ""), crossed)
536
+ if gear is None:
537
+ _note_gate(session_id, gate, gear,
538
+ "silent_covered" if gate == "covered" else "silent_gate", 0, 0, prompt)
539
+ return
540
+ # 기본은 instant 경로(low) — 회상 다이얼을 모든 타건에 물리지 않는다(950e77a).
541
+ # 단 턴이 스스로 기억-의존을 선언하면("지난번…", 세션에 없는 고유명) 그 턴에
542
+ # 한해 high로 승격한다 — 적응형 기어 (2026-08-10 정훈 승인 방향).
543
+ search_args: dict = {
544
+ "query": _hygiene(prompt)[:300],
545
+ "top_k": CANDIDATE_TOP_K,
546
+ "recall": gear,
547
+ "trace": "turn_recall", # body A1: 피드백이 붙을 주소를 만든다
548
+ "score_breakdown": True, # body A2: 의미/어휘 성분 분리
549
+ }
550
+ if os.environ.get("FORGET_REPLAY") == "1":
551
+ # RECALL-BENCH 원칙 6 (2026-08-30): 재생이 트레이스를 남기면 관성
552
+ # 상태가 오염돼 같은 표본이 런마다 다른 점수를 받는다 — 실측: inc-004
553
+ # 재생이 0~4건 요동. 재생 모드는 관측만 하고 흔적을 남기지 않는다.
554
+ search_args.pop("trace", None)
122
555
  if project and not crossed:
123
556
  search_args["filters"] = layered_filter(project)
124
- result = _rpc("search_memories", search_args)
557
+ try:
558
+ # 상한은 기어를 따른다: instant 경로(low)는 5s 원칙 유지, 턴이 스스로
559
+ # 기억-의존을 선언한 high에서는 딥 리콜 실측 ~4.4s를 품도록 12s —
560
+ # 그 턴에서 몇 초의 대기는 침묵보다 싸다 (2026-08-11, search_error 2/2 수리).
561
+ result = _rpc("search_memories", search_args, timeout=7 if gear == "high" else 5)
562
+ except Exception as exc:
563
+ # 2026-08-12 실측: 콜드 딥리콜 11s > CC 훅 한도 → 회상 전량 폐기 사고.
564
+ # 서버가 keep_alive 상주+기동 예열로 콜드를 없애지만, 남는 콜드 턴은
565
+ # 침묵 대신 low로 강등한다 — 얕은 회상이 무회상보다 낫다는 게 c43의
566
+ # 교훈(예산은 조건부 자산)의 역방향 적용이기도 하다.
567
+ if gear == "high":
568
+ try:
569
+ search_args["recall"] = "low"
570
+ result = _rpc("search_memories", search_args, timeout=2)
571
+ _note_gate(session_id, gate, gear, "degraded_to_low", 0, 0, prompt,
572
+ error=_error_brief(exc))
573
+ except Exception as exc2:
574
+ _note_gate(session_id, gate, gear, "search_error", 0, 0, prompt,
575
+ error=_error_brief(exc2))
576
+ return
577
+ else:
578
+ # fail-open은 유지하되 원장에는 남긴다 — 원인 종류 동봉 (관측 59 ②).
579
+ _note_gate(session_id, gate, gear, "search_error", 0, 0, prompt,
580
+ error=_error_brief(exc))
581
+ return
582
+ trace_id = str(result.get("trace_id") or "")
583
+ # 평탄도는 **주입 자격이 있는 후보**에서만 재야 한다 (c62 실측 수리).
584
+ # 이전 판본은 배제 이전의 전체 결과로 분포를 재서, 봉우리가 결코 주입될 수
585
+ # 없는 항목(task_state claim·capture 포인터)일 수 있었다. 실측: 축자 동일한
586
+ # 질의의 두 런이 하위 4개 점수는 완전히 같은데 top1(양쪽 다 task_state
587
+ # claim)만 0.9172 vs 1.0000으로 갈려 주입 0 vs 3이 됐다 — 방금 쓴 claim은
588
+ # 1.0으로 포화하므로 푸시 회상의 on/off가 장부 신선도에 결합돼 있었다.
589
+ results = result.get("results") or []
590
+ # 창은 자격 후보 상위 FLATNESS_WINDOW개 — 인출을 깊게 해도 자[尺]는 그대로다
591
+ # (c63: 자격 4개였던 질의의 spread 0.0454가 창 5에서도 0.0454, 중앙값 위치 보존).
592
+ scores_all = sorted(
593
+ (float(item.get("score") or 0.0) for item in results if _injection_eligible(item)),
594
+ reverse=True,
595
+ )[:FLATNESS_WINDOW]
596
+ measurable = len(scores_all) >= FLATNESS_MIN_SAMPLES
597
+ flat_distribution = (
598
+ measurable
599
+ and (scores_all[0] - scores_all[len(scores_all) // 2]) < FLATNESS_MARGIN
600
+ )
125
601
  picks = []
602
+ # P4 v1 (2026-08-12, ARB-C evidence_status 이식): 신뢰등(출처 축)과 별개로
603
+ # 이 턴 질문에 대한 증거 강도 축을 분리 보고한다. 절대 임계는 못 쓴다 —
604
+ # 실측(p4_probe): 무관 질의도 합성 점수 0.51 바닥, 관련 질의 0.55~0.66으로
605
+ # 압축돼 있어 턴-상대(상단 밴드)로만 가른다. c40 벤치의 spread 신호와 동형.
606
+ pick_scores: dict[str, float] = {}
607
+ # 시간 이웃은 서버가 목록 '끝'에 붙인다 — PICK_POOL 절단 전에 전체에서 수집.
608
+ neighbor_pool: list[dict] = [item for item in results if item.get("temporal_neighbor_of")]
126
609
  conflict_pairs: dict[tuple[str, str], None] = {}
127
- for item in result.get("results") or []:
610
+ for item in results[:PICK_POOL]:
611
+ if item.get("temporal_neighbor_of"):
612
+ continue # 이웃은 별도 경로 — 정규 임계를 안 거친다
128
613
  score = float(item.get("score") or 0.0)
129
614
  memory_id = str(item.get("id") or "")
130
- if not memory_id or memory_id in seen:
615
+ light_early = str((item.get("trust") or {}).get("light") or "yellow")
616
+ if not memory_id or _suppressed(seen_book, memory_id, light_early, score):
131
617
  continue
132
618
  metadata = item.get("metadata") or {}
133
619
  if metadata.get("hook"):
@@ -137,6 +623,11 @@ def main() -> None:
137
623
  # only; surfacing them as turn recalls is friction F2's C2 cause
138
624
  # (long claim texts farm phrase_bonus regardless of topic).
139
625
  continue
626
+ if str(metadata.get("layer") or "").lower() == "self":
627
+ # self 격언은 캡슐 "자기:" 라인 전용 채널로만 나간다 — ambient 회상에
628
+ # 어휘 인접으로 새면 소음 (2026-08-10 실측: 소음 3건 중 2건). 충돌쌍
629
+ # 경로보다 먼저 잘라 self 정정 이력도 턴 회상으로는 안 나간다.
630
+ continue
140
631
  pair = _conflict_pair(item)
141
632
  if pair:
142
633
  if score >= CONFLICT_THRESHOLD:
@@ -144,15 +635,22 @@ def main() -> None:
144
635
  continue # presented as a pair below, not as a plain recall
145
636
  if score < SCORE_THRESHOLD:
146
637
  continue
638
+ if flat_distribution:
639
+ continue # 평지 분포 = 이 질의는 기억을 고르지 못함 (충돌지대는 별도 경로로 통과)
640
+ breakdown = item.get("score_breakdown") or {}
641
+ vector_score = breakdown.get("vector")
642
+ if vector_score is not None and float(vector_score) < SEMANTIC_FLOOR:
643
+ continue
147
644
  trust = item.get("trust") or {}
148
645
  light = str(trust.get("light") or "yellow")
149
646
  picks.append((memory_id, light, str(item.get("memory") or "")[:MEMORY_CHAR_LIMIT]))
647
+ pick_scores[memory_id] = score
150
648
  if len(picks) >= MAX_RECALLS:
151
649
  break
152
650
 
153
651
  conflicts = []
154
652
  for old_id, new_id in list(conflict_pairs)[:2]:
155
- if old_id in seen and new_id in seen:
653
+ if old_id in seen_book["seen"] and new_id in seen_book["seen"]:
156
654
  continue
157
655
  try:
158
656
  old = _rpc("get_memory", {"memory_id": old_id})
@@ -161,43 +659,145 @@ def main() -> None:
161
659
  continue
162
660
  conflicts.append((old_id, new_id, str(old.get("memory") or "")[:MEMORY_CHAR_LIMIT], str(new.get("memory") or "")[:MEMORY_CHAR_LIMIT]))
163
661
 
164
- if not picks and not conflicts:
662
+ if picks and not measurable:
663
+ # 표본이 창의 최소 개수에 못 미쳐 평탄도를 **재지 못한 채** 통과시킨 턴.
664
+ # c62가 이 상태를 "무공지 전면 정지"로 등재했다 — 깊은 인출로 빈도를 0으로
665
+ # 밀었지만(c63 실측 0/20), 남는 경우엔 최소한 흔적을 남긴다. 컨텍스트에는
666
+ # 한 글자도 쓰지 않는다: 이 원장은 감사가 읽는 자리다.
667
+ _note_unmeasured_flatness(prompt, len(results), len(scores_all), len(picks))
668
+ notice = _threshold_notice(session_id)
669
+ situation = _situation_seat(session_id, prompt)
670
+ if not picks and not conflicts and notice is None and situation is None:
671
+ _note_gate(session_id, gate, gear, "silent_scores", 0, 0, prompt)
165
672
  return # below threshold or nothing new → silence
166
673
  lines = []
674
+ if situation:
675
+ lines.append(situation)
167
676
  if conflicts:
168
677
  lines.append("[forget 충돌지대 — 이 주제엔 정정 이력이 있음. 현재본 기준으로 행동하고, 구본 위에서 행동하지 말 것]")
169
678
  for _, _, old_text, new_text in conflicts:
170
679
  lines.append(f"- (현재) {new_text}")
171
680
  lines.append(f"- (red/구본) {old_text}")
172
681
  if picks:
682
+ # 전제-검증 조항 (2026-08-10, LME-V2 정합 3쌍 전승 실증의 제품 역이식):
683
+ # 기억과 어긋나는 전제 위에서 답하지 말 것 — 각서의 나머지 절반.
173
684
  header = "[forget 회상 — 이 턴과 관련된 기억 제안. green=행동 근거 OK, yellow=행동 전 확인, red=참고만"
685
+ header += " / 직접=이 턴의 근거 후보, 참고=관련 단서일 뿐 답의 근거 아님"
686
+ header += " / 질문의 전제가 기억과 어긋나면 전제를 따르지 말고 기억을 인용해 짚을 것"
174
687
  header += " / 프로젝트 경계를 넘어 검색함]" if crossed else "]"
175
688
  lines.append(header)
176
- lines += [f"- ({light}) {memory}" for _, light, memory in picks]
689
+ # 증거 태그: 평탄도를 잰 턴에서만 '직접'을 허용 — 못 잰 턴은 전원 '참고'
690
+ # (근거 표기는 과장보다 누락이 싸다). 밴드는 턴 최고점 기준 상대치.
691
+ evidence_band = float(os.environ.get("FORGET_EVIDENCE_BAND", "0.02"))
692
+ top_score = max(pick_scores.values()) if pick_scores else 0.0
693
+ def _etag(mid: str) -> str:
694
+ if not measurable or mid not in pick_scores:
695
+ return "참고"
696
+ return "직접" if pick_scores[mid] >= top_score - evidence_band else "참고"
697
+ lines += [f"- ({light}·{_etag(mid)}) {memory}" for mid, light, memory in picks]
698
+ # EM-LLM 이식(2026-08-11): 채택된 앵커의 시간 이웃 1건 — "회상은 장면을 데려온다".
699
+ if neighbor_pool and os.environ.get("FORGET_RECALL_TEMPORAL", "1").strip().lower() not in {"0", "off", "false"}:
700
+ picked_ids = {memory_id for memory_id, _, _ in picks}
701
+ nb = next((n for n in neighbor_pool
702
+ if str(n.get("temporal_neighbor_of")) in picked_ids
703
+ and str(n.get("id") or "") and str(n.get("id")) not in seen_book["seen"]), None)
704
+ if nb is not None:
705
+ gap = (nb.get("score_breakdown") or {}).get("temporal_gap_minutes")
706
+ nb_text = str(nb.get("memory") or "")[:MEMORY_CHAR_LIMIT]
707
+ lines.append(f"- (yellow·같은 장면 ±{gap}분) {nb_text}")
708
+ picks.append((str(nb.get("id")), "yellow", nb_text)) # 원장·반복억제 합류
709
+ if notice is not None:
710
+ lines.append(notice[0])
711
+ if trace_id and (picks or conflicts):
712
+ lines.append(
713
+ f"[피드백 주소: 이 회상이 명확히 도움/소음이면 record_context_outcome(trace_id=\"{trace_id}\") 한 번]"
714
+ )
177
715
  print("\n".join(lines))
178
- if session_id and turns_path:
716
+ _note_gate(session_id, gate, gear,
717
+ "injected" if (picks or conflicts)
718
+ else ("situation_only" if situation else "notice_only"),
719
+ len(picks), len(conflicts), prompt)
720
+ if notice is not None:
721
+ _mark_threshold_advised(notice[1], notice[2])
722
+ if session_id and turns_path and (picks or conflicts):
179
723
  injected = [memory_id for memory_id, _, _ in picks]
180
724
  ledger_picks = list(picks)
181
725
  for old_id, new_id, old_text, new_text in conflicts:
182
726
  injected += [old_id, new_id]
183
727
  ledger_picks.append((new_id, "green", new_text)) # 메아리 측정은 현재본 기준
184
- _remember_injected(turns_path, injected)
185
- _extend_offer_ledger(session_id, ledger_picks)
728
+ _remember_injected(turns_path, injected, seen_book)
729
+ _extend_offer_ledger(session_id, ledger_picks, trace_id)
186
730
 
187
731
 
188
- def _extend_offer_ledger(session_id: str, picks: list) -> None:
189
- """Feed turn recalls into the outcome flywheel: append their probes and
190
- ids to the session's offer ledger so the capture hook measures them too.
191
- (Discovered gap 07-22: a session answered purely from a turn recall and
192
- the capsule-only labeler scored it "not used".)"""
732
+ def _note_unmeasured_flatness(prompt: str, candidates: int, eligible: int, injected: int) -> None:
733
+ """평탄도를 재지 못하고 통과시킨 턴을 원장에 1행 남긴다 (감사용, 컨텍스트 비용 0)."""
734
+ try:
735
+ os.makedirs(STATE_DIR, exist_ok=True)
736
+ with open(os.path.join(STATE_DIR, "flatness_unmeasured.jsonl"), "a", encoding="utf-8") as fh:
737
+ fh.write(json.dumps({
738
+ "at": int(time.time()),
739
+ "candidates": candidates,
740
+ "eligible": eligible,
741
+ "min_samples": FLATNESS_MIN_SAMPLES,
742
+ "injected": injected,
743
+ "prompt_head": prompt[:80],
744
+ }, ensure_ascii=False) + "\n")
745
+ except Exception:
746
+ pass
747
+
748
+
749
+ def _extend_offer_ledger(session_id: str, picks: list, trace_id: str = "") -> None:
750
+ """Feed turn recalls into the outcome flywheel as *aligned* offers.
751
+
752
+ (Discovered gap 07-22: a session answered purely from a turn recall and the
753
+ capsule-only labeler scored it "not used".)
754
+
755
+ Rewritten 2026-08-23 after measuring what the old shape could express. It
756
+ appended ids into one set and probes into another list — two containers with
757
+ no correspondence — so the capture hook could only say "something echoed"
758
+ and then credit *every* offered memory (it labelled itself all-or-none).
759
+ Worse, the credit landed on the session capsule's trace_id, so a memory
760
+ pulled by a turn recall was recorded against the query
761
+ "session startup — active tasks, open loops". Measured consequence: 784
762
+ turn_recall traces, **zero** with used_memory_ids, while the 28 that had
763
+ gold all wore the session-startup query. The pairing was wrong, so the
764
+ mined training pairs were wrong too.
765
+
766
+ `offers` fixes both: each entry keeps its own probe next to its own id and
767
+ the trace that offered it, which is exactly what `picks` already held.
768
+ """
193
769
  ledger_path = os.path.join(STATE_DIR, f"{session_id}.json")
194
- if not os.path.exists(ledger_path):
195
- return # no capsule trace this session — nothing to record against
196
770
  try:
197
- with open(ledger_path, encoding="utf-8") as fh:
198
- state = json.load(fh)
199
- state["memory_ids"] = list({*(state.get("memory_ids") or []), *(memory_id for memory_id, _, _ in picks)})
200
- state["capsule_lines"] = (state.get("capsule_lines") or []) + [memory[:80] for _, _, memory in picks]
771
+ state = {}
772
+ if os.path.exists(ledger_path):
773
+ with open(ledger_path, encoding="utf-8") as fh:
774
+ state = json.load(fh)
775
+ elif not trace_id:
776
+ return # nothing to record against: no capsule trace, no turn trace
777
+ if not trace_id:
778
+ # 주소 없는 서버(배포 전 사본)에서는 정렬 제안을 만들 수 없다. 새 경로가
779
+ # 조용히 턴 픽을 버리면 측정이 구본보다 나빠지므로 레거시 병합으로 되돌린다:
780
+ # 거친 all-or-none이지만 0보다는 낫다. 서버가 trace_id를 돌려주기 시작하면
781
+ # 이 분기는 저절로 죽는다.
782
+ state["memory_ids"] = list({*(state.get("memory_ids") or []),
783
+ *(memory_id for memory_id, _, _ in picks)})
784
+ state["capsule_lines"] = (state.get("capsule_lines") or []) + [m[:80] for _, _, m in picks]
785
+ state["legacy_merge_reason"] = "no trace_id from server"
786
+ with open(ledger_path, "w", encoding="utf-8") as fh:
787
+ json.dump(state, fh, ensure_ascii=False)
788
+ return
789
+ offers = state.get("offers") or []
790
+ known = {(o.get("id"), o.get("trace")) for o in offers if isinstance(o, dict)}
791
+ for memory_id, _light, memory in picks:
792
+ key = (memory_id, trace_id)
793
+ if memory_id and key not in known:
794
+ # 320자: 의미 판정(v3)의 문턱이 전문(60~320자) 기억으로 보정됐다.
795
+ # 문면 판정(6그램)은 capture 쪽에서 여전히 앞 80자만 쓴다 — 의미 불변.
796
+ offers.append({"id": memory_id, "probe": memory[:320], "trace": trace_id})
797
+ known.add(key)
798
+ state["offers"] = offers
799
+ # 레거시 필드는 세션 캡슐(합성이라 줄↔기억 대응이 없다)의 몫으로 남긴다 —
800
+ # 여기에 턴 픽을 섞던 것이 위양성과 오귀속의 원천이었다.
201
801
  with open(ledger_path, "w", encoding="utf-8") as fh:
202
802
  json.dump(state, fh, ensure_ascii=False)
203
803
  except Exception:
@@ -207,6 +807,8 @@ def _extend_offer_ledger(session_id: str, picks: list) -> None:
207
807
  if __name__ == "__main__":
208
808
  try:
209
809
  main()
210
- except Exception:
211
- pass
810
+ except Exception as exc:
811
+ # 검색 이후의 크래시는 원장에도 없는 완전 침묵이었다 (관측 59 삼킴 지점 ③).
812
+ # fail-open은 유지하되 흔적은 남긴다 — 세션·프롬프트는 이 지점에선 모른다.
813
+ _note_gate("", "-", None, "hook_error", 0, 0, "", error=_error_brief(exc))
212
814
  sys.exit(0)