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.
- package/README.md +35 -41
- package/docs/CHANGELOG.md +59 -0
- package/frontend/src/api/client.ts +1 -0
- package/frontend/src/features/brain/BrainConversation.tsx +12 -3
- package/frontend/src/features/brain/BrainHome.tsx +21 -1
- package/frontend/src/features/brain/BrainMemoryLayer.tsx +8 -1
- package/frontend/src/features/brain/BrainOverviewPanel.tsx +15 -1
- package/frontend/src/features/brain/brainData.ts +72 -1
- package/frontend/src/features/brain/types.ts +15 -0
- package/frontend/src/i18n.ts +22 -0
- package/frontend/src/styles.css +128 -0
- package/lattice_brain/__init__.py +3 -1
- package/lattice_brain/quality.py +409 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/knowledge_graph.py +71 -10
- package/latticeai/api/local_files.py +2 -0
- package/latticeai/api/memory.py +12 -6
- package/latticeai/api/search.py +9 -1
- package/latticeai/api/tools.py +3 -0
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/chat_wiring.py +1 -0
- package/latticeai/services/memory_service.py +130 -21
- package/latticeai/services/router_context.py +4 -0
- package/latticeai/services/search_service.py +61 -10
- package/package.json +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-C1J_1441.js +16 -0
- package/static/app/assets/index-C1J_1441.js.map +1 -0
- package/static/app/assets/{index-Div5vMlq.css → index-CdCVz_i4.css} +1 -1
- package/static/app/index.html +2 -2
- package/static/app/assets/index-t1jx1BR9.js +0 -16
- 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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
140
|
+
kg, allowed = _scoped(request)
|
|
100
141
|
if not q or not q.strip():
|
|
101
142
|
return {"query": q, "matches": []}
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
160
|
+
kg, allowed = _scoped(request)
|
|
112
161
|
if not node_id:
|
|
113
162
|
raise HTTPException(status_code=400, detail="node_id required")
|
|
114
|
-
|
|
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
|
|
package/latticeai/api/memory.py
CHANGED
|
@@ -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)
|
package/latticeai/api/search.py
CHANGED
|
@@ -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 = {
|
|
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
|
package/latticeai/api/tools.py
CHANGED
|
@@ -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,
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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=
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "6.
|
|
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-
|
|
10
|
-
"assets/index-
|
|
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-
|
|
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-
|
|
28
|
+
"assets/index-CdCVz_i4.css"
|
|
29
29
|
]
|
|
30
30
|
}
|
|
31
31
|
}
|