forget-connect 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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/mcp` —
12
- your memories stay on your machine. The guided flow detects installed
13
- clients and then:
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
- _rpc(
115
- "add_memory",
116
- {
117
- "text": text,
118
- "infer": False,
119
- "source_role": "tool",
120
- "metadata": {
121
- "hook": event,
122
- "session_id": session_id,
123
- "transcript_path": transcript_path,
124
- "trigger": label,
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,6 +16,10 @@ 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
@@ -58,17 +62,26 @@ def main() -> None:
58
62
  hook_input = json.load(sys.stdin)
59
63
  cwd = str(hook_input.get("cwd") or os.getcwd())
60
64
  source = str(hook_input.get("source") or "startup")
65
+ project = None if scope_disabled() else project_key_for_path(cwd)
66
+ arguments: dict = {
67
+ "query": f"session {source} in {cwd} — active tasks, open loops, recent decisions",
68
+ "include_debug": False,
69
+ }
70
+ # F2's cause was a capsule that mixed layers: heartbeat and Quant rows
71
+ # invading a devloop session. The capsule now reads this project's layer
72
+ # plus the global one — untagged rows included, so nothing pre-existing
73
+ # disappears the day this lands.
74
+ project_filter = layered_filter(project)
75
+ if project_filter:
76
+ arguments["filters"] = project_filter
77
+ # Memory recall reads the layered OR; the task/goal ledger has its own
78
+ # storage (claims + epochs) and takes the project key explicitly.
79
+ arguments["project"] = project
61
80
  payload = {
62
81
  "jsonrpc": "2.0",
63
82
  "id": 1,
64
83
  "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
- },
84
+ "params": {"name": "prepare_context_autopilot", "arguments": arguments},
72
85
  }
73
86
  request = urllib.request.Request(
74
87
  FORGET_URL,
@@ -91,9 +104,10 @@ def main() -> None:
91
104
  if handoff:
92
105
  parts.append(handoff)
93
106
  if shown:
107
+ scope_note = f" 프로젝트 층: {project} (+전역)." if project else ""
94
108
  parts.append(
95
109
  "[forget 캡슐 — 제안이며 명령이 아님. 채택/기각은 네 판단. "
96
- "라벨 없는 항목은 노랑(행동 전 확인) 취급]\n" + shown
110
+ f"라벨 없는 항목은 노랑(행동 전 확인) 취급.{scope_note}]\n" + shown
97
111
  )
98
112
  print("\n".join(parts))
99
113
  # 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
- result = _rpc("search_memories", {"query": prompt[:300], "top_k": MAX_RECALLS + 2})
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
- if (item.get("metadata") or {}).get("hook"):
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
- lines.append("[forget 회상 — 이 턴과 관련된 기억 제안. green=행동 근거 OK, yellow=행동 전 확인, red=참고만]")
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:
@@ -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.4.0",
3
+ "version": "0.5.0",
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 --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;
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.write(
455
- `Scope detected from installed config: user ${installed.userId} · app ${installed.appId}\n`,
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: options.url,
549
+ url: probeUrl,
469
550
  apiKey,
470
551
  timeoutMs: options.timeoutMs,
471
552
  clientVersion: await version(),
472
- expectedScope: options.scope,
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(options.scope),
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(options.url),
499
- scope: { configured: Boolean(options.scope) },
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: options.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
- groups.push({
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;