forget-connect 0.5.2 → 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 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,11 @@ 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
+ --no-proxy Do not wire the local capture proxy (macOS only)
87
97
  --no-migrate-enacta Keep matching legacy config and rule blocks
88
98
  --dry-run Show which files would change
89
99
  --timeout <seconds> Doctor network timeout, from 1 to 60
@@ -97,13 +107,13 @@ to the Forget block. Use `--no-migrate-enacta` to keep both.
97
107
 
98
108
  ## Files managed
99
109
 
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` | — |
110
+ | Client | MCP config | Instruction | Hook registration | Skill |
111
+ |---|---|---|---|---|
112
+ | Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` | `~/.claude/settings.json` | `~/.claude/skills/memory-agent` |
113
+ | Codex | `$CODEX_HOME/config.toml` | `$CODEX_HOME/AGENTS.md` | `$CODEX_HOME/hooks.json` | `$CODEX_HOME/skills/memory-agent` |
114
+ | Claude Desktop (macOS) | `~/Library/Application Support/Claude/claude_desktop_config.json` | — | — | — |
115
+ | Claude Desktop (Windows) | `%APPDATA%/Claude/claude_desktop_config.json` | — | — | — |
116
+ | Claude Desktop (Linux) | `~/.config/Claude/claude_desktop_config.json` | — | — | — |
107
117
 
108
118
  ## Development
109
119
 
@@ -113,18 +123,52 @@ npm run lint
113
123
  npm pack --dry-run
114
124
  ```
115
125
 
116
- ## Hooks (Claude Code)
126
+ ## Memory Agent prototype
117
127
 
118
- `forget-connect` installs four harness hooks so memory arrives without being asked:
128
+ The shared skill enforces this zero-price sequence:
129
+
130
+ `catalog_search → product_quote → explicit user approval → grant_create →`
131
+ `agent_consult → receipt_verify → grant_revoke`
132
+
133
+ Catalog text and returned passages are untrusted data. A quote is bound to the
134
+ authenticated principal, personal vault, client, purpose, quota, and expiry;
135
+ the client must show those exact terms before approval. Results are usable only
136
+ after the persisted signed receipt verifies. The distributable Codex and Claude
137
+ plugin manifests live under `assets/plugins/memory-agent`; the CLI installs the
138
+ per-user MCP URL and hooks because credentials and vault identity must never be
139
+ baked into a manifest.
140
+
141
+ ## Hooks (Codex and Claude Code)
142
+
143
+ `forget-connect` installs the lifecycle subset each client currently exposes:
119
144
 
120
145
  - **SessionStart** — injects a context capsule (open tasks, next actions, constraints)
121
146
  - **UserPromptSubmit** — pushes memories relevant to the current turn, with trust lights
122
147
  (green = act on it, yellow = confirm first, red = superseded), and raises a
123
148
  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)
149
+ - **Claude PreCompact / SessionEnd** — captures the session and records whether
150
+ offered memories were actually used
151
+ - **Codex Stop** — captures the completed turn and its outcome
126
152
 
127
153
  Hooks are judgment-free and fail-open: if the Forget server is down they exit
128
154
  silently and never block your session. They need `python3` on PATH.
129
155
  Skip them with `--no-hooks`; `disconnect` always removes them. Foreign hooks
130
156
  registered by other tools are preserved byte-for-byte.
157
+
158
+ ## Capture proxy (macOS)
159
+
160
+ On macOS, connect also wires the zero-config capture proxy when `forget-proxy`
161
+ (from `pip install 'forget-ai[server]'`) is on PATH:
162
+
163
+ - registers launchd services `ai.forget.proxy` (KeepAlive, port 8377) and
164
+ `ai.forget.proxy.watchdog` (60s health checks)
165
+ - sets `env.ANTHROPIC_BASE_URL = http://127.0.0.1:8377` in
166
+ `~/.claude/settings.json`. An existing custom base URL is chained as the
167
+ proxy's `--upstream`, never discarded; a settings file we cannot parse
168
+ skips the wiring entirely with a warning.
169
+
170
+ If the proxy stops answering for three consecutive checks, the watchdog
171
+ removes the override (only the value we wrote) and restores your original
172
+ base URL — losing capture is cheaper than losing Claude. When the proxy
173
+ recovers, the override returns unless you changed the value yourself.
174
+ Skip with `--no-proxy`; `disconnect` always unwires and removes the services.
@@ -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,6 +30,11 @@ 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")
29
39
  STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
30
40
  TAIL_BYTES = 400_000
@@ -200,13 +210,20 @@ def main() -> None:
200
210
  except Exception:
201
211
  pass
202
212
  if str(hook_input.get("hook_event_name") or "") == "PreCompact":
213
+ # ② final flush BEFORE the handoff note: everything the compaction is
214
+ # about to evaporate reaches forget first (rolling-consolidation ②).
215
+ if forget_digest is not None:
216
+ try:
217
+ forget_digest.flush(transcript_path, session_id)
218
+ except Exception:
219
+ pass
203
220
  try:
204
221
  _handoff(digest, transcript_path, session_id)
205
222
  except Exception:
206
223
  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":
224
+ # Outcome is terminal for Claude (SessionEnd) and per completed Codex turn
225
+ # (Stop). A mid-session compact must not consume the offer ledger.
226
+ if str(hook_input.get("hook_event_name") or "") in {"SessionEnd", "Stop"}:
210
227
  try:
211
228
  _outcome(digest, session_id)
212
229
  except Exception:
@@ -27,7 +27,7 @@ HANDOFF_MAX_AGE_SECONDS = 48 * 3600 # a stale shift-note is worse than none
27
27
  # The server capability these hooks are built against. The capsule response
28
28
  # carries server_version from 0.3.9 on; older servers silently drop arguments
29
29
  # these hooks send (project layering) — that mismatch must never be silent.
30
- REQUIRED_SERVER_VERSION = "0.3.9"
30
+ REQUIRED_SERVER_VERSION = "0.5.0"
31
31
 
32
32
 
33
33
  def _version_tuple(value: str) -> tuple:
@@ -53,7 +53,8 @@ def _version_notice(server_version: str) -> str:
53
53
  "프로젝트 층 등이 조용히 무시됨. 처방: forget-server upgrade"
54
54
  )
55
55
  try:
56
- with open(os.path.expanduser("~/.forget/update-check.json"), encoding="utf-8") as fh:
56
+ forget_home = os.environ.get("FORGET_HOME") or os.path.expanduser("~/.forget")
57
+ with open(os.path.join(forget_home, "update-check.json"), encoding="utf-8") as fh:
57
58
  latest = str(json.load(fh).get("latest") or "")
58
59
  if latest and _version_tuple(server_version) < _version_tuple(latest):
59
60
  return f"새 버전 {latest} 나옴 (현재 {server_version}) — forget-server upgrade"
@@ -94,6 +95,18 @@ def _consume_handoff() -> str:
94
95
  return "\n".join(lines)
95
96
 
96
97
 
98
+ def _bstate_block(project: str | None) -> str:
99
+ """B층 파일 직독 — 회상 경합·캡슐 슬롯 경쟁 없음 (P39 처분)."""
100
+ try:
101
+ import forget_bstate
102
+ state = forget_bstate.load_state(project or "global")
103
+ if not state:
104
+ return ""
105
+ return forget_bstate.render_block(state)
106
+ except Exception:
107
+ return ""
108
+
109
+
97
110
  def main() -> None:
98
111
  hook_input = json.load(sys.stdin)
99
112
  cwd = str(hook_input.get("cwd") or os.getcwd())
@@ -134,12 +147,17 @@ def main() -> None:
134
147
  capsule = "\n".join(f"- {item}" for item in items[:6] if isinstance(item, str))
135
148
  handoff = _consume_handoff()
136
149
  notice = _version_notice(str(result.get("server_version") or ""))
137
- if not capsule and not handoff:
150
+ bstate = _bstate_block(project)
151
+ if not capsule and not handoff and not bstate:
138
152
  return # low confidence → silence (version nags don't earn a lone injection)
139
153
  shown = capsule[:CAPSULE_CHAR_BUDGET]
140
154
  parts = []
141
155
  if notice:
142
156
  parts.append(f"[forget 버전] {notice}")
157
+ # B층이 캡슐보다 먼저 — 인간이 깨어날 때 "무엇의 한가운데였나"가
158
+ # 백과사전보다 먼저 오듯이 (P39: 구조화 상태가 예측 정보 +62%).
159
+ if bstate:
160
+ parts.append(bstate)
143
161
  if handoff:
144
162
  parts.append(handoff)
145
163
  if shown:
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env python3
2
+ """forget_toolrecall — 진행-중 회상 v0 (PostToolUse, Bash 전용).
3
+
4
+ 설계 정본: research/proxy-native-redesign.md §3.6 지평 1. 트리거는 사용자
5
+ 프롬프트가 아니라 진행 중인 사고의 내용물 — 도구가 **실패**했을 때 그 에러
6
+ 서명으로 러닝북을 소환해 결과에 동승시킨다. 원조 사례: 2026-08-12 새벽 pkill
7
+ 자기-매칭 침묵 자살 3연속 — 첫 실패 때 러닝북이 도착했다면 한 번으로 끝났다.
8
+
9
+ v0 규율:
10
+ - Bash 결과에만 반응 (서명 전부 Bash-급 함정 + Read/Grep가 러닝북 파일을
11
+ 읽을 때의 자기-매칭 차단 — pkill 러닝북이 가르친 바로 그 함정의 훅판).
12
+ - 정적 패턴만, LLM·서버 호출 없음 (ms급 — 지연 예산 논쟁 원천 차단).
13
+ - 실패 판정이 애매한 페이로드는 발동하지 않되 **형상을 원장에 남긴다**
14
+ (계기-우선 — 내일 실측으로 보정). 원장엔 내용 무기록, 형상 메타만.
15
+ - 세션당 같은 러닝북 1회. 텍스트 4000자 초과는 실패 메시지가 아니라
16
+ 산출물로 간주하고 침묵.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import re
23
+ import sys
24
+ import time
25
+ from pathlib import Path
26
+
27
+ RUNBOOKS = Path(os.environ.get("FORGET_RUNBOOKS", str(Path.home() / ".forget/runbooks.md")))
28
+ STATE_DIR = Path.home() / ".forget/hooks/state"
29
+ LEDGER = STATE_DIR / "toolrecall_ledger.jsonl"
30
+ MAX_BODY_CHARS = 700
31
+ MAX_ERR_CHARS = 4000
32
+
33
+ SIGNATURES: list[tuple[str, str]] = [
34
+ (r"contains multiple operations|requires approval", "복합 Bash 명령 승인 회피"),
35
+ (r"brace with quote character", "heredoc·따옴표 중괄호 차단"),
36
+ (r"rm in '.*내-프롬프트를", "워크트리 한글 경로 rm 차단"),
37
+ (r"timed out after|output discarded", "타임아웃 계층 산수"),
38
+ ]
39
+
40
+
41
+ def _note(kind: str, **fields) -> None:
42
+ try:
43
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
44
+ row = {"ts": time.strftime("%F %T"), "kind": kind, **fields}
45
+ with LEDGER.open("a") as fh:
46
+ fh.write(json.dumps(row, ensure_ascii=False) + "\n")
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ def _response_text(response) -> tuple[str, bool | None]:
52
+ """(텍스트, 실패 여부) — 실패를 모르면 None."""
53
+ if isinstance(response, str):
54
+ return response, None
55
+ if isinstance(response, dict):
56
+ err = response.get("is_error", response.get("isError"))
57
+ parts = [str(response.get(k) or "") for k in ("error", "stderr", "stdout", "content", "message")]
58
+ text = "\n".join(p for p in parts if p) or json.dumps(response, ensure_ascii=False)
59
+ if err is None and response.get("stderr"):
60
+ err = None # stderr 존재만으로 실패 단정 금지 (경고 흔함)
61
+ return text, bool(err) if err is not None else None
62
+ return "", None
63
+
64
+
65
+ def _load_runbook(title: str) -> str | None:
66
+ try:
67
+ text = RUNBOOKS.read_text(encoding="utf-8")
68
+ except Exception:
69
+ return None
70
+ match = re.search(rf"## runbook: {re.escape(title)}.*?(?=\n## |\Z)", text, re.S)
71
+ return match.group(0)[:MAX_BODY_CHARS] if match else None
72
+
73
+
74
+ def main() -> None:
75
+ try:
76
+ payload = json.load(sys.stdin)
77
+ except Exception:
78
+ return
79
+ if str(payload.get("tool_name") or "") != "Bash":
80
+ return
81
+ text, failed = _response_text(payload.get("tool_response"))
82
+ if not text or len(text) > MAX_ERR_CHARS:
83
+ return
84
+ hit = next((title for pattern, title in SIGNATURES if re.search(pattern, text, re.I)), None)
85
+ if hit is None:
86
+ return
87
+ if failed is False:
88
+ _note("sig_in_success", sig=hit, chars=len(text))
89
+ return
90
+ if failed is None:
91
+ # 형상 미상 — 발동 대신 관측 (내일 이 원장으로 실패 판정을 보정한다)
92
+ _note("sig_shape_unknown", sig=hit, chars=len(text),
93
+ keys=sorted(payload.get("tool_response", {}).keys()) if isinstance(payload.get("tool_response"), dict) else "str")
94
+ return
95
+
96
+ session_id = str(payload.get("session_id") or "nosession")
97
+ seen_path = STATE_DIR / f"toolrecall_{session_id}.json"
98
+ try:
99
+ seen = set(json.loads(seen_path.read_text()))
100
+ except Exception:
101
+ seen = set()
102
+ if hit in seen:
103
+ return
104
+ body = _load_runbook(hit)
105
+ if not body:
106
+ _note("runbook_missing", sig=hit)
107
+ return
108
+ seen.add(hit)
109
+ try:
110
+ seen_path.write_text(json.dumps(sorted(seen), ensure_ascii=False))
111
+ except Exception:
112
+ pass
113
+ _note("injected", sig=hit)
114
+ print(f"[forget 러닝북 — 이 실패 서명엔 검증된 절차가 있음]\n{body}")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()