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 +49 -34
- package/assets/hooks/forget_capture.py +193 -0
- package/assets/hooks/forget_sessionstart.py +79 -0
- package/assets/hooks/forget_turnrecall.py +192 -0
- package/package.json +4 -3
- package/src/cli.js +135 -14
- package/src/core.js +65 -4
- package/src/hooks.js +250 -0
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# forget-connect
|
|
2
2
|
|
|
3
|
-
Connect Claude Code, Codex, and Claude Desktop to
|
|
4
|
-
|
|
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
|
|
8
|
+
npx forget-connect
|
|
8
9
|
```
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
or
|
|
68
|
-
|
|
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:
|
|
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
|
|
91
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
"
|
|
33
|
-
"
|
|
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
|
+
}
|