forget-connect 0.1.0 → 0.3.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
@@ -1,14 +1,16 @@
1
1
  # forget-connect
2
2
 
3
- Connect Claude Code, Codex, and Claude Desktop to [Forget](https://multi-turn.ai)
4
- without overwriting the MCP servers or global instructions you already have.
3
+ Connect Claude Code, Codex, and Claude Desktop to a local
4
+ [Forget](https://github.com/junghunkim/forget) memory server without
5
+ overwriting the MCP servers or global instructions you already have.
5
6
 
6
7
  ```bash
7
- npx forget-connect --user-id <memory-user> --app-id <project>
8
+ npx forget-connect
8
9
  ```
9
10
 
10
- The guided flow detects installed clients, asks for a hosted Forget API key
11
- with hidden input, and then:
11
+ By default this targets the local server at `http://localhost:8000/mcp` —
12
+ your memories stay on your machine. The guided flow detects installed
13
+ clients and then:
12
14
 
13
15
  - merges a `forget` entry into each selected MCP config;
14
16
  - installs a marked memory rule in Claude Code's `CLAUDE.md` and Codex's
@@ -20,36 +22,33 @@ with hidden input, and then:
20
22
  Other MCP servers and text outside the marked rule block are preserved. Invalid
21
23
  JSON or a damaged marker block aborts before any selected client is changed.
22
24
 
23
- The two scope values are part of the connection, not search-time hints. They
24
- produce `/mcp/<app-id>/http/<user-id>` so a new client resumes the intended
25
- project instead of the legacy default scope. For hosted continuity, always set
26
- both. A bare hosted connection remains available for compatibility but prints a
27
- warning, and `doctor` will not call it continuity-ready.
28
-
29
25
  ## Non-interactive use
30
26
 
31
- Pass the key through the environment, not a command-line flag (command-line
32
- arguments can be visible to other local processes):
33
-
34
27
  ```bash
35
- FORGET_API_KEY=... npx forget-connect \
36
- --user-id my-user \
37
- --app-id my-project \
38
- --client claude-code,codex
28
+ npx forget-connect --client claude-code,codex --yes
39
29
  ```
40
30
 
41
- `FORGET_USER_ID` and `FORGET_APP_ID` are equivalent to the two scope flags.
31
+ Use `FORGET_MCP_URL` or `--url` for a server on another port or host. A Bearer
32
+ token (`FORGET_API_KEY`) is only sent over HTTPS; over loopback HTTP the CLI
33
+ connects without a token and says so.
42
34
 
43
- For a local, unauthenticated Forget server:
35
+ ## Hosted service (legacy)
36
+
37
+ The managed service remains available behind an explicit flag while it is
38
+ phased out in favor of local-first:
44
39
 
45
40
  ```bash
46
- npx forget-connect \
47
- --url http://localhost:8000/mcp \
48
- --no-auth \
49
- --client all
41
+ FORGET_API_KEY=... npx forget-connect --hosted \
42
+ --user-id <memory-user> --app-id <project>
50
43
  ```
51
44
 
52
- Use `FORGET_MCP_URL` instead of `--url` if preferred.
45
+ Pass the key through the environment, not a command-line flag (command-line
46
+ arguments can be visible to other local processes). `FORGET_USER_ID` and
47
+ `FORGET_APP_ID` are equivalent to the two scope flags. The scope values are
48
+ part of the connection, not search-time hints: they produce
49
+ `/mcp/<app-id>/http/<user-id>` so a new client resumes the intended project.
50
+ A bare hosted connection prints a warning, and `doctor` will not call it
51
+ continuity-ready.
53
52
 
54
53
  ## Inspect or remove
55
54
 
@@ -62,11 +61,10 @@ npx forget-connect disconnect
62
61
  `doctor` is non-mutating. It checks that each selected client has the generated
63
62
  transport shape, expected URL and authorization, plus the current full
64
63
  instruction block (not only its markers). Only after those local checks pass
65
- does it verify the scoped endpoint identity and perform MCP initialize and
66
- tools/list against that URL. It fails when hosted scope is absent or mismatched,
67
- or when the continuity tools for search, write, context, outcome, and task state
68
- are missing. Add `--json` for automation or `--timeout 30` on a slow network.
69
- No authorization value or memory content is printed.
64
+ does it perform MCP initialize and tools/list against that URL. For hosted
65
+ connections it also verifies the scoped endpoint identity. Add `--json` for
66
+ automation or `--timeout 30` on a slow network. No authorization value or
67
+ memory content is printed.
70
68
 
71
69
  `disconnect` removes only the `forget` MCP entry and the marked Forget rules
72
70
  block. It does not restore backups or remove unrelated config.
@@ -75,7 +73,8 @@ block. It does not restore backups or remove unrelated config.
75
73
 
76
74
  ```text
77
75
  --client <ids> claude-code,codex,claude-desktop,all
78
- --url <url> MCP URL (default: https://api.multi-turn.ai/mcp)
76
+ --url <url> MCP URL (default: http://localhost:8000/mcp)
77
+ --hosted Use the managed Forget service (legacy)
79
78
  --user-id <id> Memory user scope; pair with --app-id
80
79
  --app-id <id> Project/app scope; pair with --user-id
81
80
  --no-auth Do not install a Bearer token
@@ -87,9 +86,9 @@ block. It does not restore backups or remove unrelated config.
87
86
  -y, --yes Use detected clients, or all if none are detected
88
87
  ```
89
88
 
90
- By default, a legacy `enacta` entry is removed only when it points to the same
91
- MCP URL being installed. Its marked instruction block is upgraded to the
92
- Forget block. Use `--no-migrate-enacta` to keep both.
89
+ By default, a legacy `enacta` entry is removed when it points to the URL being
90
+ installed or to the hosted service. Its marked instruction block is upgraded
91
+ to the Forget block. Use `--no-migrate-enacta` to keep both.
93
92
 
94
93
  ## Files managed
95
94
 
@@ -108,3 +107,19 @@ npm test
108
107
  npm run lint
109
108
  npm pack --dry-run
110
109
  ```
110
+
111
+ ## Hooks (Claude Code)
112
+
113
+ `forget-connect` installs four harness hooks so memory arrives without being asked:
114
+
115
+ - **SessionStart** — injects a context capsule (open tasks, next actions, constraints)
116
+ - **UserPromptSubmit** — pushes memories relevant to the current turn, with trust lights
117
+ (green = act on it, yellow = confirm first, red = superseded), and raises a
118
+ conflict-zone alert when the conversation enters territory with a correction history
119
+ - **PreCompact / SessionEnd** — captures the session into the ledger and records
120
+ whether offered memories were actually used (the outcome flywheel)
121
+
122
+ Hooks are judgment-free and fail-open: if the Forget server is down they exit
123
+ silently and never block your session. They need `python3` on PATH.
124
+ Skip them with `--no-hooks`; `disconnect` always removes them. Foreign hooks
125
+ registered by other tools are preserved byte-for-byte.
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env python3
2
+ """Session capture + mechanical outcome labeling (PreCompact and SessionEnd).
3
+
4
+ Two judgment-free jobs, each fail-open on its own:
5
+ 1. capture — register the session in forget: pointer + mechanical digest,
6
+ source_role="tool" (green). The transcript on disk is the lossless ledger;
7
+ this makes it findable.
8
+ 2. outcome — if a SessionStart capsule was offered this session (state file
9
+ exists), measure mechanically whether the session echoed it and record a
10
+ context outcome against the capsule's trace. This is the crudest possible
11
+ label (all-or-none echo, substring match); its job is to start the data
12
+ flywheel, not to be right — retire it when a learned labeler exists
13
+ (observer W2) or when per-memory matching lands.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sys
22
+ import urllib.request
23
+
24
+ FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
25
+ STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
26
+ TAIL_BYTES = 400_000
27
+ SNIPPET_LIMIT = 200
28
+ USER_SNIPPETS = 3
29
+ ASSISTANT_BLOB_LIMIT = 300_000
30
+ PROBE_MIN_LEN = 12 # sessionstart의 장부 프로브 최소 길이와 일치해야 함
31
+ PROBE_MAX_LEN = 80
32
+
33
+
34
+ def _rpc(name: str, arguments: dict, timeout: int = 5) -> None:
35
+ payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
36
+ request = urllib.request.Request(
37
+ FORGET_URL,
38
+ data=json.dumps(payload).encode("utf-8"),
39
+ headers={"Content-Type": "application/json"},
40
+ )
41
+ urllib.request.urlopen(request, timeout=timeout).read()
42
+
43
+
44
+ def _text_of(content) -> str:
45
+ if isinstance(content, str):
46
+ return content
47
+ if isinstance(content, list):
48
+ parts = [block.get("text", "") for block in content if isinstance(block, dict) and block.get("type") == "text"]
49
+ return " ".join(part for part in parts if part)
50
+ return ""
51
+
52
+
53
+ def _normalize(text: str) -> str:
54
+ return re.sub(r"\s+", " ", text).strip()
55
+
56
+
57
+ def _digest(transcript_path: str) -> dict:
58
+ with open(transcript_path, "rb") as fh:
59
+ fh.seek(0, os.SEEK_END)
60
+ size = fh.tell()
61
+ fh.seek(max(0, size - TAIL_BYTES))
62
+ tail = fh.read().decode("utf-8", errors="replace")
63
+ lines = [line for line in tail.splitlines() if line.strip()]
64
+ if size > TAIL_BYTES and lines:
65
+ lines = lines[1:] # first line of a mid-file seek is usually truncated
66
+ user_snippets: list[str] = []
67
+ assistant_parts: list[str] = []
68
+ assistant_len = 0
69
+ counts = {"user": 0, "assistant": 0}
70
+ first_ts = last_ts = ""
71
+ for line in lines:
72
+ try:
73
+ entry = json.loads(line)
74
+ except ValueError:
75
+ continue
76
+ kind = entry.get("type")
77
+ if kind not in counts:
78
+ continue
79
+ counts[kind] += 1
80
+ ts = str(entry.get("timestamp") or "")
81
+ if ts:
82
+ first_ts = first_ts or ts
83
+ last_ts = ts
84
+ text = _text_of((entry.get("message") or {}).get("content")).strip()
85
+ if kind == "user":
86
+ # skill expansions and system payloads arrive as user-role messages;
87
+ # real typed utterances are short — length is the cheapest tell
88
+ if text and len(text) <= 600 and not text.startswith(("<local-command", "<command-name", "[SYSTEM", "#")):
89
+ user_snippets.append(text[:SNIPPET_LIMIT])
90
+ elif text and assistant_len < ASSISTANT_BLOB_LIMIT:
91
+ assistant_parts.append(text)
92
+ assistant_len += len(text)
93
+ return {
94
+ "counts": counts,
95
+ "first_ts": first_ts,
96
+ "last_ts": last_ts,
97
+ "user_snippets": user_snippets[-USER_SNIPPETS:],
98
+ "assistant_blob": _normalize(" ".join(assistant_parts)),
99
+ "tail_only": size > TAIL_BYTES,
100
+ }
101
+
102
+
103
+ def _capture(hook_input: dict, digest: dict, transcript_path: str, session_id: str) -> None:
104
+ event = str(hook_input.get("hook_event_name") or "PreCompact")
105
+ label = str(hook_input.get("trigger") or hook_input.get("reason") or "auto")
106
+ snippets = " / ".join(digest["user_snippets"]) or "(없음)"
107
+ text = (
108
+ f"세션 캡처 ({event}/{label}): 세션 {session_id} — "
109
+ f"user {digest['counts']['user']}·assistant {digest['counts']['assistant']} 메시지"
110
+ f"{' (꼬리 표본)' if digest['tail_only'] else ''}, {digest['first_ts']}~{digest['last_ts']}. "
111
+ f"최근 사용자 발화: {snippets}. 전문: {transcript_path}"
112
+ )
113
+ _rpc(
114
+ "add_memory",
115
+ {
116
+ "text": text,
117
+ "infer": False,
118
+ "source_role": "tool",
119
+ "metadata": {
120
+ "hook": event,
121
+ "session_id": session_id,
122
+ "transcript_path": transcript_path,
123
+ "trigger": label,
124
+ },
125
+ },
126
+ )
127
+
128
+
129
+ def _outcome(digest: dict, session_id: str) -> None:
130
+ state_path = os.path.join(STATE_DIR, f"{session_id}.json")
131
+ if not os.path.exists(state_path):
132
+ return
133
+ with open(state_path, encoding="utf-8") as fh:
134
+ state = json.load(fh)
135
+ trace_id = str(state.get("trace_id") or "")
136
+ if not trace_id:
137
+ os.replace(state_path, state_path + ".done")
138
+ return
139
+ blob = digest["assistant_blob"]
140
+ echoed = []
141
+ for line in state.get("capsule_lines") or []:
142
+ probe = _normalize(str(line))[:PROBE_MAX_LEN]
143
+ if len(probe) >= PROBE_MIN_LEN and probe in blob:
144
+ echoed.append(probe[:40])
145
+ memory_ids = state.get("memory_ids") or []
146
+ _rpc(
147
+ "record_context_outcome",
148
+ {
149
+ "trace_id": trace_id,
150
+ "used_memory_ids": memory_ids if echoed else [],
151
+ "missing_memory_ids": [],
152
+ "harmful_memory_ids": [],
153
+ "first_action_productive": bool(echoed),
154
+ "user_correction_required": False,
155
+ "metadata": {
156
+ "labeler": "mechanical-echo-v1",
157
+ "session_id": session_id,
158
+ "echo_count": len(echoed),
159
+ "echoed_probes": echoed[:5],
160
+ "granularity": "all-or-none",
161
+ },
162
+ },
163
+ timeout=6,
164
+ )
165
+ os.replace(state_path, state_path + ".done")
166
+
167
+
168
+ def main() -> None:
169
+ hook_input = json.load(sys.stdin)
170
+ transcript_path = str(hook_input.get("transcript_path") or "")
171
+ session_id = str(hook_input.get("session_id") or "unknown")
172
+ if not transcript_path or not os.path.exists(transcript_path):
173
+ return
174
+ digest = _digest(transcript_path)
175
+ try:
176
+ _capture(hook_input, digest, transcript_path, session_id)
177
+ except Exception:
178
+ pass
179
+ # Outcome is a session-final label: a mid-session compact must not consume
180
+ # the offer ledger, or usage after the compact goes unmeasured.
181
+ if str(hook_input.get("hook_event_name") or "") == "SessionEnd":
182
+ try:
183
+ _outcome(digest, session_id)
184
+ except Exception:
185
+ pass
186
+
187
+
188
+ if __name__ == "__main__":
189
+ try:
190
+ main()
191
+ except Exception:
192
+ pass
193
+ sys.exit(0)
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook: inject a small assembled context capsule from forget.
3
+
4
+ Design contract (research/ideas/observer-consolidation.md §4.5):
5
+ - the capsule is an OFFER, not a command — the main-thread agent judges
6
+ whether to use it; low confidence → silence
7
+ - strict token budget: the main context window is the scarcest resource
8
+ - fail-open: any error prints nothing and exits 0
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import urllib.request
17
+
18
+ FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
19
+ STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
20
+ CAPSULE_CHAR_BUDGET = 1_600 # ~400 tokens
21
+
22
+
23
+ def main() -> None:
24
+ hook_input = json.load(sys.stdin)
25
+ cwd = str(hook_input.get("cwd") or os.getcwd())
26
+ source = str(hook_input.get("source") or "startup")
27
+ payload = {
28
+ "jsonrpc": "2.0",
29
+ "id": 1,
30
+ "method": "tools/call",
31
+ "params": {
32
+ "name": "prepare_context_autopilot",
33
+ "arguments": {
34
+ "query": f"session {source} in {cwd} — active tasks, open loops, recent decisions",
35
+ "include_debug": False,
36
+ },
37
+ },
38
+ }
39
+ request = urllib.request.Request(
40
+ FORGET_URL,
41
+ data=json.dumps(payload).encode("utf-8"),
42
+ headers={"Content-Type": "application/json"},
43
+ )
44
+ body = json.loads(urllib.request.urlopen(request, timeout=8).read())
45
+ result = json.loads(body["result"]["content"][0]["text"])
46
+ capsule = str(result.get("capsule_text") or "").strip()
47
+ if not capsule:
48
+ use_now = result.get("use_now") or {}
49
+ items = use_now.get("items") if isinstance(use_now, dict) else None
50
+ if isinstance(items, list) and items:
51
+ capsule = "\n".join(f"- {item}" for item in items[:6] if isinstance(item, str))
52
+ if not capsule:
53
+ return # low confidence → silence
54
+ shown = capsule[:CAPSULE_CHAR_BUDGET]
55
+ print(
56
+ "[forget 캡슐 — 제안이며 명령이 아님. 채택/기각은 네 판단. "
57
+ "라벨 없는 항목은 노랑(행동 전 확인) 취급]\n" + shown
58
+ )
59
+ # Offer ledger: record what was offered so the capture hook can measure,
60
+ # at session end, whether the offer was actually used (outcome flywheel).
61
+ session_id = str(hook_input.get("session_id") or "").strip()
62
+ trace_id = str(result.get("context_trace_id") or "").strip()
63
+ if session_id and trace_id:
64
+ os.makedirs(STATE_DIR, exist_ok=True)
65
+ state = {
66
+ "trace_id": trace_id,
67
+ "memory_ids": (result.get("evidence") or {}).get("memory_ids", []),
68
+ "capsule_lines": [line.strip() for line in shown.splitlines() if len(line.strip()) >= 12][:12],
69
+ }
70
+ with open(os.path.join(STATE_DIR, f"{session_id}.json"), "w", encoding="utf-8") as fh:
71
+ json.dump(state, fh, ensure_ascii=False)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ try:
76
+ main()
77
+ except Exception:
78
+ pass
79
+ sys.exit(0)
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook: push-recall memories relevant to THIS turn.
3
+
4
+ The session-start capsule covers "where were we"; this covers "wait, we know
5
+ something about that" mid-session — the pull-only problem's other half. The
6
+ main context window is the scarcest resource, so the gate is strict:
7
+
8
+ - silence unless the top hit clears a relevance threshold
9
+ - a memory is offered at most once per session (repeat-suppression ledger)
10
+ - memories already offered in the session-start capsule are not re-offered
11
+ - the injection is an OFFER with trust lights; adoption stays the
12
+ main-thread agent's judgment
13
+ - fail-open, hard 5s timeout: forget being down must never slow a turn
14
+
15
+ Known gap (deliberate): these injections are not yet linked to the outcome
16
+ flywheel (search_memories creates no context trace). Wire when per-turn
17
+ traces land server-side.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ import urllib.request
26
+
27
+ FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
28
+ STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
29
+ SCORE_THRESHOLD = float(os.environ.get("FORGET_TURNRECALL_THRESHOLD", "0.45"))
30
+ # Conflict-zone members get a looser gate: missing a plain recall costs
31
+ # silence, but missing a corrected-zone alert is how incident #1 happened —
32
+ # and being part of a supersede pair is itself strong prior evidence the
33
+ # zone matters. Recalibrate both when the embedder switches to e5.
34
+ CONFLICT_THRESHOLD = float(os.environ.get("FORGET_CONFLICT_THRESHOLD", "0.32"))
35
+ MAX_RECALLS = 3
36
+ MEMORY_CHAR_LIMIT = 160
37
+ MIN_PROMPT_LEN = 8
38
+
39
+
40
+ def _rpc(name: str, arguments: dict, timeout: int = 5) -> dict:
41
+ payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": arguments}}
42
+ request = urllib.request.Request(
43
+ FORGET_URL,
44
+ data=json.dumps(payload).encode("utf-8"),
45
+ headers={"Content-Type": "application/json"},
46
+ )
47
+ body = json.loads(urllib.request.urlopen(request, timeout=timeout).read())
48
+ return json.loads(body["result"]["content"][0]["text"])
49
+
50
+
51
+ def _conflict_pair(item: dict) -> tuple[str, str] | None:
52
+ """(old_id, new_id) if this memory sits in a supersede pair, else None.
53
+
54
+ Retro-scoring incident #1 (2026-07-23) showed direction is not measurable
55
+ in embedding space — old and corrected versions sit nearly parallel
56
+ (negation-blindness). But *territory* separates perfectly (100% vs 3%
57
+ false positives). So geometry only detects entry into a corrected zone;
58
+ judging which version to act on is delegated to the reading LLM, which
59
+ handles negation natively.
60
+ """
61
+ metadata = item.get("metadata") or {}
62
+ memory_id = str(item.get("id") or "")
63
+ successor = str(metadata.get("superseded_by") or "")
64
+ if successor:
65
+ return (memory_id, successor)
66
+ supersedes = metadata.get("supersedes")
67
+ if isinstance(supersedes, list) and supersedes:
68
+ return (str(supersedes[0]), memory_id)
69
+ return None
70
+
71
+
72
+ def _seen_ids(session_id: str) -> tuple[set[str], str]:
73
+ seen: set[str] = set()
74
+ offer_path = os.path.join(STATE_DIR, f"{session_id}.json")
75
+ for candidate in (offer_path, offer_path + ".done"):
76
+ if os.path.exists(candidate):
77
+ try:
78
+ with open(candidate, encoding="utf-8") as fh:
79
+ seen.update(json.load(fh).get("memory_ids") or [])
80
+ except Exception:
81
+ pass
82
+ turns_path = os.path.join(STATE_DIR, f"{session_id}.turns.json")
83
+ if os.path.exists(turns_path):
84
+ try:
85
+ with open(turns_path, encoding="utf-8") as fh:
86
+ seen.update(json.load(fh).get("injected") or [])
87
+ except Exception:
88
+ pass
89
+ return seen, turns_path
90
+
91
+
92
+ def _remember_injected(turns_path: str, injected: list[str]) -> None:
93
+ existing: list[str] = []
94
+ if os.path.exists(turns_path):
95
+ try:
96
+ with open(turns_path, encoding="utf-8") as fh:
97
+ existing = json.load(fh).get("injected") or []
98
+ except Exception:
99
+ pass
100
+ os.makedirs(STATE_DIR, exist_ok=True)
101
+ with open(turns_path, "w", encoding="utf-8") as fh:
102
+ json.dump({"injected": existing + injected}, fh, ensure_ascii=False)
103
+
104
+
105
+ def main() -> None:
106
+ hook_input = json.load(sys.stdin)
107
+ prompt = str(hook_input.get("prompt") or "").strip()
108
+ session_id = str(hook_input.get("session_id") or "").strip()
109
+ if len(prompt) < MIN_PROMPT_LEN or prompt.startswith(("/", "!", "<", "#")):
110
+ return
111
+ seen, turns_path = _seen_ids(session_id) if session_id else (set(), "")
112
+ result = _rpc("search_memories", {"query": prompt[:300], "top_k": MAX_RECALLS + 2})
113
+ picks = []
114
+ conflict_pairs: dict[tuple[str, str], None] = {}
115
+ for item in result.get("results") or []:
116
+ score = float(item.get("score") or 0.0)
117
+ memory_id = str(item.get("id") or "")
118
+ if not memory_id or memory_id in seen:
119
+ continue
120
+ if (item.get("metadata") or {}).get("hook"):
121
+ continue # session-capture pointers are for rehydration, not recall
122
+ pair = _conflict_pair(item)
123
+ if pair:
124
+ if score >= CONFLICT_THRESHOLD:
125
+ conflict_pairs.setdefault(pair)
126
+ continue # presented as a pair below, not as a plain recall
127
+ if score < SCORE_THRESHOLD:
128
+ continue
129
+ trust = item.get("trust") or {}
130
+ light = str(trust.get("light") or "yellow")
131
+ picks.append((memory_id, light, str(item.get("memory") or "")[:MEMORY_CHAR_LIMIT]))
132
+ if len(picks) >= MAX_RECALLS:
133
+ break
134
+
135
+ conflicts = []
136
+ for old_id, new_id in list(conflict_pairs)[:2]:
137
+ if old_id in seen and new_id in seen:
138
+ continue
139
+ try:
140
+ old = _rpc("get_memory", {"memory_id": old_id})
141
+ new = _rpc("get_memory", {"memory_id": new_id})
142
+ except Exception:
143
+ continue
144
+ conflicts.append((old_id, new_id, str(old.get("memory") or "")[:MEMORY_CHAR_LIMIT], str(new.get("memory") or "")[:MEMORY_CHAR_LIMIT]))
145
+
146
+ if not picks and not conflicts:
147
+ return # below threshold or nothing new → silence
148
+ lines = []
149
+ if conflicts:
150
+ lines.append("[forget 충돌지대 — 이 주제엔 정정 이력이 있음. 현재본 기준으로 행동하고, 구본 위에서 행동하지 말 것]")
151
+ for _, _, old_text, new_text in conflicts:
152
+ lines.append(f"- (현재) {new_text}")
153
+ lines.append(f"- (red/구본) {old_text}")
154
+ if picks:
155
+ lines.append("[forget 회상 — 이 턴과 관련된 기억 제안. green=행동 근거 OK, yellow=행동 전 확인, red=참고만]")
156
+ lines += [f"- ({light}) {memory}" for _, light, memory in picks]
157
+ print("\n".join(lines))
158
+ if session_id and turns_path:
159
+ injected = [memory_id for memory_id, _, _ in picks]
160
+ ledger_picks = list(picks)
161
+ for old_id, new_id, old_text, new_text in conflicts:
162
+ injected += [old_id, new_id]
163
+ ledger_picks.append((new_id, "green", new_text)) # 메아리 측정은 현재본 기준
164
+ _remember_injected(turns_path, injected)
165
+ _extend_offer_ledger(session_id, ledger_picks)
166
+
167
+
168
+ def _extend_offer_ledger(session_id: str, picks: list) -> None:
169
+ """Feed turn recalls into the outcome flywheel: append their probes and
170
+ ids to the session's offer ledger so the capture hook measures them too.
171
+ (Discovered gap 07-22: a session answered purely from a turn recall and
172
+ the capsule-only labeler scored it "not used".)"""
173
+ ledger_path = os.path.join(STATE_DIR, f"{session_id}.json")
174
+ if not os.path.exists(ledger_path):
175
+ return # no capsule trace this session — nothing to record against
176
+ try:
177
+ with open(ledger_path, encoding="utf-8") as fh:
178
+ state = json.load(fh)
179
+ state["memory_ids"] = list({*(state.get("memory_ids") or []), *(memory_id for memory_id, _, _ in picks)})
180
+ state["capsule_lines"] = (state.get("capsule_lines") or []) + [memory[:80] for _, _, memory in picks]
181
+ with open(ledger_path, "w", encoding="utf-8") as fh:
182
+ json.dump(state, fh, ensure_ascii=False)
183
+ except Exception:
184
+ pass
185
+
186
+
187
+ if __name__ == "__main__":
188
+ try:
189
+ main()
190
+ except Exception:
191
+ pass
192
+ sys.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forget-connect",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Safely connect Claude Code, Codex, and Claude Desktop to Forget memory.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,11 +10,12 @@
10
10
  "bin",
11
11
  "src",
12
12
  "README.md",
13
- "LICENSE"
13
+ "LICENSE",
14
+ "assets"
14
15
  ],
15
16
  "scripts": {
16
17
  "test": "node --test",
17
- "lint": "node --check bin/forget-connect.js && node --check src/cli.js && node --check src/core.js && node --check src/doctor.js"
18
+ "lint": "node --check bin/forget-connect.js && node --check src/cli.js && node --check src/core.js && node --check src/doctor.js && node --check src/hooks.js"
18
19
  },
19
20
  "engines": {
20
21
  "node": ">=18"
package/src/cli.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import readline from "node:readline/promises";
3
- import { stdin, stdout } from "node:process";
3
+ import { stdin, stdout, stderr } from "node:process";
4
4
  import {
5
5
  DEFAULT_MCP_URL,
6
+ HOSTED_MCP_URL,
6
7
  ConfigError,
7
8
  applyPlan,
8
9
  buildPlan,
9
10
  detectClients,
11
+ detectInstalledScope,
10
12
  getClients,
11
13
  inspectClients,
12
14
  normalizeUrl,
@@ -16,6 +18,15 @@ import {
16
18
  validateApiKey,
17
19
  } from "./core.js";
18
20
  import { doctorRemote } from "./doctor.js";
21
+ import {
22
+ connectHooksSettings,
23
+ disconnectHooksSettings,
24
+ hooksDirFor,
25
+ inspectHooks,
26
+ installHookScripts,
27
+ removeHookScripts,
28
+ settingsPathFor,
29
+ } from "./hooks.js";
19
30
 
20
31
  const CLIENT_IDS = new Set(["claude-code", "codex", "claude-desktop"]);
21
32
 
@@ -31,10 +42,13 @@ Usage:
31
42
  Options:
32
43
  --client <ids> Comma-separated: claude-code,codex,claude-desktop,all
33
44
  --url <url> MCP URL (default: ${DEFAULT_MCP_URL})
45
+ --hosted Use the managed Forget service (legacy) instead of a local server
34
46
  --user-id <id> Memory user scope (pair with --app-id)
35
47
  --app-id <id> Project/app scope (pair with --user-id)
36
- --no-auth Connect without a Bearer token (for a local server)
48
+ --no-auth Connect without a Bearer token
37
49
  --no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
50
+ --no-hooks Do not install Claude Code memory hooks (session capsule,
51
+ per-turn recall, conflict alerts, session capture)
38
52
  --no-migrate-enacta Keep matching legacy config and rules blocks
39
53
  --dry-run Show the files that would change without writing them
40
54
  --timeout <seconds> Doctor network timeout (default: 10)
@@ -44,14 +58,14 @@ Options:
44
58
  -v, --version Show version
45
59
 
46
60
  Authentication:
47
- Set FORGET_API_KEY, or run interactively and paste the key when prompted.
61
+ The default local server needs no token. For --hosted (legacy) set
62
+ FORGET_API_KEY, or run interactively and paste the key when prompted.
48
63
  Keys are intentionally not accepted as command-line arguments.
49
64
 
50
65
  Examples:
51
- npx forget-connect
52
- FORGET_API_KEY=... npx forget-connect --client claude-code,codex
53
- FORGET_API_KEY=... npx forget-connect --user-id junghunkim --app-id Mem1
54
- npx forget-connect --url http://localhost:8000/mcp --no-auth --client all
66
+ npx forget-connect # local server at ${DEFAULT_MCP_URL}
67
+ npx forget-connect --client claude-code,codex
68
+ FORGET_API_KEY=... npx forget-connect --hosted --user-id junghunkim --app-id Mem1
55
69
  npx forget-connect status
56
70
  npx forget-connect doctor --client codex
57
71
  npx forget-connect disconnect --client codex
@@ -68,7 +82,7 @@ function requireValue(argv, index, flag) {
68
82
 
69
83
  function isHostedBaseUrl(value) {
70
84
  const candidate = new URL(value);
71
- const hosted = new URL(DEFAULT_MCP_URL);
85
+ const hosted = new URL(HOSTED_MCP_URL);
72
86
  const candidateHost = candidate.hostname.toLowerCase().replace(/\.+$/, "");
73
87
  const hostedHost = hosted.hostname.toLowerCase().replace(/\.+$/, "");
74
88
  if (candidateHost !== hostedHost) return false;
@@ -88,10 +102,13 @@ export function parseArgs(argv, env = process.env) {
88
102
  action: "connect",
89
103
  clientValues: [],
90
104
  url: env.FORGET_MCP_URL || DEFAULT_MCP_URL,
105
+ urlExplicit: false,
106
+ hostedFlag: false,
91
107
  userId: env.FORGET_USER_ID?.trim() || "",
92
108
  appId: env.FORGET_APP_ID?.trim() || "",
93
109
  auth: true,
94
110
  rules: true,
111
+ hooks: true,
95
112
  migrateLegacy: true,
96
113
  dryRun: false,
97
114
  yes: false,
@@ -114,9 +131,13 @@ export function parseArgs(argv, env = process.env) {
114
131
  options.clientValues.push(arg.slice("--client=".length));
115
132
  } else if (arg === "--url") {
116
133
  options.url = requireValue(argv, index, arg);
134
+ options.urlExplicit = true;
117
135
  index += 1;
118
136
  } else if (arg.startsWith("--url=")) {
119
137
  options.url = arg.slice("--url=".length);
138
+ options.urlExplicit = true;
139
+ } else if (arg === "--hosted") {
140
+ options.hostedFlag = true;
120
141
  } else if (arg === "--user-id") {
121
142
  options.userId = requireValue(argv, index, arg);
122
143
  index += 1;
@@ -131,6 +152,8 @@ export function parseArgs(argv, env = process.env) {
131
152
  options.auth = false;
132
153
  } else if (arg === "--no-rules") {
133
154
  options.rules = false;
155
+ } else if (arg === "--no-hooks") {
156
+ options.hooks = false;
134
157
  } else if (arg === "--no-migrate-enacta") {
135
158
  options.migrateLegacy = false;
136
159
  } else if (arg === "--dry-run") {
@@ -160,6 +183,12 @@ export function parseArgs(argv, env = process.env) {
160
183
  throw new ConfigError(`Unknown argument: ${arg}`);
161
184
  }
162
185
  }
186
+ if (options.hostedFlag) {
187
+ if (options.urlExplicit) {
188
+ throw new ConfigError("--hosted and --url are mutually exclusive.");
189
+ }
190
+ options.url = HOSTED_MCP_URL;
191
+ }
163
192
  options.baseUrl = normalizeUrl(options.url);
164
193
  options.hosted = isHostedBaseUrl(options.baseUrl);
165
194
  if (Boolean(options.userId) !== Boolean(options.appId)) {
@@ -278,16 +307,27 @@ async function promptHidden(question) {
278
307
  }
279
308
  }
280
309
 
310
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
311
+
312
+ function isLoopbackUrl(value) {
313
+ return LOOPBACK_HOSTS.has(new URL(value).hostname.toLowerCase());
314
+ }
315
+
281
316
  async function apiKeyFor(options, env) {
282
317
  if (!["connect", "doctor"].includes(options.action) || !options.auth || options.dryRun) return "";
283
318
  const fromEnv = env.FORGET_API_KEY?.trim();
284
319
  if (fromEnv) {
285
- if (new URL(options.url).protocol !== "https:") {
320
+ if (new URL(options.url).protocol === "https:") return validateApiKey(fromEnv);
321
+ if (!isLoopbackUrl(options.url)) {
286
322
  throw new ConfigError(
287
323
  "Bearer authentication requires HTTPS. Use --no-auth for a local HTTP server.",
288
324
  );
289
325
  }
290
- return validateApiKey(fromEnv);
326
+ // A leftover hosted key must not block the local-first default flow, and
327
+ // a loopback target never puts the secret on the wire anyway.
328
+ stderr.write(
329
+ "Note: FORGET_API_KEY is set but the target is a loopback HTTP server; connecting without a token.\n",
330
+ );
291
331
  }
292
332
  if (!options.hosted) return "";
293
333
  const prompted = (await promptHidden("Paste your Forget API key: ")).trim();
@@ -335,6 +375,12 @@ function localDoctorResults(statuses, { requireRules = true } = {}) {
335
375
 
336
376
  function printDoctor(result) {
337
377
  stdout.write("Forget connection doctor\n");
378
+ if (result.hooks) {
379
+ const hooksDetail = result.hooks.registered
380
+ ? `registered, scripts ${result.hooks.scripts_present ? "present" : "missing"}, python3 ${result.hooks.python3 ? "ok" : "missing"}`
381
+ : "not installed (use connect without --no-hooks)";
382
+ stdout.write(`${result.hooks.ok ? "✓" : "✗"} Hooks: ${hooksDetail}\n`);
383
+ }
338
384
  for (const client of result.clients) {
339
385
  const details = [
340
386
  client.config ? "config found" : "config missing",
@@ -394,6 +440,23 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
394
440
  return;
395
441
  }
396
442
 
443
+ if (options.action === "doctor" && !options.scope && !options.urlExplicit && !options.hostedFlag) {
444
+ // A user who connected with --user-id/--app-id will run a bare
445
+ // `forget-connect doctor` next; comparing their scoped install against the
446
+ // unscoped default URL would report false failures. Adopt the scope the
447
+ // installed config already carries.
448
+ const installed = await detectInstalledScope(clients);
449
+ if (installed) {
450
+ options.scope = { userId: installed.userId, appId: installed.appId };
451
+ options.baseUrl = installed.baseUrl;
452
+ options.hosted = isHostedBaseUrl(options.baseUrl);
453
+ options.url = scopedMcpUrl(options.baseUrl, options.scope);
454
+ stdout.write(
455
+ `Scope detected from installed config: user ${installed.userId} · app ${installed.appId}\n`,
456
+ );
457
+ }
458
+ }
459
+
397
460
  const apiKey = await apiKeyFor(options, env);
398
461
  if (options.action === "doctor") {
399
462
  const local = localDoctorResults(
@@ -427,11 +490,15 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
427
490
  skipped: true,
428
491
  },
429
492
  };
493
+ const hooksStatus = clients.some((client) => client.id === "claude-code")
494
+ ? await inspectHooks({ env })
495
+ : null;
430
496
  const result = {
431
- ok: local.every((client) => client.ok) && remote.ok,
497
+ ok: local.every((client) => client.ok) && remote.ok && (hooksStatus?.ok ?? true),
432
498
  url: redactUrlForDisplay(options.url),
433
499
  scope: { configured: Boolean(options.scope) },
434
500
  clients: local,
501
+ hooks: hooksStatus,
435
502
  remote,
436
503
  };
437
504
  if (options.json) stdout.write(`${JSON.stringify(result)}\n`);
@@ -444,30 +511,84 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
444
511
  apiKey,
445
512
  installInstructionRules: options.rules,
446
513
  migrateLegacy: options.migrateLegacy,
447
- legacyUrls: [...new Set([options.url, options.baseUrl])],
514
+ legacyUrls: [...new Set([options.url, options.baseUrl, HOSTED_MCP_URL])],
448
515
  });
449
516
 
517
+ // Claude Code is the only client with a hook system today; the hooks are
518
+ // what make memory arrive without being asked. Disconnect always cleans
519
+ // them up, even when the install used --no-hooks.
520
+ const claudeCode = clients.find((client) => client.id === "claude-code");
521
+ let manageHooks = Boolean(claudeCode)
522
+ && (options.action === "disconnect" || options.hooks);
523
+ if (manageHooks && process.platform === "win32" && options.action === "connect") {
524
+ // The hook commands are POSIX shell strings and the scripts need
525
+ // python3 — installing them on Windows would register broken hooks.
526
+ stderr.write("Note: memory hooks are not yet supported on Windows; skipping hook install.\n");
527
+ manageHooks = false;
528
+ }
529
+ const hooksDir = manageHooks ? hooksDirFor({ env }) : "";
530
+ if (manageHooks) {
531
+ const settingsPath = settingsPathFor({ env });
532
+ let settingsRaw = "";
533
+ try {
534
+ settingsRaw = await readFile(settingsPath, "utf8");
535
+ } catch (error) {
536
+ if (!error || error.code !== "ENOENT") throw error;
537
+ }
538
+ const settingsNext = options.action === "connect"
539
+ ? connectHooksSettings(settingsRaw, { hooksDir, url: options.url })
540
+ : disconnectHooksSettings(settingsRaw);
541
+ if (settingsRaw !== settingsNext) {
542
+ changes.push({
543
+ client: claudeCode,
544
+ filePath: settingsPath,
545
+ before: settingsRaw,
546
+ after: settingsNext,
547
+ kind: "hooks",
548
+ sensitive: false,
549
+ backup: options.action === "connect",
550
+ });
551
+ }
552
+ }
553
+
450
554
  if (options.dryRun) {
451
- if (!changes.length) {
555
+ if (!changes.length && !manageHooks) {
452
556
  stdout.write("No changes needed.\n");
453
557
  return;
454
558
  }
455
559
  for (const change of changes) {
456
560
  stdout.write(`Would update ${change.filePath} (${change.client.name} ${change.kind})\n`);
457
561
  }
562
+ if (manageHooks && options.action === "connect") {
563
+ stdout.write(`Would install hook scripts into ${hooksDir}\n`);
564
+ }
565
+ if (manageHooks && options.action === "disconnect") {
566
+ stdout.write(`Would remove hook scripts from ${hooksDir}\n`);
567
+ }
458
568
  return;
459
569
  }
460
570
 
461
571
  const result = await applyPlan(changes);
572
+ let hookScriptPaths = [];
573
+ if (manageHooks) {
574
+ hookScriptPaths = options.action === "connect"
575
+ ? await installHookScripts(hooksDir)
576
+ : await removeHookScripts(hooksDir);
577
+ }
462
578
  const verb = options.action === "connect" ? "Connected" : "Disconnected";
463
- if (!result.changed.length) {
579
+ if (!result.changed.length && !hookScriptPaths.length) {
464
580
  stdout.write("No changes needed.\n");
465
581
  } else {
466
582
  stdout.write(`${verb} ${clients.map((client) => client.name).join(", ")}.\n`);
467
583
  for (const filePath of result.changed) stdout.write(` updated ${filePath}\n`);
468
584
  for (const filePath of result.backups) stdout.write(` backup ${filePath}\n`);
585
+ const hookVerb = options.action === "connect" ? "installed" : "removed";
586
+ for (const filePath of hookScriptPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
469
587
  }
470
588
  if (options.action === "connect") {
589
+ if (manageHooks) {
590
+ stdout.write(" hooks: session capsule, per-turn recall, conflict alerts, session capture (needs python3 on PATH)\n");
591
+ }
471
592
  if (options.hosted && !options.scope) {
472
593
  stdout.write(
473
594
  "\nWarning: hosted continuity scope is not configured. Reconnect with --user-id and --app-id before relying on cross-client recall.\n",
package/src/core.js CHANGED
@@ -14,7 +14,8 @@ import {
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
 
17
- export const DEFAULT_MCP_URL = "https://api.multi-turn.ai/mcp";
17
+ export const DEFAULT_MCP_URL = "http://localhost:8000/mcp";
18
+ export const HOSTED_MCP_URL = "https://api.multi-turn.ai/mcp";
18
19
  export const SERVER_KEY = "forget";
19
20
  export const BACKUP_SUFFIX = ".forget-backup";
20
21
 
@@ -29,8 +30,10 @@ export const MEMORY_RULES = [
29
30
  "# Memory (Forget)",
30
31
  "You have the user's long-term memory via the `forget` MCP server.",
31
32
  "ALWAYS call `search_memories` on `forget` FIRST — before any shell command or file search — whenever the user refers to their own past decisions, preferences, plans, or anything previously discussed. Trust recent memories over old ones.",
32
- "For resume or continue requests, then call `get_task_state` and `prepare_context_autopilot` with the current working directory before searching files.",
33
- "When the user states a durable decision, preference, or lasting fact, save it with `add_memory`.",
33
+ "At session start and on resume/continue requests, call `prepare_context_autopilot` once and treat its capsule as a suggestion (open tasks, next actions, constraints); call `get_task_state` for active work.",
34
+ "Results may carry a `trust` label — treat it as a permission, not a decoration: green (user-stated or tool-observed) = safe to act on; yellow (agent-inferred or self-summarized) = confirm with the user before real-world action; red (superseded) = reference only; unlabeled = treat as yellow.",
35
+ "When the user states a durable decision, preference, or lasting fact, save it with `add_memory`. Never record a planned action as completed — completion claims without evidence stay unverified.",
36
+ "To retire a fact that turned out wrong, use `supersede_memory` and always pass `superseded_by` to link the replacement. When a true-but-unverified claim gets its receipt, use `confirm_memory` with evidence instead.",
34
37
  RULES_END,
35
38
  ].join("\n");
36
39
 
@@ -391,6 +394,64 @@ async function readOptional(filePath) {
391
394
  }
392
395
  }
393
396
 
397
+ export function configuredServerUrl(client, raw) {
398
+ if (!raw) return "";
399
+ if (client.kind === "toml") {
400
+ const bounds = sectionBounds(raw, SERVER_KEY);
401
+ if (bounds.start === -1 || bounds.count !== 1) return "";
402
+ const section = bounds.lines.slice(bounds.start, bounds.end).join("\n");
403
+ const match = section.match(/^\s*url\s*=\s*("(?:[^"\\]|\\.)*")\s*$/m);
404
+ if (!match) return "";
405
+ try {
406
+ return JSON.parse(match[1]);
407
+ } catch {
408
+ return "";
409
+ }
410
+ }
411
+ try {
412
+ const config = parseJsonStrict(raw, client.configPath);
413
+ const server = isObject(config.mcpServers) ? config.mcpServers[SERVER_KEY] : null;
414
+ if (!isObject(server)) return "";
415
+ if (client.id === "claude-desktop") {
416
+ return Array.isArray(server.args) && typeof server.args[2] === "string"
417
+ ? server.args[2]
418
+ : "";
419
+ }
420
+ return typeof server.url === "string" ? server.url : "";
421
+ } catch {
422
+ return "";
423
+ }
424
+ }
425
+
426
+ export function scopeFromUrl(value) {
427
+ if (!value) return null;
428
+ let parsed;
429
+ try {
430
+ parsed = new URL(normalizeUrl(value));
431
+ } catch {
432
+ return null;
433
+ }
434
+ const match = parsed.pathname.match(/^(.*\/mcp)\/([^/]+)\/http\/([^/]+)\/?$/);
435
+ if (!match) return null;
436
+ try {
437
+ const appId = validateScopeId(decodeURIComponent(match[2]), "app_id");
438
+ const userId = validateScopeId(decodeURIComponent(match[3]), "user_id");
439
+ parsed.pathname = match[1];
440
+ return { userId, appId, baseUrl: parsed.toString().replace(/\/$/, "") };
441
+ } catch {
442
+ return null;
443
+ }
444
+ }
445
+
446
+ export async function detectInstalledScope(clients) {
447
+ for (const client of clients) {
448
+ const raw = await readOptional(client.configPath);
449
+ const scope = scopeFromUrl(configuredServerUrl(client, raw));
450
+ if (scope) return scope;
451
+ }
452
+ return null;
453
+ }
454
+
394
455
  export async function detectClients(clients) {
395
456
  const detected = [];
396
457
  for (const client of clients) {
@@ -629,7 +690,7 @@ async function sourceMode(filePath, fallback) {
629
690
  }
630
691
  }
631
692
 
632
- async function atomicWrite(filePath, content, mode) {
693
+ export async function atomicWrite(filePath, content, mode) {
633
694
  const target = await resolvedWritePath(filePath);
634
695
  await mkdir(path.dirname(target), { recursive: true });
635
696
  const temp = `${target}.forget-connect-${process.pid}-${randomBytes(5).toString("hex")}`;
package/src/hooks.js ADDED
@@ -0,0 +1,250 @@
1
+ // The hooks layer is the product thesis made real: memory that arrives
2
+ // without being asked. Claude Code gets four harness hooks — a session-start
3
+ // context capsule, per-turn push recall with conflict-zone alerts, and
4
+ // session capture feeding the outcome flywheel. Other clients (Codex,
5
+ // Desktop) rely on instruction rules until they grow hook systems.
6
+ //
7
+ // First rule of touching ~/.claude/settings.json: it is the user's file.
8
+ // Foreign hooks (other tools register there too) must survive connect,
9
+ // reconnect, and disconnect byte-for-byte. Ownership is recognized by the
10
+ // command path marker, never by position.
11
+
12
+ import { execFile } from "node:child_process";
13
+ import { readFile, unlink } from "node:fs/promises";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { ConfigError, atomicWrite, parseJsonStrict } from "./core.js";
18
+
19
+ export const HOOK_SCRIPTS = [
20
+ "forget_sessionstart.py",
21
+ "forget_turnrecall.py",
22
+ "forget_capture.py",
23
+ ];
24
+
25
+ // Ownership marker: every command we install invokes a script whose path
26
+ // contains this fragment. Recognizes hand-installed variants too.
27
+ export const HOOK_MARKER = `${path.join(".forget", "hooks", "forget_")}`;
28
+
29
+ function isObject(value) {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function shellQuote(value) {
34
+ // POSIX single-quote escaping: closes the quote, emits an escaped quote,
35
+ // reopens. Safe for every byte except NUL.
36
+ return `'${String(value).replaceAll("'", `'\\''`)}'`;
37
+ }
38
+
39
+ export function hooksDirFor(options = {}) {
40
+ const env = options.env ?? process.env;
41
+ const home = options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
42
+ return path.join(home, ".forget", "hooks");
43
+ }
44
+
45
+ export function settingsPathFor(options = {}) {
46
+ const env = options.env ?? process.env;
47
+ const home = options.home ?? env.HOME ?? env.USERPROFILE ?? os.homedir();
48
+ return path.join(home, ".claude", "settings.json");
49
+ }
50
+
51
+ export function hookCommand(script, { hooksDir, url }) {
52
+ const scriptPath = path.join(hooksDir, script);
53
+ return `FORGET_MCP_URL=${shellQuote(url)} python3 ${shellQuote(scriptPath)}`;
54
+ }
55
+
56
+ export function hookEntries({ hooksDir, url }) {
57
+ return {
58
+ SessionStart: {
59
+ command: hookCommand("forget_sessionstart.py", { hooksDir, url }),
60
+ timeout: 12,
61
+ statusMessage: "forget: assembling context capsule",
62
+ },
63
+ UserPromptSubmit: {
64
+ command: hookCommand("forget_turnrecall.py", { hooksDir, url }),
65
+ timeout: 6,
66
+ statusMessage: "forget: checking relevant memories",
67
+ },
68
+ PreCompact: {
69
+ command: hookCommand("forget_capture.py", { hooksDir, url }),
70
+ timeout: 10,
71
+ statusMessage: "forget: capturing session",
72
+ },
73
+ SessionEnd: {
74
+ command: hookCommand("forget_capture.py", { hooksDir, url }),
75
+ timeout: 10,
76
+ statusMessage: "forget: capturing session + outcome",
77
+ },
78
+ };
79
+ }
80
+
81
+ function ownsHook(hook) {
82
+ return isObject(hook)
83
+ && typeof hook.command === "string"
84
+ && hook.command.includes(HOOK_MARKER);
85
+ }
86
+
87
+ function withoutOurHooks(groups) {
88
+ const cleaned = [];
89
+ for (const group of groups) {
90
+ if (!isObject(group) || !Array.isArray(group.hooks)) {
91
+ cleaned.push(group);
92
+ continue;
93
+ }
94
+ const foreign = group.hooks.filter((hook) => !ownsHook(hook));
95
+ if (foreign.length === group.hooks.length) {
96
+ cleaned.push(group);
97
+ } else if (foreign.length) {
98
+ cleaned.push({ ...group, hooks: foreign });
99
+ }
100
+ // groups that held only our hooks are dropped entirely
101
+ }
102
+ return cleaned;
103
+ }
104
+
105
+ function validatedHooks(config, label) {
106
+ if (config.hooks !== undefined && !isObject(config.hooks)) {
107
+ throw new ConfigError(
108
+ `The existing hooks value in ${label} is not a JSON object. Nothing was changed.`,
109
+ );
110
+ }
111
+ const hooks = config.hooks ?? {};
112
+ for (const [event, groups] of Object.entries(hooks)) {
113
+ if (!Array.isArray(groups)) {
114
+ throw new ConfigError(
115
+ `hooks.${event} in ${label} is not an array. Nothing was changed.`,
116
+ );
117
+ }
118
+ }
119
+ return hooks;
120
+ }
121
+
122
+ export function connectHooksSettings(raw, { hooksDir, url }) {
123
+ const config = parseJsonStrict(raw, "settings.json");
124
+ const hooks = validatedHooks(config, "settings.json");
125
+ for (const [event, entry] of Object.entries(hookEntries({ hooksDir, url }))) {
126
+ const groups = withoutOurHooks(hooks[event] ?? []);
127
+ groups.push({
128
+ hooks: [{
129
+ type: "command",
130
+ command: entry.command,
131
+ timeout: entry.timeout,
132
+ statusMessage: entry.statusMessage,
133
+ }],
134
+ });
135
+ hooks[event] = groups;
136
+ }
137
+ config.hooks = hooks;
138
+ return `${JSON.stringify(config, null, 2)}\n`;
139
+ }
140
+
141
+ export function disconnectHooksSettings(raw) {
142
+ const config = parseJsonStrict(raw, "settings.json");
143
+ if (config.hooks === undefined) return raw;
144
+ const hooks = validatedHooks(config, "settings.json");
145
+ let touched = false;
146
+ for (const [event, groups] of Object.entries(hooks)) {
147
+ const cleaned = withoutOurHooks(groups);
148
+ if (cleaned.length !== groups.length
149
+ || JSON.stringify(cleaned) !== JSON.stringify(groups)) {
150
+ touched = true;
151
+ }
152
+ if (cleaned.length) hooks[event] = cleaned;
153
+ else delete hooks[event];
154
+ }
155
+ if (!touched) return raw;
156
+ if (Object.keys(hooks).length) config.hooks = hooks;
157
+ else delete config.hooks;
158
+ return `${JSON.stringify(config, null, 2)}\n`;
159
+ }
160
+
161
+ export function hooksInstalled(raw, { hooksDir } = {}) {
162
+ let config;
163
+ try {
164
+ config = parseJsonStrict(raw, "settings.json");
165
+ } catch {
166
+ return false;
167
+ }
168
+ if (!isObject(config.hooks)) return false;
169
+ const events = Object.keys(hookEntries({ hooksDir: hooksDir ?? "", url: "http://x" }));
170
+ return events.every((event) => {
171
+ const groups = config.hooks[event];
172
+ return Array.isArray(groups) && groups.some(
173
+ (group) => isObject(group)
174
+ && Array.isArray(group.hooks)
175
+ && group.hooks.some((hook) => ownsHook(hook)),
176
+ );
177
+ });
178
+ }
179
+
180
+ function assetsDir() {
181
+ return fileURLToPath(new URL("../assets/hooks/", import.meta.url));
182
+ }
183
+
184
+ export async function readHookAssets() {
185
+ const dir = assetsDir();
186
+ const assets = [];
187
+ for (const script of HOOK_SCRIPTS) {
188
+ assets.push({
189
+ name: script,
190
+ content: await readFile(path.join(dir, script), "utf8"),
191
+ });
192
+ }
193
+ return assets;
194
+ }
195
+
196
+ export async function installHookScripts(hooksDir) {
197
+ const assets = await readHookAssets();
198
+ const written = [];
199
+ for (const asset of assets) {
200
+ const target = path.join(hooksDir, asset.name);
201
+ await atomicWrite(target, asset.content, 0o755);
202
+ written.push(target);
203
+ }
204
+ return written;
205
+ }
206
+
207
+ export async function inspectHooks({ env } = {}) {
208
+ const hooksDir = hooksDirFor({ env });
209
+ const settingsPath = settingsPathFor({ env });
210
+ let settingsRaw = "";
211
+ try {
212
+ settingsRaw = await readFile(settingsPath, "utf8");
213
+ } catch {
214
+ settingsRaw = "";
215
+ }
216
+ const registered = hooksInstalled(settingsRaw, { hooksDir });
217
+ let scriptsPresent = true;
218
+ for (const script of HOOK_SCRIPTS) {
219
+ try {
220
+ await readFile(path.join(hooksDir, script), "utf8");
221
+ } catch {
222
+ scriptsPresent = false;
223
+ }
224
+ }
225
+ const python3 = await new Promise((resolve) => {
226
+ execFile("python3", ["--version"], (error) => resolve(!error));
227
+ });
228
+ return {
229
+ registered,
230
+ scripts_present: scriptsPresent,
231
+ python3,
232
+ // registered-but-broken is a failure; absent hooks are a legitimate
233
+ // choice (--no-hooks), so they don't fail the check
234
+ ok: !registered || (scriptsPresent && python3),
235
+ };
236
+ }
237
+
238
+ export async function removeHookScripts(hooksDir) {
239
+ const removed = [];
240
+ for (const script of HOOK_SCRIPTS) {
241
+ const target = path.join(hooksDir, script);
242
+ try {
243
+ await unlink(target);
244
+ removed.push(target);
245
+ } catch (error) {
246
+ if (!error || error.code !== "ENOENT") throw error;
247
+ }
248
+ }
249
+ return removed;
250
+ }