ltcai 6.3.1 → 6.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.
Files changed (37) hide show
  1. package/README.md +35 -41
  2. package/docs/CHANGELOG.md +59 -0
  3. package/frontend/src/api/client.ts +1 -0
  4. package/frontend/src/features/brain/BrainConversation.tsx +12 -3
  5. package/frontend/src/features/brain/BrainHome.tsx +21 -1
  6. package/frontend/src/features/brain/BrainMemoryLayer.tsx +8 -1
  7. package/frontend/src/features/brain/BrainOverviewPanel.tsx +15 -1
  8. package/frontend/src/features/brain/brainData.ts +72 -1
  9. package/frontend/src/features/brain/types.ts +15 -0
  10. package/frontend/src/i18n.ts +22 -0
  11. package/frontend/src/styles.css +128 -0
  12. package/lattice_brain/__init__.py +3 -1
  13. package/lattice_brain/quality.py +409 -0
  14. package/lattice_brain/runtime/multi_agent.py +1 -1
  15. package/latticeai/__init__.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +71 -10
  17. package/latticeai/api/local_files.py +2 -0
  18. package/latticeai/api/memory.py +12 -6
  19. package/latticeai/api/search.py +9 -1
  20. package/latticeai/api/tools.py +3 -0
  21. package/latticeai/core/marketplace.py +1 -1
  22. package/latticeai/core/workspace_os.py +1 -1
  23. package/latticeai/runtime/chat_wiring.py +1 -0
  24. package/latticeai/services/memory_service.py +130 -21
  25. package/latticeai/services/router_context.py +4 -0
  26. package/latticeai/services/search_service.py +61 -10
  27. package/package.json +1 -1
  28. package/src-tauri/Cargo.lock +1 -1
  29. package/src-tauri/Cargo.toml +1 -1
  30. package/src-tauri/tauri.conf.json +1 -1
  31. package/static/app/asset-manifest.json +5 -5
  32. package/static/app/assets/index-C1J_1441.js +16 -0
  33. package/static/app/assets/index-C1J_1441.js.map +1 -0
  34. package/static/app/assets/{index-Div5vMlq.css → index-CdCVz_i4.css} +1 -1
  35. package/static/app/index.html +2 -2
  36. package/static/app/assets/index-t1jx1BR9.js +0 -16
  37. package/static/app/assets/index-t1jx1BR9.js.map +0 -1
@@ -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
 
@@ -51,6 +51,12 @@ def create_memory_router(
51
51
  scope = gate_read(request)
52
52
  return service.manager(user_email=user, workspace_id=scope)
53
53
 
54
+ @router.get("/api/memory/brain-quality")
55
+ async def brain_quality_summary(request: Request):
56
+ user = require_user(request)
57
+ scope = gate_read(request)
58
+ return service.brain_quality_summary(user_email=user, workspace_id=scope)
59
+
54
60
  @router.get("/api/memory/tiers")
55
61
  async def memory_tiers(request: Request):
56
62
  require_user(request)
@@ -74,16 +80,16 @@ def create_memory_router(
74
80
  @router.post("/api/memory/prune")
75
81
  async def memory_prune(req: PruneRequest, request: Request):
76
82
  user = require_user(request)
77
- gate_write(request)
78
- result = service.prune(ids=req.ids, kind=req.kind, user_email=user)
83
+ scope = gate_write(request)
84
+ result = service.prune(ids=req.ids, kind=req.kind, user_email=user, workspace_id=scope)
79
85
  append_audit_event("memory_prune", user_email=user, count=result.get("count", 0))
80
86
  return result
81
87
 
82
88
  @router.post("/api/memory/compact")
83
89
  async def memory_compact(request: Request):
84
90
  user = require_user(request)
85
- gate_write(request)
86
- result = service.compact(user_email=user)
91
+ scope = gate_write(request)
92
+ result = service.compact(user_email=user, workspace_id=scope)
87
93
  append_audit_event("memory_compact", user_email=user, compacted=result.get("compacted", 0))
88
94
  return result
89
95
 
@@ -98,9 +104,9 @@ def create_memory_router(
98
104
  @router.post("/api/memory/clear")
99
105
  async def memory_clear(req: ClearRequest, request: Request):
100
106
  user = require_user(request)
101
- gate_write(request)
107
+ scope = gate_write(request)
102
108
  try:
103
- result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user)
109
+ result = service.clear(scope=req.scope, confirm=req.confirm, user_email=user, workspace_id=scope)
104
110
  except ValueError as exc:
105
111
  raise HTTPException(status_code=400, detail=str(exc)) from exc
106
112
  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,
@@ -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.1"
14
+ MARKETPLACE_VERSION = "6.5.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.1"
22
+ WORKSPACE_OS_VERSION = "6.5.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
@@ -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,
@@ -132,9 +132,66 @@ class MemoryService:
132
132
  return None
133
133
 
134
134
  # ── Memory Manager: sources / usage / health ──────────────────────────
135
+ def _brain_readiness(
136
+ self,
137
+ *,
138
+ memory_count: int,
139
+ concept_count: Optional[int],
140
+ relationship_count: Optional[int],
141
+ healthy_sources: int,
142
+ ) -> Dict[str, Any]:
143
+ """Summarize how ready the user's Brain is for the product UI.
144
+
145
+ The frontend should render this signal instead of re-deriving it from
146
+ UI fragments. Keeping it here makes the score auditable and keeps the
147
+ Memory Manager as the single owner of cross-tier Brain growth signals.
148
+ """
149
+ concepts = max(0, int(concept_count or 0))
150
+ relationships = max(0, int(relationship_count or 0))
151
+ memories = max(0, int(memory_count or 0))
152
+ healthy = max(0, int(healthy_sources or 0))
153
+ score = min(100, round(memories * 12 + concepts * 8 + relationships * 4 + healthy * 3))
154
+
155
+ if memories < 1 and concepts < 1:
156
+ state = "quiet"
157
+ depth = 2
158
+ title_key = "brain.readiness.quiet"
159
+ action_key = "brain.readiness.start"
160
+ score = max(12, score)
161
+ elif concepts < 3 or relationships < 2:
162
+ state = "forming"
163
+ depth = 3 if concepts < 3 else 4
164
+ title_key = "brain.readiness.forming"
165
+ action_key = "brain.readiness.grow"
166
+ score = max(38, score)
167
+ else:
168
+ state = "alive"
169
+ depth = 5
170
+ title_key = "brain.readiness.alive"
171
+ action_key = "brain.readiness.map"
172
+ score = max(72, score)
173
+
174
+ return {
175
+ "score": score,
176
+ "state": state,
177
+ "depth": depth,
178
+ "title_key": title_key,
179
+ "action_key": action_key,
180
+ "signals": {
181
+ "memory_count": memories,
182
+ "concept_count": concepts,
183
+ "relationship_count": relationships,
184
+ "healthy_sources": healthy,
185
+ },
186
+ "source": "memory_service",
187
+ }
188
+
135
189
  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"]
190
+ ws_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")
191
+ if workspace_id is None:
192
+ project_mem = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"]
193
+ else:
194
+ project_mem = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
138
195
  snaps = self._snapshots(workspace_id=workspace_id)
139
196
  convs = self._conversations()
140
197
  kg_stats = self._kg_stats()
@@ -195,15 +252,27 @@ class MemoryService:
195
252
  total_bytes = ws_bytes + kg_bytes + conv_bytes
196
253
  healthy = sum(1 for s in sources if s["health"] == "ok")
197
254
  overall = "ok" if healthy >= 4 else "degraded" if healthy >= 1 else "unavailable"
255
+ memory_ids = {m.get("id") for m in [*ws_mem, *project_mem] if m.get("id")}
256
+ memory_count = len(memory_ids) + len(snaps) + len(convs)
198
257
  return {
199
258
  "sources": sources,
200
259
  "tiers": list(TIERS),
201
260
  "usage": {"total_items": total_items, "total_bytes": total_bytes, "sources": len(sources)},
261
+ "brain_readiness": self._brain_readiness(
262
+ memory_count=memory_count,
263
+ concept_count=node_total,
264
+ relationship_count=edge_total,
265
+ healthy_sources=healthy,
266
+ ),
202
267
  "health": overall,
203
268
  "graph_enabled": self._enable_graph,
204
269
  "generated_at": _now(),
205
270
  }
206
271
 
272
+ def brain_quality_summary(self, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None) -> Dict[str, Any]:
273
+ """Return the backend-owned Brain readiness signal for API consumers."""
274
+ return self.manager(user_email=user_email, workspace_id=workspace_id)["brain_readiness"]
275
+
207
276
  def tiers(self) -> Dict[str, Any]:
208
277
  return {"tiers": list(TIERS), "workspace_kinds": list(WORKSPACE_KINDS)}
209
278
 
@@ -268,10 +337,13 @@ class MemoryService:
268
337
  # ── inspect a single tier ─────────────────────────────────────────────
269
338
  def inspect(self, source: str, *, user_email: Optional[str] = None, workspace_id: Optional[str] = None, limit: int = 50) -> Dict[str, Any]:
270
339
  if source == "workspace":
271
- items = self._workspace_memories(user_email=user_email, workspace_id="personal")[:limit]
340
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id or "personal")[:limit]
272
341
  return {"source": source, "items": items, "count": len(items)}
273
342
  if source == "project":
274
- items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
343
+ if workspace_id is None:
344
+ items = [m for m in self._all_memories() if (m.get("workspace_id") or "personal") != "personal"][:limit]
345
+ else:
346
+ items = self._workspace_memories(user_email=user_email, workspace_id=workspace_id)[:limit]
275
347
  return {"source": source, "items": items, "count": len(items)}
276
348
  if source == "agent":
277
349
  items = self._snapshots(workspace_id=workspace_id)[:limit]
@@ -287,12 +359,39 @@ class MemoryService:
287
359
  raise KeyError(source)
288
360
 
289
361
  # ── mutating operations ───────────────────────────────────────────────
290
- def prune(self, *, ids: Optional[List[str]] = None, kind: Optional[str] = None, user_email: Optional[str] = None) -> Dict[str, Any]:
362
+ def prune(
363
+ self,
364
+ *,
365
+ ids: Optional[List[str]] = None,
366
+ kind: Optional[str] = None,
367
+ user_email: Optional[str] = None,
368
+ workspace_id: Optional[str] = None,
369
+ ) -> Dict[str, Any]:
370
+ # Ownership guard: a caller may only prune memories they own. Both the
371
+ # explicit-id and kind paths are intersected with the caller's own
372
+ # memories, so a forged id for another user's memory is refused, not
373
+ # silently deleted.
374
+ owned_ids = {
375
+ m["id"]
376
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id)
377
+ if m.get("id")
378
+ }
291
379
  removed: List[str] = []
292
- target_ids = list(ids or [])
380
+ skipped: List[str] = []
381
+ target_ids: List[str] = []
382
+ seen: set = set()
383
+ for mid in (ids or []):
384
+ if mid in seen:
385
+ continue
386
+ seen.add(mid)
387
+ if mid in owned_ids:
388
+ target_ids.append(mid)
389
+ else:
390
+ skipped.append(mid)
293
391
  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"):
392
+ for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id):
393
+ if m.get("kind") == kind and m.get("id") and m["id"] not in seen:
394
+ seen.add(m["id"])
296
395
  target_ids.append(m["id"])
297
396
  for mid in target_ids:
298
397
  try:
@@ -300,14 +399,22 @@ class MemoryService:
300
399
  removed.append(mid)
301
400
  except Exception:
302
401
  continue
303
- return {"removed": removed, "count": len(removed)}
402
+ result: Dict[str, Any] = {"removed": removed, "count": len(removed)}
403
+ if skipped:
404
+ result["skipped"] = skipped
405
+ return result
304
406
 
305
- def compact(self, *, user_email: Optional[str] = None) -> Dict[str, Any]:
407
+ def compact(
408
+ self,
409
+ *,
410
+ user_email: Optional[str] = None,
411
+ workspace_id: Optional[str] = None,
412
+ ) -> Dict[str, Any]:
306
413
  """Dedupe workspace memories with identical (kind, content)."""
307
414
  seen: set = set()
308
415
  removed: List[str] = []
309
416
  # Oldest first so the first occurrence (oldest) is kept.
310
- memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=None)))
417
+ memories = list(reversed(self._workspace_memories(user_email=user_email, workspace_id=workspace_id)))
311
418
  for m in memories:
312
419
  key = (m.get("kind"), str(m.get("content") or "").strip())
313
420
  if key in seen:
@@ -332,21 +439,23 @@ class MemoryService:
332
439
  return {"status": "error", "detail": str(exc)}
333
440
  return {"status": "error", "detail": f"Unknown rebuild target: {target}"}
334
441
 
335
- def clear(self, *, scope: str, confirm: bool = False, user_email: Optional[str] = None) -> Dict[str, Any]:
442
+ def clear(
443
+ self,
444
+ *,
445
+ scope: str,
446
+ confirm: bool = False,
447
+ user_email: Optional[str] = None,
448
+ workspace_id: Optional[str] = None,
449
+ ) -> Dict[str, Any]:
336
450
  if not confirm:
337
451
  raise ValueError("clear requires confirm=true")
338
452
  if scope in WORKSPACE_KINDS:
339
- result = self.prune(kind=scope, user_email=user_email)
453
+ result = self.prune(kind=scope, user_email=user_email, workspace_id=workspace_id)
340
454
  return {"cleared": scope, **result}
341
455
  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)
456
+ ids = [m["id"] for m in self._workspace_memories(user_email=user_email, workspace_id=workspace_id) if m.get("id")]
457
+ result = self.prune(ids=ids, user_email=user_email, workspace_id=workspace_id)
344
458
  return {"cleared": "workspace", **result}
345
459
  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)}
460
+ raise ValueError("graph clear is disabled from Memory Manager because it is not workspace-scoped")
352
461
  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)
@@ -179,10 +179,12 @@ class SearchService:
179
179
  allowed_workspaces=None,
180
180
  ) -> Dict[str, Any]:
181
181
  weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
182
+ # Scope each channel at the source so out-of-scope rows never enter the
183
+ # fusion set (defense-in-depth — the fused result is re-scoped below too).
182
184
  channels = {
183
- "keyword": self.keyword_search(query, limit=keyword_limit),
184
- "vector": self.vector_search(query, limit=vector_limit),
185
- "graph": self.graph_search(query, limit=graph_limit),
185
+ "keyword": self.keyword_search(query, limit=keyword_limit, allowed_workspaces=allowed_workspaces),
186
+ "vector": self.vector_search(query, limit=vector_limit, allowed_workspaces=allowed_workspaces),
187
+ "graph": self.graph_search(query, limit=graph_limit, allowed_workspaces=allowed_workspaces),
186
188
  }
187
189
  fused: Dict[str, Dict[str, Any]] = {}
188
190
  for source, payload in channels.items():
@@ -234,14 +236,50 @@ class SearchService:
234
236
  "matches": matches,
235
237
  }
236
238
 
237
- def graph(self, *, limit: int = 300) -> Dict[str, Any]:
238
- return self._require_graph().graph(limit=limit)
239
-
240
- def node(self, node_id: str, *, include_neighbors: bool = True, depth: int = 1, limit: int = 100) -> Dict[str, Any]:
239
+ def graph(self, *, limit: int = 300, allowed_workspaces=None) -> Dict[str, Any]:
241
240
  graph = self._require_graph()
242
- payload = {"node": graph.get_node(node_id)}
241
+ try:
242
+ return graph.graph(limit=limit, allowed_workspaces=allowed_workspaces)
243
+ except TypeError:
244
+ payload = graph.graph(limit=limit)
245
+ if allowed_workspaces is not None:
246
+ nodes = graph.filter_scoped_nodes(payload.get("nodes", []), allowed_workspaces)
247
+ kept = {node.get("id") for node in nodes}
248
+ edges = [
249
+ edge for edge in payload.get("edges", [])
250
+ if edge.get("from") in kept and edge.get("to") in kept
251
+ ]
252
+ payload = {**payload, "nodes": nodes, "edges": edges}
253
+ return payload
254
+
255
+ def node(
256
+ self,
257
+ node_id: str,
258
+ *,
259
+ include_neighbors: bool = True,
260
+ depth: int = 1,
261
+ limit: int = 100,
262
+ allowed_workspaces=None,
263
+ ) -> Dict[str, Any]:
264
+ graph = self._require_graph()
265
+ node = graph.get_node(node_id)
266
+ if allowed_workspaces is not None:
267
+ visible = graph.filter_scoped_nodes([node], allowed_workspaces)
268
+ if not visible:
269
+ raise ValueError(f"graph node not found: {node_id}")
270
+ node = visible[0]
271
+ payload = {"node": node}
243
272
  if include_neighbors:
244
- payload["neighborhood"] = graph.traverse(node_id, depth=depth, limit=limit)
273
+ neighborhood = graph.traverse(node_id, depth=depth, limit=limit)
274
+ if allowed_workspaces is not None:
275
+ nodes = graph.filter_scoped_nodes(neighborhood.get("nodes", []), allowed_workspaces)
276
+ kept = {item.get("id") for item in nodes}
277
+ edges = [
278
+ edge for edge in neighborhood.get("edges", [])
279
+ if edge.get("from") in kept and edge.get("to") in kept
280
+ ]
281
+ neighborhood = {**neighborhood, "nodes": nodes, "edges": edges}
282
+ payload["neighborhood"] = neighborhood
245
283
  return payload
246
284
 
247
285
  def relationships(
@@ -251,13 +289,26 @@ class SearchService:
251
289
  node_id: str = "",
252
290
  relationship_type: str = "",
253
291
  limit: int = 30,
292
+ allowed_workspaces=None,
254
293
  ) -> Dict[str, Any]:
255
- return self._require_graph().relationship_search(
294
+ graph = self._require_graph()
295
+ payload = graph.relationship_search(
256
296
  query=query,
257
297
  node_id=node_id,
258
298
  relationship_type=relationship_type,
259
299
  limit=limit,
260
300
  )
301
+ if allowed_workspaces is not None:
302
+ kept = []
303
+ for rel in payload.get("relationships", []):
304
+ endpoints = [
305
+ {"id": (rel.get("source") or {}).get("id")},
306
+ {"id": (rel.get("target") or {}).get("id")},
307
+ ]
308
+ if len(graph.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
309
+ kept.append(rel)
310
+ payload = {**payload, "relationships": kept}
311
+ return payload
261
312
 
262
313
  def index_status(self) -> Dict[str, Any]:
263
314
  return self._require_graph().index_status()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "6.3.1",
3
+ "version": "6.5.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -1654,7 +1654,7 @@ dependencies = [
1654
1654
 
1655
1655
  [[package]]
1656
1656
  name = "lattice-ai-desktop"
1657
- version = "6.3.1"
1657
+ version = "6.5.0"
1658
1658
  dependencies = [
1659
1659
  "plist",
1660
1660
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "6.3.1"
3
+ version = "6.5.0"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "6.3.1",
4
+ "version": "6.5.0",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "6.3.1",
2
+ "version": "6.5.0",
3
3
  "generated_at": "vite",
4
4
  "entrypoints": {
5
5
  "app": "/static/app/index.html"
6
6
  },
7
7
  "assets": {
8
8
  "../node_modules/@tauri-apps/api/core.js": "/static/app/assets/core-CwxXejkd.js",
9
- "index.html": "/static/app/assets/index-t1jx1BR9.js",
10
- "assets/index-Div5vMlq.css": "/static/app/assets/index-Div5vMlq.css"
9
+ "index.html": "/static/app/assets/index-C1J_1441.js",
10
+ "assets/index-CdCVz_i4.css": "/static/app/assets/index-CdCVz_i4.css"
11
11
  },
12
12
  "vite": {
13
13
  "../node_modules/@tauri-apps/api/core.js": {
@@ -17,7 +17,7 @@
17
17
  "isDynamicEntry": true
18
18
  },
19
19
  "index.html": {
20
- "file": "assets/index-t1jx1BR9.js",
20
+ "file": "assets/index-C1J_1441.js",
21
21
  "name": "index",
22
22
  "src": "index.html",
23
23
  "isEntry": true,
@@ -25,7 +25,7 @@
25
25
  "../node_modules/@tauri-apps/api/core.js"
26
26
  ],
27
27
  "css": [
28
- "assets/index-Div5vMlq.css"
28
+ "assets/index-CdCVz_i4.css"
29
29
  ]
30
30
  }
31
31
  }