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 +77 -21
- package/assets/hooks/forget_bstate.py +229 -0
- package/assets/hooks/forget_capture.py +42 -6
- package/assets/hooks/forget_sessionstart.py +41 -4
- package/assets/hooks/forget_toolrecall.py +118 -0
- package/assets/hooks/forget_turnrecall.py +664 -45
- 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 +447 -58
- package/src/core.js +28 -9
- package/src/doctor.js +23 -0
- package/src/hooks.js +50 -20
- package/src/proxy.js +526 -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,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
|
|
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` | — |
|
|
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
|
-
##
|
|
128
|
+
## Memory Agent prototype
|
|
117
129
|
|
|
118
|
-
|
|
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
|
|
125
|
-
|
|
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
|
|
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=
|
|
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
|
|
208
|
-
#
|
|
209
|
-
if str(hook_input.get("hook_event_name") or "")
|
|
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.
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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:
|