ltcai 6.3.0 → 6.4.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.
@@ -27,12 +27,32 @@ class KnowledgeGraphIngestRequest(BaseModel):
27
27
  metadata: Optional[Dict[str, Any]] = None
28
28
 
29
29
 
30
+ def _format_context(matches: list, limit: int) -> str:
31
+ """Mirror ``KnowledgeGraphRetrievalMixin.context_for_query`` formatting for a
32
+ pre-filtered match list, so scoped callers get identical context lines minus
33
+ the rows they are not allowed to see."""
34
+ lines = []
35
+ for match in matches[:limit]:
36
+ meta = match.get("metadata") or {}
37
+ source = (
38
+ meta.get("relative_path")
39
+ or meta.get("filename")
40
+ or meta.get("conversation_id")
41
+ or meta.get("source")
42
+ or match.get("id")
43
+ )
44
+ summary = " ".join(str(match.get("summary") or "").split())[:700]
45
+ lines.append(f"- [{match.get('type')}] {match.get('title')} | source={source} | {summary}")
46
+ return "\n".join(lines)
47
+
48
+
30
49
  def create_knowledge_graph_router(
31
50
  *,
32
51
  get_graph: Callable[[], Any],
33
52
  require_graph: Callable[[], None],
34
53
  require_user: Callable[[Request], str],
35
54
  static_dir: Path,
55
+ allowed_workspaces_for: Optional[Callable[[Optional[str]], Any]] = None,
36
56
  ) -> APIRouter:
37
57
  router = APIRouter()
38
58
 
@@ -40,6 +60,21 @@ def create_knowledge_graph_router(
40
60
  require_graph()
41
61
  return get_graph()
42
62
 
63
+ def _scoped(request: Request):
64
+ """Authenticate the caller and resolve their allowed workspace set.
65
+
66
+ Returns ``(graph, allowed)``. ``allowed is None`` means no scoping
67
+ (single-user / no-auth mode); otherwise it is the set of workspace ids
68
+ the caller may read. Legacy-global rows (no workspace) stay visible —
69
+ the documented pre-v4 compatibility behavior enforced by
70
+ ``filter_scoped_nodes``.
71
+ """
72
+ user = require_user(request)
73
+ allowed = None
74
+ if allowed_workspaces_for is not None and user:
75
+ allowed = allowed_workspaces_for(user)
76
+ return graph(), allowed
77
+
43
78
  @router.get("/graph")
44
79
  async def knowledge_graph_page(request: Request):
45
80
  """Serve the interactive knowledge graph canvas UI."""
@@ -81,8 +116,10 @@ def create_knowledge_graph_router(
81
116
 
82
117
  @router.get("/knowledge-graph/graph")
83
118
  async def knowledge_graph_data(request: Request, limit: int = 300):
84
- require_user(request)
85
- return graph().graph(limit)
119
+ kg, allowed = _scoped(request)
120
+ if allowed is None:
121
+ return kg.graph(limit)
122
+ return kg.graph(limit, allowed_workspaces=allowed)
86
123
 
87
124
  @router.get("/knowledge-graph/documents")
88
125
  async def knowledge_graph_documents(request: Request, limit: int = 200):
@@ -91,27 +128,51 @@ def create_knowledge_graph_router(
91
128
  Backs the Files view so uploaded content is visible end-to-end:
92
129
  upload → Files → Knowledge Graph → Hybrid Search → Chat.
93
130
  """
94
- require_user(request)
95
- return graph().list_documents(limit)
131
+ kg, allowed = _scoped(request)
132
+ payload = kg.list_documents(limit)
133
+ if allowed is not None:
134
+ documents = kg.filter_scoped_nodes(payload.get("documents", []), allowed)
135
+ payload = {**payload, "documents": documents, "total": len(documents)}
136
+ return payload
96
137
 
97
138
  @router.get("/knowledge-graph/search")
98
139
  async def knowledge_graph_search(q: str, request: Request, limit: int = 30):
99
- require_user(request)
140
+ kg, allowed = _scoped(request)
100
141
  if not q or not q.strip():
101
142
  return {"query": q, "matches": []}
102
- return graph().search(q, limit)
143
+ payload = kg.search(q, limit)
144
+ if allowed is not None:
145
+ payload = {**payload, "matches": kg.filter_scoped_nodes(payload.get("matches", []), allowed)}
146
+ return payload
103
147
 
104
148
  @router.get("/knowledge-graph/context")
105
149
  async def knowledge_graph_context(q: str, request: Request, limit: int = 6):
106
- require_user(request)
107
- return {"query": q, "context": graph().context_for_query(q, limit)}
150
+ kg, allowed = _scoped(request)
151
+ if allowed is None:
152
+ return {"query": q, "context": kg.context_for_query(q, limit)}
153
+ # Scoped mode: derive context from scope-filtered search matches so the
154
+ # RAG context never carries content from workspaces the caller can't read.
155
+ matches = kg.filter_scoped_nodes(kg.search(q, limit).get("matches", []), allowed)
156
+ return {"query": q, "context": _format_context(matches, limit)}
108
157
 
109
158
  @router.get("/knowledge-graph/neighbors/{node_id:path}")
110
159
  async def knowledge_graph_neighbors(node_id: str, request: Request):
111
- require_user(request)
160
+ kg, allowed = _scoped(request)
112
161
  if not node_id:
113
162
  raise HTTPException(status_code=400, detail="node_id required")
114
- return graph().neighbors(node_id)
163
+ if allowed is not None and not kg.filter_scoped_nodes([{"id": node_id}], allowed):
164
+ raise HTTPException(status_code=404, detail="node not found")
165
+ payload = kg.neighbors(node_id)
166
+ if allowed is not None:
167
+ neighbors = kg.filter_scoped_nodes(payload.get("neighbors", []), allowed)
168
+ kept = {n.get("id") for n in neighbors}
169
+ edges = [
170
+ e for e in payload.get("edges", [])
171
+ if (e.get("from") == node_id or e.get("from") in kept)
172
+ and (e.get("to") == node_id or e.get("to") in kept)
173
+ ]
174
+ payload = {**payload, "neighbors": neighbors, "edges": edges}
175
+ return payload
115
176
 
116
177
  @router.post("/knowledge-graph/ingest")
117
178
  async def knowledge_graph_ingest(req: KnowledgeGraphIngestRequest, request: Request):
@@ -48,6 +48,7 @@ def create_local_files_router(
48
48
  local_kg_watcher,
49
49
  hooks=None,
50
50
  data_dir: Optional[Path] = None,
51
+ allowed_workspaces_for=None,
51
52
  ) -> APIRouter:
52
53
  router = APIRouter()
53
54
 
@@ -210,6 +211,7 @@ def create_local_files_router(
210
211
  require_graph=require_graph,
211
212
  require_user=require_user,
212
213
  static_dir=static_dir,
214
+ allowed_workspaces_for=allowed_workspaces_for,
213
215
  )
214
216
  )
215
217
 
@@ -74,16 +74,16 @@ def create_memory_router(
74
74
  @router.post("/api/memory/prune")
75
75
  async def memory_prune(req: PruneRequest, request: Request):
76
76
  user = require_user(request)
77
- gate_write(request)
78
- result = service.prune(ids=req.ids, kind=req.kind, user_email=user)
77
+ scope = gate_write(request)
78
+ result = service.prune(ids=req.ids, kind=req.kind, user_email=user, workspace_id=scope)
79
79
  append_audit_event("memory_prune", user_email=user, count=result.get("count", 0))
80
80
  return result
81
81
 
82
82
  @router.post("/api/memory/compact")
83
83
  async def memory_compact(request: Request):
84
84
  user = require_user(request)
85
- gate_write(request)
86
- result = service.compact(user_email=user)
85
+ scope = gate_write(request)
86
+ result = service.compact(user_email=user, workspace_id=scope)
87
87
  append_audit_event("memory_compact", user_email=user, compacted=result.get("compacted", 0))
88
88
  return result
89
89
 
@@ -98,9 +98,9 @@ def create_memory_router(
98
98
  @router.post("/api/memory/clear")
99
99
  async def memory_clear(req: ClearRequest, request: Request):
100
100
  user = require_user(request)
101
- gate_write(request)
101
+ scope = gate_write(request)
102
102
  try:
103
- result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user)
103
+ result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user, workspace_id=scope)
104
104
  except ValueError as exc:
105
105
  raise HTTPException(status_code=400, detail=str(exc)) from exc
106
106
  append_audit_event("memory_clear", user_email=user, scope=req.scope)
@@ -51,7 +51,15 @@ class _ScopedSearchService:
51
51
  """Injects the caller's workspace scope into every search call —
52
52
  enforcement lives at this one chokepoint, not in each handler."""
53
53
 
54
- _SCOPED = {"keyword_search", "vector_search", "graph_search", "hybrid_search"}
54
+ _SCOPED = {
55
+ "keyword_search",
56
+ "vector_search",
57
+ "graph_search",
58
+ "hybrid_search",
59
+ "graph",
60
+ "node",
61
+ "relationships",
62
+ }
55
63
 
56
64
  def __init__(self, service: SearchService, allowed):
57
65
  self._service = service
@@ -202,6 +202,7 @@ def create_tools_router(
202
202
  install_mcp=None,
203
203
  mcp_public_item=None,
204
204
  hooks=None,
205
+ allowed_workspaces_for=None,
205
206
  ) -> APIRouter:
206
207
  if tool_context is not None:
207
208
  config = tool_context.config
@@ -227,6 +228,7 @@ def create_tools_router(
227
228
  install_mcp = tool_context.install_mcp
228
229
  mcp_public_item = tool_context.mcp_public_item
229
230
  hooks = tool_context.hooks
231
+ allowed_workspaces_for = tool_context.allowed_workspaces_for
230
232
 
231
233
  api_router = APIRouter()
232
234
  HOOKS = hooks
@@ -486,6 +488,7 @@ def create_tools_router(
486
488
  local_kg_watcher=LOCAL_KG_WATCHER,
487
489
  hooks=HOOKS,
488
490
  data_dir=DATA_DIR,
491
+ allowed_workspaces_for=allowed_workspaces_for,
489
492
  ))
490
493
  api_router.include_router(create_computer_use_router(
491
494
  model_router=router,
@@ -18,6 +18,7 @@ import threading
18
18
  from typing import TYPE_CHECKING, Any, Dict, List, Optional
19
19
 
20
20
  from latticeai.runtime.app_context_runtime import build_app_context
21
+ from latticeai.runtime.access_runtime import build_access_runtime
21
22
  from latticeai.runtime.bootstrap import build_session_runtime
22
23
  from latticeai.runtime.brain_runtime import build_brain_runtime
23
24
  from latticeai.runtime.chat_wiring import (
@@ -122,12 +123,11 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
122
123
  from latticeai.core.enterprise import (
123
124
  capability_registry,
124
125
  )
125
- from latticeai.core.policy import normalize_role, policy_matrix, require_capability
126
+ from latticeai.core.policy import policy_matrix
126
127
  from latticeai.core.users import (
127
128
  ensure_user_identity,
128
129
  load_users_file,
129
130
  migrate_knowledge_graph_identity,
130
- normalize_email,
131
131
  save_users_file,
132
132
  user_id_for_email as _user_id_for_email,
133
133
  )
@@ -813,42 +813,21 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
813
813
  def clear_conversation(conversation_id: str, started_at: Optional[str] = None) -> Dict:
814
814
  return CONVERSATIONS.clear_conversation(conversation_id, started_at=started_at)
815
815
 
816
- def get_user_role(email: str, users: Optional[Dict] = None) -> str:
817
- users = users or load_users()
818
- identity = str(email or "")
819
- normalized_email = normalize_email(identity)
820
- user = users.get(normalized_email) or users.get(identity) or next(
821
- (
822
- item for item in users.values()
823
- if isinstance(item, dict) and item.get("id") == identity
824
- ),
825
- {},
826
- )
827
- if isinstance(user, dict) and user.get("role"):
828
- return normalize_role(user["role"])
829
- admin_emails = {normalize_email(item) for item in CONFIG.admin_emails}
830
- if normalized_email in admin_emails:
831
- return "admin"
832
- first_email = next(iter(users), None)
833
- return "admin" if first_email == normalized_email else "user"
834
-
835
- def _extract_bearer_token(request: Request) -> Optional[str]:
836
- auth = request.headers.get("Authorization", "")
837
- if auth.startswith("Bearer "):
838
- return auth[7:].strip()
839
- return request.cookies.get("session_token")
840
-
841
- def get_current_user(request: Request) -> Optional[str]:
842
- token = _extract_bearer_token(request)
843
- if token:
844
- return get_session_email(token)
845
- return None
846
-
847
- def require_user(request: Request) -> str:
848
- email = get_current_user(request)
849
- if REQUIRE_AUTH and not email:
850
- raise HTTPException(status_code=401, detail="인증이 필요합니다.")
851
- return email or ""
816
+ _access_runtime = build_access_runtime(
817
+ config=CONFIG,
818
+ require_auth=REQUIRE_AUTH,
819
+ http_exception=HTTPException,
820
+ request_type=Request,
821
+ load_users=load_users,
822
+ get_session_email=get_session_email,
823
+ user_id_for_email=_user_id_for_email,
824
+ )
825
+ get_user_role = _access_runtime["get_user_role"]
826
+ _extract_bearer_token = _access_runtime["_extract_bearer_token"]
827
+ get_current_user = _access_runtime["get_current_user"]
828
+ require_user = _access_runtime["require_user"]
829
+ require_admin = _access_runtime["require_admin"]
830
+ public_user = _access_runtime["public_user"]
852
831
 
853
832
 
854
833
  # ── Rate limiting & file validation — delegated to latticeai.core.security ────
@@ -917,35 +896,6 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
917
896
  raise HTTPException(status_code=403, detail="승인된 파일 내용과 요청 내용이 다릅니다.")
918
897
 
919
898
 
920
- def require_admin(request: Request) -> tuple[str, Dict]:
921
- users = load_users()
922
- if not REQUIRE_AUTH:
923
- return "", users
924
- token = _extract_bearer_token(request)
925
- if token:
926
- email = get_session_email(token)
927
- if email:
928
- role = get_user_role(email, users)
929
- try:
930
- require_capability(role, "admin:users")
931
- return email, users
932
- except PermissionError:
933
- pass
934
- raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다.")
935
-
936
- def public_user(email: str, user: Dict, users: Dict) -> Dict:
937
- role = get_user_role(email, users)
938
- user_id = user.get("id") or _user_id_for_email(users, email)
939
- return {
940
- "id": user_id,
941
- "email": email,
942
- "identity": user_id,
943
- "name": user.get("name", ""),
944
- "nickname": user.get("nickname", ""),
945
- "role": role,
946
- "disabled": bool(user.get("disabled", False)),
947
- }
948
-
949
899
  def get_history_user(email: Optional[str], nickname: Optional[str] = None) -> Dict:
950
900
  if not email:
951
901
  return {"user_email": None, "user_nickname": nickname or None}
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "6.3.0"
14
+ MARKETPLACE_VERSION = "6.4.0"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent")
16
16
 
17
17
 
@@ -19,7 +19,7 @@ from pathlib import Path
19
19
  from typing import Any, Callable, Dict, Iterable, List, Optional
20
20
 
21
21
 
22
- WORKSPACE_OS_VERSION = "6.3.0"
22
+ WORKSPACE_OS_VERSION = "6.4.0"
23
23
 
24
24
  # Workspace types separate single-user Personal workspaces from shared
25
25
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -0,0 +1,97 @@
1
+ """Access-control helper closures for the app factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable, Dict, Optional
6
+
7
+
8
+ def build_access_runtime(
9
+ *,
10
+ config: Any,
11
+ require_auth: bool,
12
+ http_exception: Any,
13
+ request_type: Any,
14
+ load_users: Callable[[], Dict],
15
+ get_session_email: Callable[[str], Optional[str]],
16
+ user_id_for_email: Callable[[Dict, str], str],
17
+ ) -> Dict[str, Any]:
18
+ """Build user/admin access helpers without changing legacy call signatures."""
19
+
20
+ from latticeai.core.policy import normalize_role, require_capability
21
+ from latticeai.core.users import normalize_email
22
+
23
+ def get_user_role(email: str, users: Optional[Dict] = None) -> str:
24
+ users = users or load_users()
25
+ identity = str(email or "")
26
+ normalized_email = normalize_email(identity)
27
+ user = users.get(normalized_email) or users.get(identity) or next(
28
+ (
29
+ item
30
+ for item in users.values()
31
+ if isinstance(item, dict) and item.get("id") == identity
32
+ ),
33
+ {},
34
+ )
35
+ if isinstance(user, dict) and user.get("role"):
36
+ return normalize_role(user["role"])
37
+ admin_emails = {normalize_email(item) for item in config.admin_emails}
38
+ if normalized_email in admin_emails:
39
+ return "admin"
40
+ first_email = next(iter(users), None)
41
+ return "admin" if first_email == normalized_email else "user"
42
+
43
+ def extract_bearer_token(request: request_type) -> Optional[str]:
44
+ auth = request.headers.get("Authorization", "")
45
+ if auth.startswith("Bearer "):
46
+ return auth[7:].strip()
47
+ return request.cookies.get("session_token")
48
+
49
+ def get_current_user(request: request_type) -> Optional[str]:
50
+ token = extract_bearer_token(request)
51
+ if token:
52
+ return get_session_email(token)
53
+ return None
54
+
55
+ def require_user(request: request_type) -> str:
56
+ email = get_current_user(request)
57
+ if require_auth and not email:
58
+ raise http_exception(status_code=401, detail="인증이 필요합니다.")
59
+ return email or ""
60
+
61
+ def require_admin(request: request_type) -> tuple[str, Dict]:
62
+ users = load_users()
63
+ if not require_auth:
64
+ return "", users
65
+ token = extract_bearer_token(request)
66
+ if token:
67
+ email = get_session_email(token)
68
+ if email:
69
+ role = get_user_role(email, users)
70
+ try:
71
+ require_capability(role, "admin:users")
72
+ return email, users
73
+ except PermissionError:
74
+ pass
75
+ raise http_exception(status_code=403, detail="관리자 권한이 필요합니다.")
76
+
77
+ def public_user(email: str, user: Dict, users: Dict) -> Dict:
78
+ role = get_user_role(email, users)
79
+ user_id = user.get("id") or user_id_for_email(users, email)
80
+ return {
81
+ "id": user_id,
82
+ "email": email,
83
+ "identity": user_id,
84
+ "name": user.get("name", ""),
85
+ "nickname": user.get("nickname", ""),
86
+ "role": role,
87
+ "disabled": bool(user.get("disabled", False)),
88
+ }
89
+
90
+ return {
91
+ "get_user_role": get_user_role,
92
+ "_extract_bearer_token": extract_bearer_token,
93
+ "get_current_user": get_current_user,
94
+ "require_user": require_user,
95
+ "require_admin": require_admin,
96
+ "public_user": public_user,
97
+ }
@@ -88,6 +88,7 @@ def build_interaction_contexts(
88
88
  install_mcp=install_mcp,
89
89
  mcp_public_item=mcp_public_item,
90
90
  hooks=hooks,
91
+ allowed_workspaces_for=allowed_workspaces_for,
91
92
  )
92
93
  interaction_router_context = InteractionRouterContext(
93
94
  chat_context=chat_context,
@@ -133,8 +133,11 @@ class MemoryService:
133
133
 
134
134
  # ── Memory Manager: sources / usage / health ──────────────────────────
135
135
  def manager(self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
136
- ws_mem = self._workspace_memories(user_email=user_email, workspace_id="personal")
137
- project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
136
+ ws_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")
137
+ if workspace_id is None:
138
+ project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
139
+ else:
140
+ project_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
138
141
  snaps = self._snapshots(workspace_id=workspace_id)
139
142
  convs = self._conversations()
140
143
  kg_stats = self._kg_stats()
@@ -268,10 +271,13 @@ class MemoryService:
268
271
  # ── inspect a single tier ─────────────────────────────────────────────
269
272
  def inspect(self, source: str, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
270
273
  if source == "workspace":
271
- items = self._workspace_memories(user_email=user_email, workspace_id="personal")[:limit]
274
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")[:limit]
272
275
  return {"source": source, "items": items, "count": len(items)}
273
276
  if source == "project":
274
- items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
277
+ if workspace_id is None:
278
+ items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
279
+ else:
280
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)[:limit]
275
281
  return {"source": source, "items": items, "count": len(items)}
276
282
  if source == "agent":
277
283
  items = self._snapshots(workspace_id=workspace_id)[:limit]
@@ -287,12 +293,39 @@ class MemoryService:
287
293
  raise KeyError(source)
288
294
 
289
295
  # ── mutating operations ───────────────────────────────────────────────
290
- def prune(self, *, ids: Optional[List[str]] = None, kind: Optional[str] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
296
+ def prune(
297
+ self,
298
+ *,
299
+ ids: Optional[List[str]] = None,
300
+ kind: Optional[str] = None,
301
+ user_email: Optional[str] = None,
302
+ workspace_id: Optional[str] = None,
303
+ ) -> Dict[str, Any]:
304
+ # Ownership guard: a caller may only prune memories they own. Both the
305
+ # explicit-id and kind paths are intersected with the caller's own
306
+ # memories, so a forged id for another user's memory is refused, not
307
+ # silently deleted.
308
+ owned_ids = {
309
+ m["id"]
310
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
311
+ if m.get("id")
312
+ }
291
313
  removed: List[str] = []
292
- target_ids = list(ids or [])
314
+ skipped: List[str] = []
315
+ target_ids: List[str] = []
316
+ seen: set = set()
317
+ for mid in (ids or []):
318
+ if mid in seen:
319
+ continue
320
+ seen.add(mid)
321
+ if mid in owned_ids:
322
+ target_ids.append(mid)
323
+ else:
324
+ skipped.append(mid)
293
325
  if kind:
294
- for m in self._workspace_memories(user_email=user_email, workspace_id=None):
295
- if m.get("kind") == kind and m.get("id"):
326
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id):
327
+ if m.get("kind") == kind and m.get("id") and m["id"] not in seen:
328
+ seen.add(m["id"])
296
329
  target_ids.append(m["id"])
297
330
  for mid in target_ids:
298
331
  try:
@@ -300,14 +333,22 @@ class MemoryService:
300
333
  removed.append(mid)
301
334
  except Exception:
302
335
  continue
303
- return {"removed": removed, "count": len(removed)}
336
+ result: Dict[str, Any] = {"removed": removed, "count": len(removed)}
337
+ if skipped:
338
+ result["skipped"] = skipped
339
+ return result
304
340
 
305
- def compact(self, *, user_email: Optional[str] = None) -> Dict[str, Any]:
341
+ def compact(
342
+ self,
343
+ *,
344
+ user_email: Optional[str] = None,
345
+ workspace_id: Optional[str] = None,
346
+ ) -> Dict[str, Any]:
306
347
  """Dedupe workspace memories with identical (kind, content)."""
307
348
  seen: set = set()
308
349
  removed: List[str] = []
309
350
  # Oldest first so the first occurrence (oldest) is kept.
310
- memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=None)))
351
+ memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=workspace_id)))
311
352
  for m in memories:
312
353
  key = (m.get("kind"), str(m.get("content") or "").strip())
313
354
  if key in seen:
@@ -332,21 +373,23 @@ class MemoryService:
332
373
  return {"status": "error", "detail": str(exc)}
333
374
  return {"status": "error", "detail": f"Unknown rebuild target: {target}"}
334
375
 
335
- def clear(self, *, scope: str, confirm: bool = False, user_email: Optional[str] = None) -> Dict[str, Any]:
376
+ def clear(
377
+ self,
378
+ *,
379
+ scope: str,
380
+ confirm: bool = False,
381
+ user_email: Optional[str] = None,
382
+ workspace_id: Optional[str] = None,
383
+ ) -> Dict[str, Any]:
336
384
  if not confirm:
337
385
  raise ValueError("clear requires confirm=true")
338
386
  if scope in WORKSPACE_KINDS:
339
- result = self.prune(kind=scope, user_email=user_email)
387
+ result = self.prune(kind=scope, user_email=user_email, workspace_id=workspace_id)
340
388
  return {"cleared": scope, **result}
341
389
  if scope == "workspace":
342
- ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=None) if m.get("id")]
343
- result = self.prune(ids=ids, user_email=user_email)
390
+ ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id) if m.get("id")]
391
+ result = self.prune(ids=ids, user_email=user_email, workspace_id=workspace_id)
344
392
  return {"cleared": "workspace", **result}
345
393
  if scope == "graph":
346
- if not self._enable_graph:
347
- return {"status": "unavailable", "detail": "Knowledge graph disabled."}
348
- try:
349
- return {"cleared": "graph", "result": self._kg.clear_all()}
350
- except Exception as exc:
351
- return {"status": "error", "detail": str(exc)}
394
+ raise ValueError("graph clear is disabled from Memory Manager because it is not workspace-scoped")
352
395
  raise ValueError(f"unsupported clear scope: {scope}")
@@ -34,6 +34,10 @@ class ToolRouterContext:
34
34
  install_mcp: Any
35
35
  mcp_public_item: Any
36
36
  hooks: Any = None
37
+ # Resolves a caller email to their allowed workspace set (None = no scoping,
38
+ # i.e. single-user / no-auth mode). Threaded to the knowledge-graph router so
39
+ # its read endpoints enforce the same workspace boundary as /api/search.
40
+ allowed_workspaces_for: Any = None
37
41
 
38
42
 
39
43
  @dataclass(frozen=True)