forget-connect 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/assets/hooks/forget_capture.py +17 -14
- package/assets/hooks/forget_project.py +243 -0
- package/assets/hooks/forget_projecttag.py +99 -0
- package/assets/hooks/forget_sessionstart.py +62 -9
- package/assets/hooks/forget_turnrecall.py +23 -3
- package/bin/forget-connect.js +7 -0
- package/package.json +2 -2
- package/src/cli.js +108 -19
- package/src/core.js +15 -4
- package/src/hooks.js +12 -2
package/README.md
CHANGED
|
@@ -8,9 +8,13 @@ overwriting the MCP servers or global instructions you already have.
|
|
|
8
8
|
npx forget-connect
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
By default this targets the local server at `http://localhost:8000
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
By default this targets the local server at `http://localhost:8000` and
|
|
12
|
+
scopes each client to its own memory pool at
|
|
13
|
+
`/mcp/<client>/http/<os-username>` (for example
|
|
14
|
+
`/mcp/claude-code/http/junghun`) — your memories stay on your machine and
|
|
15
|
+
stay isolated per user and per client. Pass `--no-scope` for the shared
|
|
16
|
+
unscoped `/mcp` endpoint. The guided flow detects installed clients and
|
|
17
|
+
then:
|
|
14
18
|
|
|
15
19
|
- merges a `forget` entry into each selected MCP config;
|
|
16
20
|
- installs a marked memory rule in Claude Code's `CLAUDE.md` and Codex's
|
|
@@ -73,10 +77,11 @@ block. It does not restore backups or remove unrelated config.
|
|
|
73
77
|
|
|
74
78
|
```text
|
|
75
79
|
--client <ids> claude-code,codex,claude-desktop,all
|
|
76
|
-
--url <url> MCP URL (default: http://localhost:8000/mcp)
|
|
80
|
+
--url <url> Exact MCP URL to install (default base: http://localhost:8000/mcp)
|
|
77
81
|
--hosted Use the managed Forget service (legacy)
|
|
78
82
|
--user-id <id> Memory user scope; pair with --app-id
|
|
79
83
|
--app-id <id> Project/app scope; pair with --user-id
|
|
84
|
+
--no-scope Install the shared unscoped /mcp endpoint (legacy behavior)
|
|
80
85
|
--no-auth Do not install a Bearer token
|
|
81
86
|
--no-rules Do not manage CLAUDE.md or AGENTS.md
|
|
82
87
|
--no-migrate-enacta Keep matching legacy config and rule blocks
|
|
@@ -21,6 +21,10 @@ import re
|
|
|
21
21
|
import sys
|
|
22
22
|
import urllib.request
|
|
23
23
|
|
|
24
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
25
|
+
|
|
26
|
+
from forget_project import project_key_for_path, scope_disabled # noqa: E402
|
|
27
|
+
|
|
24
28
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
25
29
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
26
30
|
TAIL_BYTES = 400_000
|
|
@@ -111,20 +115,19 @@ def _capture(hook_input: dict, digest: dict, transcript_path: str, session_id: s
|
|
|
111
115
|
f"{' (꼬리 표본)' if digest['tail_only'] else ''}, {digest['first_ts']}~{digest['last_ts']}. "
|
|
112
116
|
f"최근 사용자 발화: {snippets}. 전문: {transcript_path}"
|
|
113
117
|
)
|
|
114
|
-
|
|
115
|
-
"
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
118
|
+
metadata = {
|
|
119
|
+
"hook": event,
|
|
120
|
+
"session_id": session_id,
|
|
121
|
+
"transcript_path": transcript_path,
|
|
122
|
+
"trigger": label,
|
|
123
|
+
}
|
|
124
|
+
# A session pointer belongs to the repo the session happened in — the one
|
|
125
|
+
# write here whose layer needs no guessing.
|
|
126
|
+
project = None if scope_disabled() else project_key_for_path(hook_input.get("cwd") or os.getcwd())
|
|
127
|
+
if project:
|
|
128
|
+
metadata["project"] = project
|
|
129
|
+
metadata["scope_layer"] = "project"
|
|
130
|
+
_rpc("add_memory", {"text": text, "infer": False, "source_role": "tool", "metadata": metadata})
|
|
128
131
|
|
|
129
132
|
|
|
130
133
|
def _handoff(digest: dict, transcript_path: str, session_id: str) -> None:
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project identity for hooks: which repo is this session working in?
|
|
3
|
+
|
|
4
|
+
Design principle (2026-08-01): a scope the user has to declare is a scope that
|
|
5
|
+
fails. Our two worst scope bugs were fragmentation (one topic scattered across
|
|
6
|
+
five pools, so search missed it) and contamination (339 demo rows living
|
|
7
|
+
alongside live memory, F4). Asking the user to create and select a project
|
|
8
|
+
scope hands both diseases to the user. So the boundary is DETECTED, never
|
|
9
|
+
configured — every hook already receives `cwd`, and the git repository root is
|
|
10
|
+
the project.
|
|
11
|
+
|
|
12
|
+
Layering, not splitting: one store, two layers distinguished by metadata.
|
|
13
|
+
Splitting into separate pools would re-introduce fragmentation by construction.
|
|
14
|
+
|
|
15
|
+
- metadata.project — origin key, always attached on write (provenance)
|
|
16
|
+
- metadata.scope_layer — "global" | "project" (which layer recall reads)
|
|
17
|
+
|
|
18
|
+
Recall then reads: this project's rows + everything global + everything
|
|
19
|
+
untagged. Untagged-means-global is what keeps every pre-existing memory
|
|
20
|
+
visible, so turning this on changes nothing until new writes land.
|
|
21
|
+
|
|
22
|
+
Stdlib only, and no imports from the `forget` package: the hooks are deployed
|
|
23
|
+
as standalone scripts under ~/.forget/hooks/ and run with the system python.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import configparser
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
|
|
33
|
+
CONFIG_PATH = os.path.expanduser("~/.forget/projects.json")
|
|
34
|
+
KEY_MAX_LEN = 40
|
|
35
|
+
|
|
36
|
+
# Directories that hold projects instead of being one. Keying off them would
|
|
37
|
+
# file unrelated work under "documents" — fragmentation's other direction.
|
|
38
|
+
_CONTAINER_NAMES = {
|
|
39
|
+
"code",
|
|
40
|
+
"desktop",
|
|
41
|
+
"dev",
|
|
42
|
+
"documents",
|
|
43
|
+
"downloads",
|
|
44
|
+
"git",
|
|
45
|
+
"projects",
|
|
46
|
+
"repos",
|
|
47
|
+
"src",
|
|
48
|
+
"tmp",
|
|
49
|
+
"work",
|
|
50
|
+
"workspaces",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Crude v1 classifier (same spirit as the mechanical-echo-v1 outcome labeler:
|
|
54
|
+
# its job is to start the flywheel, not to be right). A fact about the user
|
|
55
|
+
# themself outlives any single repo; everything else stays with its repo.
|
|
56
|
+
# The default is deliberately "project": global contamination costs more than
|
|
57
|
+
# a project-local miss, and the miss is recoverable by asking across projects.
|
|
58
|
+
_GLOBAL_PATTERNS = (
|
|
59
|
+
r"(?:나는|내가|제가|정훈은|정훈이|사용자는|유저는)[^.\n]{0,40}"
|
|
60
|
+
r"(?:선호|좋아|싫어|원해|원한다|스타일|성향|습관|정체성|목표|철학)",
|
|
61
|
+
r"\b(?:i|my)\b[^.\n]{0,40}\b(?:prefer|preference|always|never|habit|style|identity)\b",
|
|
62
|
+
r"(?:모든 프로젝트|어느 프로젝트|어디서나|프로젝트 무관|전역|전체적으로)",
|
|
63
|
+
r"\b(?:globally|(?:across|all|every) projects?\b|across (?:all )?projects|regardless of (?:the )?project)\b",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# "이 레포는 내가 선호하는 패턴대로" — the sentence is ABOUT the repo even
|
|
67
|
+
# though it name-drops a preference. Repo-context anchors outrank user-pattern
|
|
68
|
+
# hits: a fact anchored to "this repo" belongs to it.
|
|
69
|
+
_REPO_ANCHOR_PATTERNS = (
|
|
70
|
+
r"이 (?:레포|저장소|프로젝트|코드베이스)",
|
|
71
|
+
r"\bthis (?:repo|repository|project|codebase)\b",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Explicit permission to cross the boundary. Recall stays inside the current
|
|
75
|
+
# project unless the user asks to look elsewhere — the boundary doubles as a
|
|
76
|
+
# privacy gate (the other company's strategy must not leak into this session's
|
|
77
|
+
# capsule), so crossing has to be asked for, and is flagged when it happens.
|
|
78
|
+
_CROSS_PROJECT_PATTERNS = (
|
|
79
|
+
r"다른 (?:프로젝트|레포|repo|회사)",
|
|
80
|
+
# Plural or explicitly-universal only: "프로젝트 전체 테스트 돌려줘" means
|
|
81
|
+
# THIS project entire, and loosening the boundary on it would be a leak.
|
|
82
|
+
r"프로젝트들",
|
|
83
|
+
r"(?:모든|전체) 프로젝트(?:들)?",
|
|
84
|
+
r"프로젝트(?:들)? ?(?:통틀어|건너|넘어)",
|
|
85
|
+
r"(?:다른 곳|딴 데|저쪽)에서",
|
|
86
|
+
r"\b(?:other|another|across|all) (?:projects|repos|repositories)\b",
|
|
87
|
+
r"\b(?:other|another) (?:project|repo)\b",
|
|
88
|
+
r"\bany project\b",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _config() -> dict:
|
|
93
|
+
"""Optional overrides: {"aliases": {...}, "ignore": [...]}.
|
|
94
|
+
|
|
95
|
+
The escape hatch for the one case detection cannot resolve on its own —
|
|
96
|
+
two repositories that share a name. Nobody needs to write this file for
|
|
97
|
+
the common case.
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
with open(CONFIG_PATH, encoding="utf-8") as fh:
|
|
101
|
+
config = json.load(fh)
|
|
102
|
+
return config if isinstance(config, dict) else {}
|
|
103
|
+
except Exception:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _slug(raw: str) -> str:
|
|
108
|
+
"""Readable, stable key. Unicode word chars survive — a Korean directory
|
|
109
|
+
name is a perfectly good key, and transliterating would only obscure it."""
|
|
110
|
+
slug = re.sub(r"[^\w.-]+", "-", str(raw).strip(), flags=re.UNICODE).strip("-._")
|
|
111
|
+
return slug.lower()[:KEY_MAX_LEN]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _git_common_dir(start: str) -> str | None:
|
|
115
|
+
"""The shared .git of the repo containing `start`, worktrees included.
|
|
116
|
+
|
|
117
|
+
The worktree case is the trap: this very session runs in an Orca worktree
|
|
118
|
+
whose directory is named after a throwaway branch topic, while the same
|
|
119
|
+
repo is also checked out at ~/Documents/forget. Keying off the worktree
|
|
120
|
+
directory would file the same project under two keys — fragmentation
|
|
121
|
+
again. Resolving to the common .git collapses them.
|
|
122
|
+
"""
|
|
123
|
+
current = os.path.realpath(start)
|
|
124
|
+
while True:
|
|
125
|
+
candidate = os.path.join(current, ".git")
|
|
126
|
+
if os.path.isdir(candidate):
|
|
127
|
+
return candidate
|
|
128
|
+
if os.path.isfile(candidate):
|
|
129
|
+
try:
|
|
130
|
+
with open(candidate, encoding="utf-8") as fh:
|
|
131
|
+
pointer = fh.read().strip()
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
if pointer.startswith("gitdir:"):
|
|
135
|
+
path = pointer.split(":", 1)[1].strip()
|
|
136
|
+
if not os.path.isabs(path):
|
|
137
|
+
path = os.path.join(current, path)
|
|
138
|
+
path = os.path.realpath(path)
|
|
139
|
+
marker = os.sep + "worktrees" + os.sep
|
|
140
|
+
if marker in path:
|
|
141
|
+
path = path.split(marker)[0]
|
|
142
|
+
return path
|
|
143
|
+
return None
|
|
144
|
+
parent = os.path.dirname(current)
|
|
145
|
+
if parent == current:
|
|
146
|
+
return None
|
|
147
|
+
current = parent
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _origin_url(common_dir: str) -> str:
|
|
151
|
+
parser = configparser.ConfigParser(strict=False)
|
|
152
|
+
try:
|
|
153
|
+
parser.read(os.path.join(common_dir, "config"), encoding="utf-8")
|
|
154
|
+
except Exception:
|
|
155
|
+
return ""
|
|
156
|
+
for section in ('remote "origin"', "remote origin"):
|
|
157
|
+
if parser.has_option(section, "url"):
|
|
158
|
+
return str(parser.get(section, "url") or "").strip()
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def repo_identity(url: str) -> str:
|
|
163
|
+
"""github.com/owner/repo, scheme- and suffix-free, for alias lookups."""
|
|
164
|
+
identity = re.sub(r"^[a-z+]+://", "", url.strip(), flags=re.IGNORECASE)
|
|
165
|
+
identity = re.sub(r"^[^/@]+@", "", identity) # git@host:owner/repo
|
|
166
|
+
identity = identity.replace(":", "/", 1) if "/" not in identity.split(":")[0] else identity
|
|
167
|
+
identity = re.sub(r"\.git/?$", "", identity).rstrip("/")
|
|
168
|
+
return identity.lower()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def project_key_for_path(path: str | None) -> str | None:
|
|
172
|
+
"""The project key for a working directory, or None for "no project".
|
|
173
|
+
|
|
174
|
+
None means global-only: a session started in $HOME or some scratch
|
|
175
|
+
directory has no project to be about, and inventing one there would
|
|
176
|
+
scatter memories across as many keys as the user has stray folders.
|
|
177
|
+
"""
|
|
178
|
+
if not path:
|
|
179
|
+
return None
|
|
180
|
+
real = os.path.realpath(path)
|
|
181
|
+
config = _config()
|
|
182
|
+
aliases = config.get("aliases") if isinstance(config.get("aliases"), dict) else {}
|
|
183
|
+
ignore = {str(item).lower() for item in (config.get("ignore") or []) if str(item)}
|
|
184
|
+
|
|
185
|
+
common_dir = _git_common_dir(real)
|
|
186
|
+
identity = ""
|
|
187
|
+
if common_dir:
|
|
188
|
+
identity = repo_identity(_origin_url(common_dir))
|
|
189
|
+
if identity:
|
|
190
|
+
raw = identity.rsplit("/", 1)[-1]
|
|
191
|
+
else:
|
|
192
|
+
# No remote: the repo directory itself names the project. Use the
|
|
193
|
+
# common .git's parent, not cwd — same worktree reason as above.
|
|
194
|
+
repo_root = os.path.dirname(common_dir.rstrip(os.sep))
|
|
195
|
+
raw = os.path.basename(repo_root)
|
|
196
|
+
else:
|
|
197
|
+
if real in (os.path.realpath(os.path.expanduser("~")), os.sep):
|
|
198
|
+
return None
|
|
199
|
+
raw = os.path.basename(real)
|
|
200
|
+
if raw.lower() in _CONTAINER_NAMES:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
for candidate in (identity, raw):
|
|
204
|
+
if candidate and str(aliases.get(candidate) or "").strip():
|
|
205
|
+
return _slug(aliases[candidate])
|
|
206
|
+
key = _slug(raw)
|
|
207
|
+
if not key or key in ignore:
|
|
208
|
+
return None
|
|
209
|
+
return key
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def layered_filter(project_key: str | None) -> dict | None:
|
|
213
|
+
"""Recall filter: this project + the global layer + everything untagged."""
|
|
214
|
+
if not project_key:
|
|
215
|
+
return None
|
|
216
|
+
return {
|
|
217
|
+
"OR": [
|
|
218
|
+
{"metadata.project": project_key},
|
|
219
|
+
{"metadata.project": None}, # written before layering existed
|
|
220
|
+
{"metadata.scope_layer": "global"},
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def classify_layer(text: str) -> str:
|
|
226
|
+
""""global" for facts about the user themself, else "project"."""
|
|
227
|
+
body = str(text or "")
|
|
228
|
+
for anchor in _REPO_ANCHOR_PATTERNS:
|
|
229
|
+
if re.search(anchor, body, flags=re.IGNORECASE | re.UNICODE):
|
|
230
|
+
return "project"
|
|
231
|
+
for pattern in _GLOBAL_PATTERNS:
|
|
232
|
+
if re.search(pattern, body, flags=re.IGNORECASE | re.UNICODE):
|
|
233
|
+
return "global"
|
|
234
|
+
return "project"
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def wants_cross_project(prompt: str) -> bool:
|
|
238
|
+
body = str(prompt or "")
|
|
239
|
+
return any(re.search(p, body, flags=re.IGNORECASE | re.UNICODE) for p in _CROSS_PROJECT_PATTERNS)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def scope_disabled() -> bool:
|
|
243
|
+
return str(os.environ.get("FORGET_PROJECT_SCOPE", "")).strip().lower() in {"0", "off", "false"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PreToolUse hook: stamp the originating project onto every memory write.
|
|
3
|
+
|
|
4
|
+
The write side of project layering. The recall side can filter by
|
|
5
|
+
metadata.project only if something puts it there, and the one thing that
|
|
6
|
+
knows which repo a session is working in is the hook — the forget server is a
|
|
7
|
+
shared local daemon with no idea of any client's cwd, and asking the agent to
|
|
8
|
+
remember to pass a project key would make correctness depend on the model's
|
|
9
|
+
diligence (and on the user configuring scopes, which is the failure this whole
|
|
10
|
+
design exists to avoid).
|
|
11
|
+
|
|
12
|
+
So the tag is attached mechanically, by rewriting the tool input in flight
|
|
13
|
+
(`updatedInput`). No permissionDecision is returned: the hook adds provenance,
|
|
14
|
+
it does not decide whether a write is allowed.
|
|
15
|
+
|
|
16
|
+
add_memory writes get metadata.project + scope_layer; record_task_state gets
|
|
17
|
+
a top-level project key (the task ledger stores it in its scope blob — this
|
|
18
|
+
is the F2 cure: heartbeat/Quant task rows stop invading the devloop capsule).
|
|
19
|
+
add_memories stays untagged: it takes no metadata over MCP.
|
|
20
|
+
|
|
21
|
+
Fail-open: on any error, print nothing and exit 0 — the write proceeds
|
|
22
|
+
untagged, which recall reads as the global layer.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
|
|
33
|
+
from forget_project import classify_layer, project_key_for_path, scope_disabled # noqa: E402
|
|
34
|
+
|
|
35
|
+
MEMORY_TOOLS = {"mcp__forget__add_memory", "add_memory"}
|
|
36
|
+
TASK_TOOLS = {"mcp__forget__record_task_state", "record_task_state"}
|
|
37
|
+
TAGGED_TOOLS = MEMORY_TOOLS | TASK_TOOLS
|
|
38
|
+
TAGGER_VERSION = "cwd-git-v1"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _write_text(tool_input: dict) -> str:
|
|
42
|
+
"""What the classifier reads: the fact being saved, however it arrived."""
|
|
43
|
+
text = str(tool_input.get("text") or "")
|
|
44
|
+
if text:
|
|
45
|
+
return text
|
|
46
|
+
messages = tool_input.get("messages")
|
|
47
|
+
if isinstance(messages, list):
|
|
48
|
+
parts = [str(m.get("content") or "") for m in messages if isinstance(m, dict)]
|
|
49
|
+
return " ".join(part for part in parts if part)
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _emit(updated_input: dict) -> None:
|
|
54
|
+
print(
|
|
55
|
+
json.dumps(
|
|
56
|
+
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "updatedInput": updated_input}},
|
|
57
|
+
ensure_ascii=False,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
hook_input = json.load(sys.stdin)
|
|
64
|
+
tool_name = str(hook_input.get("tool_name") or "")
|
|
65
|
+
if tool_name not in TAGGED_TOOLS:
|
|
66
|
+
return
|
|
67
|
+
if scope_disabled():
|
|
68
|
+
return
|
|
69
|
+
tool_input = hook_input.get("tool_input")
|
|
70
|
+
if not isinstance(tool_input, dict):
|
|
71
|
+
return
|
|
72
|
+
project = project_key_for_path(hook_input.get("cwd") or os.getcwd())
|
|
73
|
+
if not project:
|
|
74
|
+
return # no project to be about → leave it in the global layer
|
|
75
|
+
|
|
76
|
+
if tool_name in TASK_TOOLS:
|
|
77
|
+
# A task is inherently project work — no layer to classify. The key
|
|
78
|
+
# rides top-level; the server files it into the task's scope blob.
|
|
79
|
+
if tool_input.get("project"):
|
|
80
|
+
return # an explicit caller outranks detection
|
|
81
|
+
_emit({**tool_input, "project": project})
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
metadata = tool_input.get("metadata")
|
|
85
|
+
metadata = dict(metadata) if isinstance(metadata, dict) else {}
|
|
86
|
+
if metadata.get("project"):
|
|
87
|
+
return # an explicit caller outranks detection
|
|
88
|
+
metadata["project"] = project
|
|
89
|
+
metadata.setdefault("scope_layer", classify_layer(_write_text(tool_input)))
|
|
90
|
+
metadata.setdefault("project_tagger", TAGGER_VERSION)
|
|
91
|
+
_emit({**tool_input, "metadata": metadata})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
try:
|
|
96
|
+
main()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
sys.exit(0)
|
|
@@ -16,10 +16,50 @@ import sys
|
|
|
16
16
|
import time
|
|
17
17
|
import urllib.request
|
|
18
18
|
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
|
|
21
|
+
from forget_project import layered_filter, project_key_for_path, scope_disabled # noqa: E402
|
|
22
|
+
|
|
19
23
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
20
24
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
21
25
|
CAPSULE_CHAR_BUDGET = 1_600 # ~400 tokens
|
|
22
26
|
HANDOFF_MAX_AGE_SECONDS = 48 * 3600 # a stale shift-note is worse than none
|
|
27
|
+
# The server capability these hooks are built against. The capsule response
|
|
28
|
+
# carries server_version from 0.3.9 on; older servers silently drop arguments
|
|
29
|
+
# these hooks send (project layering) — that mismatch must never be silent.
|
|
30
|
+
REQUIRED_SERVER_VERSION = "0.3.9"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_tuple(value: str) -> tuple:
|
|
34
|
+
parts = []
|
|
35
|
+
for chunk in str(value or "").split("."):
|
|
36
|
+
digits = "".join(ch for ch in chunk if ch.isdigit())
|
|
37
|
+
parts.append(int(digits) if digits else 0)
|
|
38
|
+
return tuple(parts or [0])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_notice(server_version: str) -> str:
|
|
42
|
+
"""One warning line for the capsule header area, or ''.
|
|
43
|
+
|
|
44
|
+
Two signals, no network: the server's self-reported version (mismatch =
|
|
45
|
+
features silently lost), and the update-check cache that doctor/status
|
|
46
|
+
maintain (a local file — hooks never call out).
|
|
47
|
+
"""
|
|
48
|
+
if not server_version:
|
|
49
|
+
return "⚠ 서버가 버전을 안 밝힘(≤0.3.8) — 훅 기능 일부가 조용히 무시될 수 있음. 처방: forget-server upgrade"
|
|
50
|
+
if _version_tuple(server_version) < _version_tuple(REQUIRED_SERVER_VERSION):
|
|
51
|
+
return (
|
|
52
|
+
f"⚠ 서버 {server_version} < 훅 요구 {REQUIRED_SERVER_VERSION} — "
|
|
53
|
+
"프로젝트 층 등이 조용히 무시됨. 처방: forget-server upgrade"
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
with open(os.path.expanduser("~/.forget/update-check.json"), encoding="utf-8") as fh:
|
|
57
|
+
latest = str(json.load(fh).get("latest") or "")
|
|
58
|
+
if latest and _version_tuple(server_version) < _version_tuple(latest):
|
|
59
|
+
return f"새 버전 {latest} 나옴 (현재 {server_version}) — forget-server upgrade"
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return ""
|
|
23
63
|
|
|
24
64
|
|
|
25
65
|
def _consume_handoff() -> str:
|
|
@@ -58,17 +98,26 @@ def main() -> None:
|
|
|
58
98
|
hook_input = json.load(sys.stdin)
|
|
59
99
|
cwd = str(hook_input.get("cwd") or os.getcwd())
|
|
60
100
|
source = str(hook_input.get("source") or "startup")
|
|
101
|
+
project = None if scope_disabled() else project_key_for_path(cwd)
|
|
102
|
+
arguments: dict = {
|
|
103
|
+
"query": f"session {source} in {cwd} — active tasks, open loops, recent decisions",
|
|
104
|
+
"include_debug": False,
|
|
105
|
+
}
|
|
106
|
+
# F2's cause was a capsule that mixed layers: heartbeat and Quant rows
|
|
107
|
+
# invading a devloop session. The capsule now reads this project's layer
|
|
108
|
+
# plus the global one — untagged rows included, so nothing pre-existing
|
|
109
|
+
# disappears the day this lands.
|
|
110
|
+
project_filter = layered_filter(project)
|
|
111
|
+
if project_filter:
|
|
112
|
+
arguments["filters"] = project_filter
|
|
113
|
+
# Memory recall reads the layered OR; the task/goal ledger has its own
|
|
114
|
+
# storage (claims + epochs) and takes the project key explicitly.
|
|
115
|
+
arguments["project"] = project
|
|
61
116
|
payload = {
|
|
62
117
|
"jsonrpc": "2.0",
|
|
63
118
|
"id": 1,
|
|
64
119
|
"method": "tools/call",
|
|
65
|
-
"params": {
|
|
66
|
-
"name": "prepare_context_autopilot",
|
|
67
|
-
"arguments": {
|
|
68
|
-
"query": f"session {source} in {cwd} — active tasks, open loops, recent decisions",
|
|
69
|
-
"include_debug": False,
|
|
70
|
-
},
|
|
71
|
-
},
|
|
120
|
+
"params": {"name": "prepare_context_autopilot", "arguments": arguments},
|
|
72
121
|
}
|
|
73
122
|
request = urllib.request.Request(
|
|
74
123
|
FORGET_URL,
|
|
@@ -84,16 +133,20 @@ def main() -> None:
|
|
|
84
133
|
if isinstance(items, list) and items:
|
|
85
134
|
capsule = "\n".join(f"- {item}" for item in items[:6] if isinstance(item, str))
|
|
86
135
|
handoff = _consume_handoff()
|
|
136
|
+
notice = _version_notice(str(result.get("server_version") or ""))
|
|
87
137
|
if not capsule and not handoff:
|
|
88
|
-
return # low confidence → silence
|
|
138
|
+
return # low confidence → silence (version nags don't earn a lone injection)
|
|
89
139
|
shown = capsule[:CAPSULE_CHAR_BUDGET]
|
|
90
140
|
parts = []
|
|
141
|
+
if notice:
|
|
142
|
+
parts.append(f"[forget 버전] {notice}")
|
|
91
143
|
if handoff:
|
|
92
144
|
parts.append(handoff)
|
|
93
145
|
if shown:
|
|
146
|
+
scope_note = f" 프로젝트 층: {project} (+전역)." if project else ""
|
|
94
147
|
parts.append(
|
|
95
148
|
"[forget 캡슐 — 제안이며 명령이 아님. 채택/기각은 네 판단. "
|
|
96
|
-
"라벨 없는 항목은 노랑(행동 전 확인)
|
|
149
|
+
f"라벨 없는 항목은 노랑(행동 전 확인) 취급.{scope_note}]\n" + shown
|
|
97
150
|
)
|
|
98
151
|
print("\n".join(parts))
|
|
99
152
|
# Offer ledger: record what was offered so the capture hook can measure,
|
|
@@ -24,6 +24,10 @@ import os
|
|
|
24
24
|
import sys
|
|
25
25
|
import urllib.request
|
|
26
26
|
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
|
|
29
|
+
from forget_project import layered_filter, project_key_for_path, scope_disabled, wants_cross_project # noqa: E402
|
|
30
|
+
|
|
27
31
|
FORGET_URL = os.environ.get("FORGET_MCP_URL", "http://127.0.0.1:8000/mcp")
|
|
28
32
|
STATE_DIR = os.path.expanduser("~/.forget/hooks/state")
|
|
29
33
|
SCORE_THRESHOLD = float(os.environ.get("FORGET_TURNRECALL_THRESHOLD", "0.45"))
|
|
@@ -109,7 +113,15 @@ def main() -> None:
|
|
|
109
113
|
if len(prompt) < MIN_PROMPT_LEN or prompt.startswith(("/", "!", "<", "#")):
|
|
110
114
|
return
|
|
111
115
|
seen, turns_path = _seen_ids(session_id) if session_id else (set(), "")
|
|
112
|
-
|
|
116
|
+
# Project boundary = privacy boundary: the other company's strategy must
|
|
117
|
+
# not surface mid-session here just because the words rhyme. Crossing is
|
|
118
|
+
# possible, but only when the user asks for it — and it says so when it does.
|
|
119
|
+
project = None if scope_disabled() else project_key_for_path(hook_input.get("cwd") or os.getcwd())
|
|
120
|
+
crossed = bool(project) and wants_cross_project(prompt)
|
|
121
|
+
search_args: dict = {"query": prompt[:300], "top_k": MAX_RECALLS + 2}
|
|
122
|
+
if project and not crossed:
|
|
123
|
+
search_args["filters"] = layered_filter(project)
|
|
124
|
+
result = _rpc("search_memories", search_args)
|
|
113
125
|
picks = []
|
|
114
126
|
conflict_pairs: dict[tuple[str, str], None] = {}
|
|
115
127
|
for item in result.get("results") or []:
|
|
@@ -117,8 +129,14 @@ def main() -> None:
|
|
|
117
129
|
memory_id = str(item.get("id") or "")
|
|
118
130
|
if not memory_id or memory_id in seen:
|
|
119
131
|
continue
|
|
120
|
-
|
|
132
|
+
metadata = item.get("metadata") or {}
|
|
133
|
+
if metadata.get("hook"):
|
|
121
134
|
continue # session-capture pointers are for rehydration, not recall
|
|
135
|
+
if metadata.get("assertion_kind") == "task_state":
|
|
136
|
+
# Fluid-layer task ledger rows travel via get_task_state/capsule
|
|
137
|
+
# only; surfacing them as turn recalls is friction F2's C2 cause
|
|
138
|
+
# (long claim texts farm phrase_bonus regardless of topic).
|
|
139
|
+
continue
|
|
122
140
|
pair = _conflict_pair(item)
|
|
123
141
|
if pair:
|
|
124
142
|
if score >= CONFLICT_THRESHOLD:
|
|
@@ -152,7 +170,9 @@ def main() -> None:
|
|
|
152
170
|
lines.append(f"- (현재) {new_text}")
|
|
153
171
|
lines.append(f"- (red/구본) {old_text}")
|
|
154
172
|
if picks:
|
|
155
|
-
|
|
173
|
+
header = "[forget 회상 — 이 턴과 관련된 기억 제안. green=행동 근거 OK, yellow=행동 전 확인, red=참고만"
|
|
174
|
+
header += " / 프로젝트 경계를 넘어 검색함]" if crossed else "]"
|
|
175
|
+
lines.append(header)
|
|
156
176
|
lines += [f"- ({light}) {memory}" for _, light, memory in picks]
|
|
157
177
|
print("\n".join(lines))
|
|
158
178
|
if session_id and turns_path:
|
package/bin/forget-connect.js
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { main } from "../src/cli.js";
|
|
4
4
|
|
|
5
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
6
|
+
stream.on("error", (error) => {
|
|
7
|
+
if (error?.code === "EPIPE") process.exit(0);
|
|
8
|
+
throw error;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
main().catch((error) => {
|
|
6
13
|
const message = error instanceof Error ? error.message : String(error);
|
|
7
14
|
console.error(`forget-connect: ${message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forget-connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Safely connect Claude Code, Codex, and Claude Desktop to Forget memory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -35,4 +35,4 @@
|
|
|
35
35
|
"directory": "packages/forget-connect"
|
|
36
36
|
},
|
|
37
37
|
"homepage": "https://multi-turn.ai"
|
|
38
|
-
}
|
|
38
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import readline from "node:readline/promises";
|
|
3
4
|
import { stdin, stdout, stderr } from "node:process";
|
|
4
5
|
import {
|
|
@@ -7,13 +8,15 @@ import {
|
|
|
7
8
|
ConfigError,
|
|
8
9
|
applyPlan,
|
|
9
10
|
buildPlan,
|
|
11
|
+
configuredServerUrl,
|
|
10
12
|
detectClients,
|
|
11
|
-
detectInstalledScope,
|
|
12
13
|
getClients,
|
|
13
14
|
inspectClients,
|
|
14
15
|
normalizeUrl,
|
|
15
16
|
redactUrlForDisplay,
|
|
17
|
+
scopeFromUrl,
|
|
16
18
|
scopedMcpUrl,
|
|
19
|
+
CANONICAL_APP_ID,
|
|
17
20
|
validateScopeId,
|
|
18
21
|
validateApiKey,
|
|
19
22
|
} from "./core.js";
|
|
@@ -41,10 +44,17 @@ Usage:
|
|
|
41
44
|
|
|
42
45
|
Options:
|
|
43
46
|
--client <ids> Comma-separated: claude-code,codex,claude-desktop,all
|
|
44
|
-
--url <url> MCP URL (default: ${DEFAULT_MCP_URL})
|
|
47
|
+
--url <url> Exact MCP URL to install (default base: ${DEFAULT_MCP_URL})
|
|
45
48
|
--hosted Use the managed Forget service (legacy) instead of a local server
|
|
46
49
|
--user-id <id> Memory user scope (pair with --app-id)
|
|
47
50
|
--app-id <id> Project/app scope (pair with --user-id)
|
|
51
|
+
--no-scope Install the shared unscoped /mcp endpoint (legacy behavior)
|
|
52
|
+
|
|
53
|
+
Scope:
|
|
54
|
+
Local connections default to one canonical scoped endpoint (all clients share it):
|
|
55
|
+
/mcp/<client>/http/<os-username>. This keeps each user's and client's
|
|
56
|
+
memories isolated. Override with --user-id/--app-id, or opt out with
|
|
57
|
+
--no-scope. An explicit --url is installed verbatim.
|
|
48
58
|
--no-auth Connect without a Bearer token
|
|
49
59
|
--no-rules Do not manage CLAUDE.md or AGENTS.md instruction blocks
|
|
50
60
|
--no-hooks Do not install Claude Code memory hooks (session capsule,
|
|
@@ -106,6 +116,11 @@ export function parseArgs(argv, env = process.env) {
|
|
|
106
116
|
hostedFlag: false,
|
|
107
117
|
userId: env.FORGET_USER_ID?.trim() || "",
|
|
108
118
|
appId: env.FORGET_APP_ID?.trim() || "",
|
|
119
|
+
noScope: false,
|
|
120
|
+
baseUrl: "",
|
|
121
|
+
hosted: false,
|
|
122
|
+
scope: null,
|
|
123
|
+
defaultScope: null,
|
|
109
124
|
auth: true,
|
|
110
125
|
rules: true,
|
|
111
126
|
hooks: true,
|
|
@@ -148,6 +163,8 @@ export function parseArgs(argv, env = process.env) {
|
|
|
148
163
|
index += 1;
|
|
149
164
|
} else if (arg.startsWith("--app-id=")) {
|
|
150
165
|
options.appId = arg.slice("--app-id=".length);
|
|
166
|
+
} else if (arg === "--no-scope") {
|
|
167
|
+
options.noScope = true;
|
|
151
168
|
} else if (arg === "--no-auth") {
|
|
152
169
|
options.auth = false;
|
|
153
170
|
} else if (arg === "--no-rules") {
|
|
@@ -194,6 +211,9 @@ export function parseArgs(argv, env = process.env) {
|
|
|
194
211
|
if (Boolean(options.userId) !== Boolean(options.appId)) {
|
|
195
212
|
throw new ConfigError("--user-id and --app-id must be provided together.");
|
|
196
213
|
}
|
|
214
|
+
if (options.noScope && options.userId) {
|
|
215
|
+
throw new ConfigError("--no-scope and --user-id/--app-id are mutually exclusive.");
|
|
216
|
+
}
|
|
197
217
|
options.scope = options.userId
|
|
198
218
|
? {
|
|
199
219
|
userId: validateScopeId(options.userId, "user_id"),
|
|
@@ -203,9 +223,52 @@ export function parseArgs(argv, env = process.env) {
|
|
|
203
223
|
options.url = options.scope
|
|
204
224
|
? scopedMcpUrl(options.baseUrl, options.scope)
|
|
205
225
|
: options.baseUrl;
|
|
226
|
+
// Cold-install default: scope each client into its own memory pool at
|
|
227
|
+
// /mcp/<client>/http/<os-username>. The unscoped /mcp endpoint pools every
|
|
228
|
+
// client's memories into the server's fallback scope (cold-install audit
|
|
229
|
+
// 2026-07-29), so plain /mcp is now the opt-in (--no-scope), not the
|
|
230
|
+
// default. An explicit --url stays verbatim, and hosted keeps requiring an
|
|
231
|
+
// explicit account identity — the OS username is not a hosted account.
|
|
232
|
+
options.defaultScope = null;
|
|
233
|
+
if (!options.scope && !options.noScope && !options.hosted && !options.urlExplicit) {
|
|
234
|
+
const osUser = defaultScopeUserId(env);
|
|
235
|
+
if (osUser) options.defaultScope = { userId: osUser };
|
|
236
|
+
}
|
|
206
237
|
return options;
|
|
207
238
|
}
|
|
208
239
|
|
|
240
|
+
function defaultScopeUserId(env) {
|
|
241
|
+
let candidate = "";
|
|
242
|
+
try {
|
|
243
|
+
candidate = os.userInfo().username?.trim() || "";
|
|
244
|
+
} catch {
|
|
245
|
+
candidate = "";
|
|
246
|
+
}
|
|
247
|
+
candidate = candidate || env.USER?.trim() || env.USERNAME?.trim() || "";
|
|
248
|
+
if (!candidate) return "";
|
|
249
|
+
try {
|
|
250
|
+
return validateScopeId(candidate, "user_id");
|
|
251
|
+
} catch {
|
|
252
|
+
// An exotic username must degrade to the unscoped legacy behavior, not
|
|
253
|
+
// block the connect.
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function urlForClient(options, client) {
|
|
259
|
+
if (options.scope) return options.url;
|
|
260
|
+
if (options.defaultScope && client) {
|
|
261
|
+
// Every client shares the canonical pool; which tool wrote a memory is
|
|
262
|
+
// provenance, not a scope boundary (issue #27). A per-client pool made
|
|
263
|
+
// Codex writes invisible to Claude and vice versa.
|
|
264
|
+
return scopedMcpUrl(options.baseUrl, {
|
|
265
|
+
userId: options.defaultScope.userId,
|
|
266
|
+
appId: CANONICAL_APP_ID,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return options.url;
|
|
270
|
+
}
|
|
271
|
+
|
|
209
272
|
function requestedClientIds(values) {
|
|
210
273
|
const ids = values
|
|
211
274
|
.flatMap((value) => value.split(","))
|
|
@@ -440,36 +503,54 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
440
503
|
return;
|
|
441
504
|
}
|
|
442
505
|
|
|
506
|
+
const urlOverrides = new Map();
|
|
507
|
+
const urlFor = (client) => urlOverrides.get(client.id) ?? urlForClient(options, client);
|
|
508
|
+
|
|
443
509
|
if (options.action === "doctor" && !options.scope && !options.urlExplicit && !options.hostedFlag) {
|
|
444
|
-
// A user who connected with
|
|
445
|
-
// `forget-connect doctor` next; comparing their scoped install
|
|
446
|
-
//
|
|
447
|
-
// installed config already carries.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
510
|
+
// A user who connected with an explicit or per-client default scope will
|
|
511
|
+
// run a bare `forget-connect doctor` next; comparing their scoped install
|
|
512
|
+
// against a freshly computed URL would report false failures. Adopt the
|
|
513
|
+
// scope each installed config already carries.
|
|
514
|
+
let first = null;
|
|
515
|
+
for (const client of clients) {
|
|
516
|
+
let raw = "";
|
|
517
|
+
try {
|
|
518
|
+
raw = await readFile(client.configPath, "utf8");
|
|
519
|
+
} catch {
|
|
520
|
+
raw = "";
|
|
521
|
+
}
|
|
522
|
+
const installed = scopeFromUrl(configuredServerUrl(client, raw));
|
|
523
|
+
if (!installed) continue;
|
|
524
|
+
urlOverrides.set(client.id, scopedMcpUrl(installed.baseUrl, installed));
|
|
525
|
+
if (!first) first = installed;
|
|
526
|
+
}
|
|
527
|
+
if (first) {
|
|
528
|
+
options.scope = { userId: first.userId, appId: first.appId };
|
|
529
|
+
options.baseUrl = first.baseUrl;
|
|
452
530
|
options.hosted = isHostedBaseUrl(options.baseUrl);
|
|
453
531
|
options.url = scopedMcpUrl(options.baseUrl, options.scope);
|
|
454
|
-
stdout
|
|
455
|
-
|
|
532
|
+
const scopeNoticeStream = options.json ? stderr : stdout;
|
|
533
|
+
scopeNoticeStream.write(
|
|
534
|
+
`Scope detected from installed config: user ${first.userId} · app ${first.appId}\n`,
|
|
456
535
|
);
|
|
457
536
|
}
|
|
458
537
|
}
|
|
459
538
|
|
|
460
539
|
const apiKey = await apiKeyFor(options, env);
|
|
461
540
|
if (options.action === "doctor") {
|
|
541
|
+
const probeUrl = clients.length ? urlFor(clients[0]) : options.url;
|
|
542
|
+
const probeScope = options.scope ?? scopeFromUrl(probeUrl);
|
|
462
543
|
const local = localDoctorResults(
|
|
463
|
-
await inspectClients(clients, { url: options.url, apiKey }),
|
|
544
|
+
await inspectClients(clients, { url: options.url, apiKey, urlFor }),
|
|
464
545
|
{ requireRules: options.rules },
|
|
465
546
|
);
|
|
466
547
|
const remote = local.every((client) => client.ok)
|
|
467
548
|
? await doctorRemote({
|
|
468
|
-
url:
|
|
549
|
+
url: probeUrl,
|
|
469
550
|
apiKey,
|
|
470
551
|
timeoutMs: options.timeoutMs,
|
|
471
552
|
clientVersion: await version(),
|
|
472
|
-
expectedScope:
|
|
553
|
+
expectedScope: probeScope,
|
|
473
554
|
requireScope: options.hosted,
|
|
474
555
|
})
|
|
475
556
|
: {
|
|
@@ -484,7 +565,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
484
565
|
required_tools: [],
|
|
485
566
|
missing_tools: [],
|
|
486
567
|
scope_probe: {
|
|
487
|
-
requested: Boolean(
|
|
568
|
+
requested: Boolean(probeScope),
|
|
488
569
|
required: options.hosted,
|
|
489
570
|
ok: false,
|
|
490
571
|
skipped: true,
|
|
@@ -495,8 +576,8 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
495
576
|
: null;
|
|
496
577
|
const result = {
|
|
497
578
|
ok: local.every((client) => client.ok) && remote.ok && (hooksStatus?.ok ?? true),
|
|
498
|
-
url: redactUrlForDisplay(
|
|
499
|
-
scope: { configured: Boolean(
|
|
579
|
+
url: redactUrlForDisplay(probeUrl),
|
|
580
|
+
scope: { configured: Boolean(probeScope) },
|
|
500
581
|
clients: local,
|
|
501
582
|
hooks: hooksStatus,
|
|
502
583
|
remote,
|
|
@@ -508,6 +589,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
508
589
|
}
|
|
509
590
|
const changes = await buildPlan(options.action, clients, {
|
|
510
591
|
url: options.url,
|
|
592
|
+
urlFor,
|
|
511
593
|
apiKey,
|
|
512
594
|
installInstructionRules: options.rules,
|
|
513
595
|
migrateLegacy: options.migrateLegacy,
|
|
@@ -536,7 +618,7 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
536
618
|
if (!error || error.code !== "ENOENT") throw error;
|
|
537
619
|
}
|
|
538
620
|
const settingsNext = options.action === "connect"
|
|
539
|
-
? connectHooksSettings(settingsRaw, { hooksDir, url:
|
|
621
|
+
? connectHooksSettings(settingsRaw, { hooksDir, url: urlFor(claudeCode) })
|
|
540
622
|
: disconnectHooksSettings(settingsRaw);
|
|
541
623
|
if (settingsRaw !== settingsNext) {
|
|
542
624
|
changes.push({
|
|
@@ -586,6 +668,13 @@ export async function run(argv = process.argv.slice(2), env = process.env) {
|
|
|
586
668
|
for (const filePath of hookScriptPaths) stdout.write(` ${hookVerb} ${filePath}\n`);
|
|
587
669
|
}
|
|
588
670
|
if (options.action === "connect") {
|
|
671
|
+
if (options.scope) {
|
|
672
|
+
stdout.write(` scope: user ${options.scope.userId} · app ${options.scope.appId}\n`);
|
|
673
|
+
} else if (options.defaultScope) {
|
|
674
|
+
stdout.write(
|
|
675
|
+
` scope: user ${options.defaultScope.userId} · app forget — one canonical pool for all clients (--no-scope opts out)\n`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
589
678
|
if (manageHooks) {
|
|
590
679
|
stdout.write(" hooks: session capsule, per-turn recall, conflict alerts, session capture (needs python3 on PATH)\n");
|
|
591
680
|
}
|
package/src/core.js
CHANGED
|
@@ -15,6 +15,12 @@ import os from "node:os";
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_MCP_URL = "http://localhost:8000/mcp";
|
|
18
|
+
// One canonical memory pool per user. Naming the pool after the client
|
|
19
|
+
// (codex, claude-code, …) invents an empty per-tool scope and re-creates the
|
|
20
|
+
// fragmentation the scoped default exists to prevent: memories written from
|
|
21
|
+
// one tool become invisible to every other (issue #27, field report round 3).
|
|
22
|
+
// The client's identity is provenance, not an isolation boundary.
|
|
23
|
+
export const CANONICAL_APP_ID = "forget";
|
|
18
24
|
export const HOSTED_MCP_URL = "https://api.multi-turn.ai/mcp";
|
|
19
25
|
export const SERVER_KEY = "forget";
|
|
20
26
|
export const BACKUP_SUFFIX = ".forget-backup";
|
|
@@ -582,10 +588,11 @@ function rulesCurrent(raw) {
|
|
|
582
588
|
}
|
|
583
589
|
}
|
|
584
590
|
|
|
585
|
-
export async function inspectClients(clients, { url = "", apiKey = "" } = {}) {
|
|
586
|
-
const expectedUrl = url ? normalizeUrl(url) : "";
|
|
591
|
+
export async function inspectClients(clients, { url = "", apiKey = "", urlFor = null } = {}) {
|
|
587
592
|
return Promise.all(
|
|
588
593
|
clients.map(async (client) => {
|
|
594
|
+
const clientUrl = urlFor ? urlFor(client) : url;
|
|
595
|
+
const expectedUrl = clientUrl ? normalizeUrl(clientUrl) : "";
|
|
589
596
|
const configRaw = await readOptional(client.configPath);
|
|
590
597
|
const rulesRaw = client.rulesPath ? await readOptional(client.rulesPath) : "";
|
|
591
598
|
const expected = expectedUrl
|
|
@@ -616,6 +623,7 @@ export async function buildPlan(
|
|
|
616
623
|
installInstructionRules = true,
|
|
617
624
|
migrateLegacy = true,
|
|
618
625
|
legacyUrls = [url],
|
|
626
|
+
urlFor = null,
|
|
619
627
|
} = {},
|
|
620
628
|
) {
|
|
621
629
|
if (!['connect', 'disconnect'].includes(action)) {
|
|
@@ -623,17 +631,20 @@ export async function buildPlan(
|
|
|
623
631
|
}
|
|
624
632
|
const changes = [];
|
|
625
633
|
for (const client of clients) {
|
|
634
|
+
// urlFor lets connect scope each client into its own memory pool
|
|
635
|
+
// (/mcp/{app}/http/{user}) while everything else shares one plan.
|
|
636
|
+
const clientUrl = urlFor ? urlFor(client) : url;
|
|
626
637
|
const configRaw = await readOptional(client.configPath);
|
|
627
638
|
let configNext;
|
|
628
639
|
if (client.kind === "toml") {
|
|
629
640
|
configNext = action === "connect"
|
|
630
|
-
? connectToml(configRaw, { url, apiKey, migrateLegacy, legacyUrls })
|
|
641
|
+
? connectToml(configRaw, { url: clientUrl, apiKey, migrateLegacy, legacyUrls })
|
|
631
642
|
: disconnectToml(configRaw);
|
|
632
643
|
} else {
|
|
633
644
|
configNext = action === "connect"
|
|
634
645
|
? connectJson(configRaw, {
|
|
635
646
|
clientId: client.id,
|
|
636
|
-
url,
|
|
647
|
+
url: clientUrl,
|
|
637
648
|
apiKey,
|
|
638
649
|
migrateLegacy,
|
|
639
650
|
legacyUrls,
|
package/src/hooks.js
CHANGED
|
@@ -20,6 +20,8 @@ export const HOOK_SCRIPTS = [
|
|
|
20
20
|
"forget_sessionstart.py",
|
|
21
21
|
"forget_turnrecall.py",
|
|
22
22
|
"forget_capture.py",
|
|
23
|
+
"forget_project.py", // shared project-boundary detection (imported by the others)
|
|
24
|
+
"forget_projecttag.py", // PreToolUse: stamps memory/task writes with the cwd's project
|
|
23
25
|
];
|
|
24
26
|
|
|
25
27
|
// Ownership marker: every command we install invokes a script whose path
|
|
@@ -75,6 +77,12 @@ export function hookEntries({ hooksDir, url }) {
|
|
|
75
77
|
timeout: 10,
|
|
76
78
|
statusMessage: "forget: capturing session + outcome",
|
|
77
79
|
},
|
|
80
|
+
PreToolUse: {
|
|
81
|
+
command: hookCommand("forget_projecttag.py", { hooksDir, url }),
|
|
82
|
+
timeout: 5,
|
|
83
|
+
statusMessage: "forget: tagging project scope",
|
|
84
|
+
matcher: "mcp__forget__add_memory|mcp__forget__record_task_state",
|
|
85
|
+
},
|
|
78
86
|
};
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -124,14 +132,16 @@ export function connectHooksSettings(raw, { hooksDir, url }) {
|
|
|
124
132
|
const hooks = validatedHooks(config, "settings.json");
|
|
125
133
|
for (const [event, entry] of Object.entries(hookEntries({ hooksDir, url }))) {
|
|
126
134
|
const groups = withoutOurHooks(hooks[event] ?? []);
|
|
127
|
-
|
|
135
|
+
const group = {
|
|
128
136
|
hooks: [{
|
|
129
137
|
type: "command",
|
|
130
138
|
command: entry.command,
|
|
131
139
|
timeout: entry.timeout,
|
|
132
140
|
statusMessage: entry.statusMessage,
|
|
133
141
|
}],
|
|
134
|
-
}
|
|
142
|
+
};
|
|
143
|
+
if (entry.matcher) group.matcher = entry.matcher;
|
|
144
|
+
groups.push(group);
|
|
135
145
|
hooks[event] = groups;
|
|
136
146
|
}
|
|
137
147
|
config.hooks = hooks;
|