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.
- package/README.md +65 -21
- package/assets/commands/forget-settings.md +25 -0
- package/assets/commands/forget.md +24 -0
- package/assets/hooks/forget_bstate.py +229 -0
- package/assets/hooks/forget_capture.py +22 -5
- package/assets/hooks/forget_sessionstart.py +21 -3
- package/assets/hooks/forget_toolrecall.py +118 -0
- package/assets/hooks/forget_turnrecall.py +644 -42
- package/assets/plugins/memory-agent/.claude-plugin/plugin.json +15 -0
- package/assets/plugins/memory-agent/.codex-plugin/plugin.json +29 -0
- package/assets/plugins/memory-agent/README.md +9 -0
- package/assets/plugins/memory-agent/skills/memory-agent/SKILL.md +22 -0
- package/assets/plugins/memory-agent/skills/memory-agent/agents/openai.yaml +6 -0
- package/assets/proxy/forget_proxy_watchdog.py +215 -0
- package/assets/skills/memory-agent/SKILL.md +22 -0
- package/assets/skills/memory-agent/agents/openai.yaml +6 -0
- package/package.json +7 -3
- package/src/cli.js +286 -47
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +99 -19
- package/src/proxy.js +431 -0
- package/src/skills.js +95 -0
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
487
|
+
return book, turns_path
|
|
94
488
|
|
|
95
489
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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({"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
"""
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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)
|