forget-connect 0.5.2 → 0.6.1

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 CHANGED
@@ -9,16 +9,18 @@ npx forget-connect
9
9
  ```
10
10
 
11
11
  By default this targets the local server at `http://localhost:8000` and
12
- scopes each client to its own memory pool at
13
- `/mcp/<client>/http/<os-username>` (for example
14
- `/mcp/claude-code/http/junghun`) — your memories stay on your machine and
15
- stay isolated per user and per client. Pass `--no-scope` for the shared
16
- unscoped `/mcp` endpoint. The guided flow detects installed clients and
17
- then:
12
+ connects every provider to one personal vault at
13
+ `/mcp/forget/http/<os-username>` (for example
14
+ `/mcp/forget/http/junghun`). Codex and Claude see the same personal memory,
15
+ but their URLs carry different `profile` values so tool surfaces and writer
16
+ provenance remain separate. Pass `--no-scope` only for the legacy unscoped
17
+ `/mcp` endpoint. The guided flow detects installed clients and then:
18
18
 
19
19
  - merges a `forget` entry into each selected MCP config;
20
20
  - installs a marked memory rule in Claude Code's `CLAUDE.md` and Codex's
21
21
  `AGENTS.md`, so past-decision questions call memory before searching files;
22
+ - installs one shared Memory Agent skill plus provider-specific lifecycle-hook
23
+ registrations without overwriting foreign skills or hooks;
22
24
  - creates a one-time adjacent `.forget-backup` before changing an existing
23
25
  file; and
24
26
  - writes through temporary files and renames them into place.
@@ -33,8 +35,10 @@ npx forget-connect --client claude-code,codex --yes
33
35
  ```
34
36
 
35
37
  Use `FORGET_MCP_URL` or `--url` for a server on another port or host. A Bearer
36
- token (`FORGET_API_KEY`) is only sent over HTTPS; over loopback HTTP the CLI
37
- connects without a token and says so.
38
+ token (`FORGET_API_KEY`) is only sent over HTTPS, except for an explicit
39
+ loopback-only `--local-auth` connection. Without that flag a leftover local
40
+ token is ignored, so credentials cannot silently leak into a different local
41
+ server.
38
42
 
39
43
  ## Hosted service (legacy)
40
44
 
@@ -70,8 +74,10 @@ connections it also verifies the scoped endpoint identity. Add `--json` for
70
74
  automation or `--timeout 30` on a slow network. No authorization value or
71
75
  memory content is printed.
72
76
 
73
- `disconnect` removes only the `forget` MCP entry and the marked Forget rules
74
- block. It does not restore backups or remove unrelated config.
77
+ `disconnect` removes only the `forget` MCP entry, marked Forget rules, owned
78
+ skill files, and owned lifecycle-hook entries. It does not restore backups or
79
+ remove unrelated config, foreign hooks, or user-owned skill files. Shared hook
80
+ scripts remain until neither Codex nor Claude Code references them.
75
81
 
76
82
  ## Options
77
83
 
@@ -83,7 +89,13 @@ block. It does not restore backups or remove unrelated config.
83
89
  --app-id <id> Project/app scope; pair with --user-id
84
90
  --no-scope Install the shared unscoped /mcp endpoint (legacy behavior)
85
91
  --no-auth Do not install a Bearer token
92
+ --local-auth Send FORGET_API_KEY to an explicit loopback server
86
93
  --no-rules Do not manage CLAUDE.md or AGENTS.md
94
+ --no-skill Do not install the shared Memory Agent skill
95
+ --no-hooks Do not install Codex/Claude Code memory hooks
96
+ --proxy Opt in to the local capture proxy (macOS only, off by default)
97
+ --no-proxy Accepted for compatibility; the proxy is already off by default
98
+ --purge-capture With disconnect: also delete ~/.forget/proxy/stream
87
99
  --no-migrate-enacta Keep matching legacy config and rule blocks
88
100
  --dry-run Show which files would change
89
101
  --timeout <seconds> Doctor network timeout, from 1 to 60
@@ -97,13 +109,13 @@ to the Forget block. Use `--no-migrate-enacta` to keep both.
97
109
 
98
110
  ## Files managed
99
111
 
100
- | Client | MCP config | Instruction file |
101
- |---|---|---|
102
- | Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` |
103
- | Codex | `$CODEX_HOME/config.toml` or `~/.codex/config.toml` | `$CODEX_HOME/AGENTS.md` or `~/.codex/AGENTS.md` |
104
- | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | — |
105
- | Claude Desktop (Windows) | `%APPDATA%/Claude/claude_desktop_config.json` | — |
106
- | Claude Desktop (Linux) | `~/.config/Claude/claude_desktop_config.json` | — |
112
+ | Client | MCP config | Instruction | Hook registration | Skill |
113
+ |---|---|---|---|---|
114
+ | Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` | `~/.claude/skills/memory-agent` |
115
+ | Codex | `$CODEX_HOME/config.toml` | `$CODEX_HOME/AGENTS.md` | `$CODEX_HOME/hooks.json` | `$CODEX_HOME/skills/memory-agent` |
116
+ | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | — | — | — |
117
+ | Claude Desktop (Windows) | `%APPDATA%/Claude/claude_desktop_config.json` | — | — | — |
118
+ | Claude Desktop (Linux) | `~/.config/Claude/claude_desktop_config.json` | — | — | — |
107
119
 
108
120
  ## Development
109
121
 
@@ -113,18 +125,62 @@ npm run lint
113
125
  npm pack --dry-run
114
126
  ```
115
127
 
116
- ## Hooks (Claude Code)
128
+ ## Memory Agent prototype
117
129
 
118
- `forget-connect` installs four harness hooks so memory arrives without being asked:
130
+ The shared skill enforces this zero-price sequence:
131
+
132
+ `catalog_search → product_quote → explicit user approval → grant_create →`
133
+ `agent_consult → receipt_verify → grant_revoke`
134
+
135
+ Catalog text and returned passages are untrusted data. A quote is bound to the
136
+ authenticated principal, personal vault, client, purpose, quota, and expiry;
137
+ the client must show those exact terms before approval. Results are usable only
138
+ after the persisted signed receipt verifies. The distributable Codex and Claude
139
+ plugin manifests live under `assets/plugins/memory-agent`; the CLI installs the
140
+ per-user MCP URL and hooks because credentials and vault identity must never be
141
+ baked into a manifest.
142
+
143
+ ## Hooks (Codex and Claude Code)
144
+
145
+ `forget-connect` installs the lifecycle subset each client currently exposes:
119
146
 
120
147
  - **SessionStart** — injects a context capsule (open tasks, next actions, constraints)
121
148
  - **UserPromptSubmit** — pushes memories relevant to the current turn, with trust lights
122
149
  (green = act on it, yellow = confirm first, red = superseded), and raises a
123
150
  conflict-zone alert when the conversation enters territory with a correction history
124
- - **PreCompact / SessionEnd** — captures the session into the ledger and records
125
- whether offered memories were actually used (the outcome flywheel)
151
+ - **Claude PreCompact / SessionEnd** — captures the session and records whether
152
+ offered memories were actually used
153
+ - **Codex Stop** — captures the completed turn and its outcome
126
154
 
127
155
  Hooks are judgment-free and fail-open: if the Forget server is down they exit
128
156
  silently and never block your session. They need `python3` on PATH.
129
157
  Skip them with `--no-hooks`; `disconnect` always removes them. Foreign hooks
130
158
  registered by other tools are preserved byte-for-byte.
159
+
160
+ ## Capture proxy (macOS)
161
+
162
+ The capture proxy is **off by default** since 0.6.1. It records every Claude
163
+ Code request and response (secret-scrubbed, owner-only files) in
164
+ `~/.forget/proxy/stream/`, so it is wired only when you pass `--proxy`.
165
+ With `--proxy` on macOS, and `forget-proxy` (from
166
+ `pip install 'forget-ai[server]'`) on PATH, connect:
167
+
168
+ - registers launchd services `ai.forget.proxy` (KeepAlive, port 8377) and
169
+ `ai.forget.proxy.watchdog` (60s health checks)
170
+ - sets `env.ANTHROPIC_BASE_URL = http://127.0.0.1:8377` in
171
+ `~/.claude/settings.json`. An existing custom base URL is chained as the
172
+ proxy's `--upstream`, never discarded; a settings file we cannot parse
173
+ skips the wiring entirely with a warning.
174
+
175
+ If the proxy stops answering for three consecutive checks, the watchdog
176
+ removes the override (only the value we wrote) and restores your original
177
+ base URL — losing capture is cheaper than losing Claude. When the proxy
178
+ recovers, the override returns unless you changed the value yourself.
179
+ `disconnect` always unwires and removes the services. It reports the size of
180
+ the capture folder and keeps it unless you pass `--purge-capture` (or answer
181
+ yes when asked). `forget-connect capture purge --before <YYYY-MM-DD>` deletes
182
+ older day files. `doctor` shows whether the proxy is on and how much is stored.
183
+
184
+ If 0.6.0 already wired the proxy on your machine, upgrading does not remove
185
+ it: run `npx forget-connect disconnect` and then connect again without
186
+ `--proxy`.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ """B층 — 작업 상태 자동 캡처 (Stop·PreCompact·SessionEnd 훅).
3
+
4
+ P39 실증(2026-08-15, e1f6a94): 4청크 구조화 상태 {목표, 직전 사건, 미결,
5
+ 다음 손}는 같은 예산의 원문보다 턴-수준 예측 정보를 +62% 더 만든다.
6
+ 이 훅은 그 상태를 사람 규율 없이 자동으로 유지한다 — 인간 작업 기억의
7
+ 기계판: 작게(≤600자), 항상 최신, 세션이 죽어도 살아남는다.
8
+
9
+ 설계 제약과 해법:
10
+ - 훅 타임아웃은 5초, LLM 요약은 10~40초 → 훅은 **분리-발사**: 워커를
11
+ setsid로 떼어 즉시 exit 0. 캡처 실패는 다음 캡처가 덮는다 (fail-open).
12
+ - Stop은 매 턴 발화 → mtime 스로틀(THROTTLE_MIN분). SessionEnd·PreCompact은
13
+ 무조건 캡처 — 노트북 덮음·압축이 바로 B층이 살아남아야 할 순간이다.
14
+ - LLM 불가 시 구조적 폴백 — 마지막 사용자 요구·어시스턴트 보고 머리로
15
+ 4청크를 기계 조립. 낮은 품질의 상태가 무상태보다 낫다.
16
+ - 쓰기는 원자적(tmp+rename), 이력은 append(계기용) — 원장 불변 규율.
17
+
18
+ 저장: ~/.forget/bstate/<project>.json (현재) + <project>.history.jsonl (이력).
19
+ 주입: forget_sessionstart.py가 파일을 직접 읽는다 — 회상 경합·슬롯 경쟁 없음.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ import urllib.request
31
+
32
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
33
+
34
+ from forget_project import project_key_for_path # noqa: E402
35
+
36
+ BSTATE_DIR = os.path.expanduser("~/.forget/bstate")
37
+ THROTTLE_MIN = int(os.environ.get("FORGET_BSTATE_THROTTLE_MIN", "10"))
38
+ DIALOGUE_BUDGET = 6000 # 요약 입력 (P39와 동일)
39
+ STATE_BUDGET = 600 # 4청크 상태 예산 (P39와 동일)
40
+ SUMM_ENGINES = ( # 순서대로 시도 — Spark 27B → 로컬 ollama
41
+ ("http://127.0.0.1:11435/api/chat", "qwen3.6:27b"),
42
+ ("http://127.0.0.1:11434/api/chat", "qwen3.5:9b"),
43
+ )
44
+ CHUNK_KEYS = ("목표", "직전 사건", "미결", "다음 손")
45
+
46
+ NOISE = re.compile(
47
+ r"\[forget 회상|\[forget 캡슐|<command-|<local-command|<bash-input"
48
+ r"|<system-reminder|Caveat:|\[SYSTEM NOTIFICATION|<task-notification"
49
+ r"|SUGGESTION MODE|Recap in under", re.I)
50
+
51
+
52
+ def _text_of(content) -> str:
53
+ if isinstance(content, str):
54
+ return content
55
+ if isinstance(content, list):
56
+ return "\n".join(p.get("text", "") for p in content
57
+ if isinstance(p, dict) and p.get("type") == "text")
58
+ return ""
59
+
60
+
61
+ def extract_dialogue(transcript_path: str, budget: int = DIALOGUE_BUDGET) -> list[tuple[str, str]]:
62
+ """트랜스크립트에서 (역할, 텍스트) 대화 턴 — 꼬리 budget자."""
63
+ turns: list[tuple[str, str]] = []
64
+ try:
65
+ with open(transcript_path, encoding="utf-8", errors="replace") as fh:
66
+ for line in fh:
67
+ try:
68
+ row = json.loads(line)
69
+ except json.JSONDecodeError:
70
+ continue
71
+ typ = row.get("type")
72
+ if typ not in ("user", "assistant"):
73
+ continue
74
+ t = _text_of((row.get("message") or {}).get("content")).strip()
75
+ if not t:
76
+ continue
77
+ if typ == "user":
78
+ head = re.split(r"\n\[forget|\n<system-reminder|\nSessionStart:|<local-command", t)[0].strip()
79
+ if not head or head.startswith("[{") or NOISE.search(head[:200]):
80
+ continue
81
+ turns.append(("user", head[:800]))
82
+ else:
83
+ turns.append(("assistant", t[:2000]))
84
+ except OSError:
85
+ return []
86
+ # 꼬리 예산 절단
87
+ out, total = [], 0
88
+ for role, t in reversed(turns):
89
+ take = t[: max(0, budget - total)]
90
+ if not take:
91
+ break
92
+ out.append((role, take))
93
+ total += len(take)
94
+ return list(reversed(out))
95
+
96
+
97
+ def dialogue_text(turns: list[tuple[str, str]]) -> str:
98
+ return "\n".join(f"[{'정훈' if r == 'user' else '에이전트'}] {t}" for r, t in turns)
99
+
100
+
101
+ def parse_chunks(text: str) -> dict:
102
+ """요약 출력에서 4청크를 관대하게 파싱 — 누락 키는 폴백 몫."""
103
+ chunks = {}
104
+ for key in CHUNK_KEYS:
105
+ m = re.search(rf"{key}\s*[::]\s*(.+)", text)
106
+ if m:
107
+ chunks[key] = m.group(1).strip()[:160]
108
+ return chunks
109
+
110
+
111
+ def llm_state(turns: list[tuple[str, str]]) -> dict:
112
+ prompt = ("다음은 개발자(정훈)와 에이전트의 대화다. 이 시점의 작업 상태를 정확히 4줄로 압축하라.\n"
113
+ "형식(각 줄 120자 이내):\n목표: …\n직전 사건: …\n미결: …\n다음 손: …\n\n"
114
+ + dialogue_text(turns))
115
+ for url, model in SUMM_ENGINES:
116
+ try:
117
+ req = urllib.request.Request(url, data=json.dumps({
118
+ "model": model, "stream": False, "think": False, "keep_alive": "3h",
119
+ "messages": [{"role": "user", "content": prompt}],
120
+ "options": {"temperature": 0.0, "num_predict": 300},
121
+ }).encode(), headers={"Content-Type": "application/json"})
122
+ body = json.loads(urllib.request.urlopen(req, timeout=90).read())
123
+ chunks = parse_chunks(str((body.get("message") or {}).get("content") or ""))
124
+ if len(chunks) >= 3:
125
+ chunks["_engine"] = model
126
+ return chunks
127
+ except Exception:
128
+ continue
129
+ return {}
130
+
131
+
132
+ def structural_state(turns: list[tuple[str, str]]) -> dict:
133
+ """LLM 불가 시 기계 조립 — 낮은 품질의 상태가 무상태보다 낫다."""
134
+ last_user = next((t for r, t in reversed(turns) if r == "user"), "")
135
+ last_asst = next((t for r, t in reversed(turns) if r == "assistant"), "")
136
+ return {
137
+ "목표": last_user[:160] or "(요약 불가)",
138
+ "직전 사건": last_asst[:160] or "(요약 불가)",
139
+ "미결": "(구조적 폴백 — LLM 요약 실패)",
140
+ "다음 손": last_user[:160] or "(요약 불가)",
141
+ "_engine": "structural-fallback",
142
+ }
143
+
144
+
145
+ def write_state(project: str, chunks: dict, transcript_path: str, event: str) -> None:
146
+ os.makedirs(BSTATE_DIR, exist_ok=True)
147
+ state = {
148
+ "project": project,
149
+ "captured_at": time.strftime("%FT%T%z"),
150
+ "event": event,
151
+ "engine": chunks.pop("_engine", "?"),
152
+ "chunks": {k: chunks.get(k, "") for k in CHUNK_KEYS},
153
+ "transcript": os.path.basename(transcript_path),
154
+ }
155
+ path = os.path.join(BSTATE_DIR, f"{project}.json")
156
+ tmp = path + ".tmp"
157
+ with open(tmp, "w", encoding="utf-8") as fh:
158
+ json.dump(state, fh, ensure_ascii=False, indent=1)
159
+ os.replace(tmp, path) # 원자적 — 반쯤 쓰인 상태 파일은 존재하지 않는다
160
+ with open(os.path.join(BSTATE_DIR, f"{project}.history.jsonl"), "a", encoding="utf-8") as fh:
161
+ fh.write(json.dumps(state, ensure_ascii=False) + "\n")
162
+
163
+
164
+ def load_state(project: str) -> dict | None:
165
+ try:
166
+ with open(os.path.join(BSTATE_DIR, f"{project}.json"), encoding="utf-8") as fh:
167
+ return json.load(fh)
168
+ except Exception:
169
+ return None
170
+
171
+
172
+ def render_block(state: dict) -> str:
173
+ """세션 시작 주입용 — 작게, 신선도 명기."""
174
+ try:
175
+ captured = time.mktime(time.strptime(state["captured_at"][:19], "%Y-%m-%dT%H:%M:%S"))
176
+ age_min = int((time.time() - captured) / 60)
177
+ except Exception:
178
+ age_min = -1
179
+ age = (f"{age_min}분 전" if 0 <= age_min < 120 else
180
+ f"{age_min // 60}시간 전" if age_min >= 0 else "시각 불명")
181
+ stale = " ⚠오래됨 — 재검증 후 행동" if age_min > 24 * 60 else ""
182
+ lines = [f"[B층 — 마지막 작업 상태 (캡처 {age}{stale}, {state.get('event', '?')})]"]
183
+ for k in CHUNK_KEYS:
184
+ v = (state.get("chunks") or {}).get(k, "")
185
+ if v:
186
+ lines.append(f"{k}: {v}")
187
+ return "\n".join(lines)[:STATE_BUDGET + 200]
188
+
189
+
190
+ def worker(transcript_path: str, project: str, event: str) -> None:
191
+ turns = extract_dialogue(transcript_path)
192
+ if len(turns) < 2:
193
+ return
194
+ chunks = llm_state(turns) or structural_state(turns)
195
+ write_state(project, chunks, transcript_path, event)
196
+
197
+
198
+ def hook_entry() -> None:
199
+ """훅 본체 — 즉시 반환. 판단은 전부 여기서, 노동은 워커에서."""
200
+ try:
201
+ payload = json.load(sys.stdin)
202
+ except Exception:
203
+ return
204
+ transcript = str(payload.get("transcript_path") or "")
205
+ cwd = str(payload.get("cwd") or os.getcwd())
206
+ event = str(payload.get("hook_event_name") or "?")
207
+ if not transcript or not os.path.exists(transcript):
208
+ return
209
+ project = project_key_for_path(cwd) or "global"
210
+ # Stop은 매 턴 — 스로틀. 종료·압축은 B층이 살아남아야 할 순간이라 무조건.
211
+ if event == "Stop":
212
+ try:
213
+ age = time.time() - os.path.getmtime(os.path.join(BSTATE_DIR, f"{project}.json"))
214
+ if age < THROTTLE_MIN * 60:
215
+ return
216
+ except OSError:
217
+ pass # 상태 없음 — 첫 캡처
218
+ subprocess.Popen(
219
+ [sys.executable, os.path.abspath(__file__), "--worker", transcript, project, event],
220
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
221
+ start_new_session=True, # 훅 프로세스와 운명 분리
222
+ )
223
+
224
+
225
+ if __name__ == "__main__":
226
+ if len(sys.argv) >= 5 and sys.argv[1] == "--worker":
227
+ worker(sys.argv[2], sys.argv[3], sys.argv[4])
228
+ else:
229
+ hook_entry()
@@ -1,16 +1,21 @@
1
1
  #!/usr/bin/env python3
2
- """Session capture + mechanical outcome labeling (PreCompact and SessionEnd).
2
+ """Session capture + mechanical outcome labeling (PreCompact, SessionEnd, Stop).
3
3
 
4
4
  Two judgment-free jobs, each fail-open on its own:
5
5
  1. capture — register the session in forget: pointer + mechanical digest,
6
6
  source_role="tool" (green). The transcript on disk is the lossless ledger;
7
7
  this makes it findable.
8
- 2. outcome — if a SessionStart capsule was offered this session (state file
8
+ 2. outcome — if a SessionStart capsule was offered this session/turn (state file
9
9
  exists), measure mechanically whether the session echoed it and record a
10
10
  context outcome against the capsule's trace. This is the crudest possible
11
11
  label (all-or-none echo, substring match); its job is to start the data
12
12
  flywheel, not to be right — retire it when a learned labeler exists
13
13
  (observer W2) or when per-memory matching lands.
14
+
15
+ At Claude PreCompact a third job is delegated, not owned: forget_digest.flush()
16
+ digests the whole undigested segment before the handoff note is written
17
+ (rolling consolidation ②), so a compaction can only evaporate turns that
18
+ already reached forget.
14
19
  """
15
20
 
16
21
  from __future__ import annotations
@@ -25,7 +30,31 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
25
30
 
26
31
  from forget_project import project_key_for_path, scope_disabled # noqa: E402
27
32
 
33
+ try:
34
+ import forget_digest # noqa: E402 — sibling; packaged installs may not ship it yet
35
+ except Exception:
36
+ forget_digest = None
37
+
28
38
  FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
39
+
40
+
41
+ def _auth_headers() -> dict:
42
+ """Content-Type plus, for a loopback server only, the local Bearer key from
43
+ $FORGET_HOME/api-key (written by `forget install-service`). Never sent off-box."""
44
+ headers = {"Content-Type": "application/json"}
45
+ try:
46
+ from pathlib import Path
47
+ from urllib.parse import urlsplit
48
+ if (urlsplit(FORGET_URL).hostname or "") not in ("127.0.0.1", "localhost", "::1"):
49
+ return headers
50
+ home = Path(os.environ.get("FORGET_HOME") or Path.home() / ".forget")
51
+ key = (home / "api-key").read_text().strip()
52
+ if key:
53
+ headers["Authorization"] = "Bearer " + key
54
+ except Exception:
55
+ pass
56
+ return headers
57
+
29
58
  STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
30
59
  TAIL_BYTES = 400_000
31
60
  SNIPPET_LIMIT = 200
@@ -40,7 +69,7 @@ def _rpc(name: str, arguments: dict, timeout: int = 5) -> None:
40
69
  request = urllib.request.Request(
41
70
  FORGET_URL,
42
71
  data=json.dumps(payload).encode("utf-8"),
43
- headers={"Content-Type": "application/json"},
72
+ headers=_auth_headers(),
44
73
  )
45
74
  urllib.request.urlopen(request, timeout=timeout).read()
46
75
 
@@ -200,13 +229,20 @@ def main() -> None:
200
229
  except Exception:
201
230
  pass
202
231
  if str(hook_input.get("hook_event_name") or "") == "PreCompact":
232
+ # ② final flush BEFORE the handoff note: everything the compaction is
233
+ # about to evaporate reaches forget first (rolling-consolidation ②).
234
+ if forget_digest is not None:
235
+ try:
236
+ forget_digest.flush(transcript_path, session_id)
237
+ except Exception:
238
+ pass
203
239
  try:
204
240
  _handoff(digest, transcript_path, session_id)
205
241
  except Exception:
206
242
  pass
207
- # Outcome is a session-final label: a mid-session compact must not consume
208
- # the offer ledger, or usage after the compact goes unmeasured.
209
- if str(hook_input.get("hook_event_name") or "") == "SessionEnd":
243
+ # Outcome is terminal for Claude (SessionEnd) and per completed Codex turn
244
+ # (Stop). A mid-session compact must not consume the offer ledger.
245
+ if str(hook_input.get("hook_event_name") or "") in {"SessionEnd", "Stop"}:
210
246
  try:
211
247
  _outcome(digest, session_id)
212
248
  except Exception:
@@ -21,13 +21,32 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
21
21
  from forget_project import layered_filter, project_key_for_path, scope_disabled # noqa: E402
22
22
 
23
23
  FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
24
+
25
+
26
+ def _auth_headers() -> dict:
27
+ """Content-Type plus, for a loopback server only, the local Bearer key from
28
+ $FORGET_HOME/api-key (written by `forget install-service`). Never sent off-box."""
29
+ headers = {"Content-Type": "application/json"}
30
+ try:
31
+ from pathlib import Path
32
+ from urllib.parse import urlsplit
33
+ if (urlsplit(FORGET_URL).hostname or "") not in ("127.0.0.1", "localhost", "::1"):
34
+ return headers
35
+ home = Path(os.environ.get("FORGET_HOME") or Path.home() / ".forget")
36
+ key = (home / "api-key").read_text().strip()
37
+ if key:
38
+ headers["Authorization"] = "Bearer " + key
39
+ except Exception:
40
+ pass
41
+ return headers
42
+
24
43
  STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
25
44
  CAPSULE_CHAR_BUDGET = 1_600 # ~400 tokens
26
45
  HANDOFF_MAX_AGE_SECONDS = 48 * 3600 # a stale shift-note is worse than none
27
46
  # The server capability these hooks are built against. The capsule response
28
47
  # carries server_version from 0.3.9 on; older servers silently drop arguments
29
48
  # these hooks send (project layering) — that mismatch must never be silent.
30
- REQUIRED_SERVER_VERSION = "0.3.9"
49
+ REQUIRED_SERVER_VERSION = "0.5.0"
31
50
 
32
51
 
33
52
  def _version_tuple(value: str) -> tuple:
@@ -53,7 +72,8 @@ def _version_notice(server_version: str) -> str:
53
72
  "프로젝트 층 등이 조용히 무시됨. 처방: forget-server upgrade"
54
73
  )
55
74
  try:
56
- with open(os.path.expanduser("~/.forget/update-check.json"), encoding="utf-8") as fh:
75
+ forget_home = os.environ.get("FORGET_HOME") or os.path.expanduser("~/.forget")
76
+ with open(os.path.join(forget_home, "update-check.json"), encoding="utf-8") as fh:
57
77
  latest = str(json.load(fh).get("latest") or "")
58
78
  if latest and _version_tuple(server_version) < _version_tuple(latest):
59
79
  return f"새 버전 {latest} 나옴 (현재 {server_version}) — forget-server upgrade"
@@ -94,6 +114,18 @@ def _consume_handoff() -> str:
94
114
  return "\n".join(lines)
95
115
 
96
116
 
117
+ def _bstate_block(project: str | None) -> str:
118
+ """B층 파일 직독 — 회상 경합·캡슐 슬롯 경쟁 없음 (P39 처분)."""
119
+ try:
120
+ import forget_bstate
121
+ state = forget_bstate.load_state(project or "global")
122
+ if not state:
123
+ return ""
124
+ return forget_bstate.render_block(state)
125
+ except Exception:
126
+ return ""
127
+
128
+
97
129
  def main() -> None:
98
130
  hook_input = json.load(sys.stdin)
99
131
  cwd = str(hook_input.get("cwd") or os.getcwd())
@@ -122,7 +154,7 @@ def main() -> None:
122
154
  request = urllib.request.Request(
123
155
  FORGET_URL,
124
156
  data=json.dumps(payload).encode("utf-8"),
125
- headers={"Content-Type": "application/json"},
157
+ headers=_auth_headers(),
126
158
  )
127
159
  body = json.loads(urllib.request.urlopen(request, timeout=8).read())
128
160
  result = json.loads(body["result"]["content"][0]["text"])
@@ -134,12 +166,17 @@ def main() -> None:
134
166
  capsule = "\n".join(f"- {item}" for item in items[:6] if isinstance(item, str))
135
167
  handoff = _consume_handoff()
136
168
  notice = _version_notice(str(result.get("server_version") or ""))
137
- if not capsule and not handoff:
169
+ bstate = _bstate_block(project)
170
+ if not capsule and not handoff and not bstate:
138
171
  return # low confidence → silence (version nags don't earn a lone injection)
139
172
  shown = capsule[:CAPSULE_CHAR_BUDGET]
140
173
  parts = []
141
174
  if notice:
142
175
  parts.append(f"[forget 버전] {notice}")
176
+ # B층이 캡슐보다 먼저 — 인간이 깨어날 때 "무엇의 한가운데였나"가
177
+ # 백과사전보다 먼저 오듯이 (P39: 구조화 상태가 예측 정보 +62%).
178
+ if bstate:
179
+ parts.append(bstate)
143
180
  if handoff:
144
181
  parts.append(handoff)
145
182
  if shown: