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 +65 -21
- 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 -44
- 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 +283 -55
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +45 -19
- package/src/proxy.js +431 -0
- package/src/skills.js +95 -0
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
|
-
|
|
13
|
-
`/mcp
|
|
14
|
-
`/mcp/
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
74
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
| Claude Code | `~/.claude.json` | `~/.claude/CLAUDE.md` |
|
|
103
|
-
| Codex | `$CODEX_HOME/config.toml`
|
|
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
|
-
##
|
|
126
|
+
## Memory Agent prototype
|
|
117
127
|
|
|
118
|
-
|
|
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
|
|
125
|
-
|
|
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
|
|
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
|
|
208
|
-
#
|
|
209
|
-
if str(hook_input.get("hook_event_name") or "")
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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()
|