ltcai 8.9.0 → 9.1.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 (208) hide show
  1. package/README.md +81 -58
  2. package/auto_setup.py +7 -904
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +221 -238
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  13. package/docs/TRUST_MODEL.md +5 -2
  14. package/docs/WHY_LATTICE.md +15 -10
  15. package/docs/WORKFLOW_DESIGNER.md +22 -0
  16. package/docs/kg-schema.md +13 -2
  17. package/docs/mcp-tools.md +17 -6
  18. package/docs/privacy.md +19 -3
  19. package/docs/public-deploy.md +32 -3
  20. package/docs/security-model.md +46 -11
  21. package/lattice_brain/__init__.py +1 -1
  22. package/lattice_brain/archive.py +4 -14
  23. package/lattice_brain/context.py +66 -9
  24. package/lattice_brain/embeddings.py +38 -2
  25. package/lattice_brain/graph/_kg_common.py +27 -462
  26. package/lattice_brain/graph/_kg_constants.py +243 -0
  27. package/lattice_brain/graph/_kg_fsutil.py +293 -0
  28. package/lattice_brain/graph/discovery.py +0 -948
  29. package/lattice_brain/graph/discovery_index.py +1126 -0
  30. package/lattice_brain/graph/documents.py +44 -9
  31. package/lattice_brain/graph/ingest.py +47 -20
  32. package/lattice_brain/graph/provenance.py +13 -6
  33. package/lattice_brain/graph/retrieval.py +141 -610
  34. package/lattice_brain/graph/retrieval_docgen.py +243 -0
  35. package/lattice_brain/graph/retrieval_vector.py +460 -0
  36. package/lattice_brain/graph/store.py +6 -0
  37. package/lattice_brain/ingestion.py +69 -4
  38. package/lattice_brain/portability.py +29 -23
  39. package/lattice_brain/quality.py +98 -4
  40. package/lattice_brain/runtime/agent_runtime.py +169 -7
  41. package/lattice_brain/runtime/hooks.py +30 -13
  42. package/lattice_brain/runtime/multi_agent.py +27 -8
  43. package/lattice_brain/runtime/statuses.py +10 -0
  44. package/lattice_brain/utils.py +46 -0
  45. package/lattice_brain/workflow.py +1 -5
  46. package/latticeai/__init__.py +1 -1
  47. package/latticeai/api/admin.py +1 -1
  48. package/latticeai/api/agent_registry.py +10 -8
  49. package/latticeai/api/agents.py +22 -3
  50. package/latticeai/api/auth.py +78 -16
  51. package/latticeai/api/browser.py +303 -34
  52. package/latticeai/api/chat.py +355 -952
  53. package/latticeai/api/chat_agent_http.py +356 -0
  54. package/latticeai/api/chat_contracts.py +54 -0
  55. package/latticeai/api/chat_documents.py +256 -0
  56. package/latticeai/api/chat_helpers.py +250 -0
  57. package/latticeai/api/chat_history.py +99 -0
  58. package/latticeai/api/chat_intents.py +302 -0
  59. package/latticeai/api/chat_stream.py +115 -0
  60. package/latticeai/api/computer_use.py +211 -40
  61. package/latticeai/api/health.py +9 -3
  62. package/latticeai/api/hooks.py +17 -6
  63. package/latticeai/api/knowledge_graph.py +78 -14
  64. package/latticeai/api/local_files.py +7 -2
  65. package/latticeai/api/marketplace.py +11 -0
  66. package/latticeai/api/mcp.py +102 -23
  67. package/latticeai/api/models.py +72 -33
  68. package/latticeai/api/network.py +5 -5
  69. package/latticeai/api/permissions.py +70 -29
  70. package/latticeai/api/plugins.py +21 -7
  71. package/latticeai/api/portability.py +5 -5
  72. package/latticeai/api/realtime.py +33 -5
  73. package/latticeai/api/setup.py +2 -2
  74. package/latticeai/api/static_routes.py +123 -24
  75. package/latticeai/api/tools.py +97 -14
  76. package/latticeai/api/workflow_designer.py +37 -0
  77. package/latticeai/api/workspace.py +2 -1
  78. package/latticeai/app_factory.py +185 -405
  79. package/latticeai/core/agent.py +19 -9
  80. package/latticeai/core/agent_registry.py +1 -5
  81. package/latticeai/core/config.py +23 -3
  82. package/latticeai/core/context_builder.py +21 -3
  83. package/latticeai/core/invitations.py +1 -4
  84. package/latticeai/core/io_utils.py +45 -0
  85. package/latticeai/core/legacy_compatibility.py +24 -3
  86. package/latticeai/core/local_embeddings.py +2 -4
  87. package/latticeai/core/marketplace.py +33 -2
  88. package/latticeai/core/mcp_catalog.py +450 -0
  89. package/latticeai/core/mcp_registry.py +2 -441
  90. package/latticeai/core/plugins.py +15 -0
  91. package/latticeai/core/policy.py +1 -1
  92. package/latticeai/core/realtime.py +38 -15
  93. package/latticeai/core/sessions.py +7 -2
  94. package/latticeai/core/timeutil.py +10 -0
  95. package/latticeai/core/tool_registry.py +90 -18
  96. package/latticeai/core/users.py +5 -14
  97. package/latticeai/core/workspace_graph_trace.py +31 -5
  98. package/latticeai/core/workspace_memory.py +3 -1
  99. package/latticeai/core/workspace_os.py +18 -7
  100. package/latticeai/core/workspace_os_utils.py +2 -21
  101. package/latticeai/core/workspace_permissions.py +2 -1
  102. package/latticeai/core/workspace_plugins.py +1 -1
  103. package/latticeai/core/workspace_runs.py +14 -5
  104. package/latticeai/core/workspace_skills.py +1 -1
  105. package/latticeai/core/workspace_snapshots.py +2 -1
  106. package/latticeai/core/workspace_timeline.py +2 -1
  107. package/latticeai/integrations/telegram_bot.py +96 -40
  108. package/latticeai/models/model_providers.py +111 -0
  109. package/latticeai/models/router.py +189 -173
  110. package/latticeai/runtime/access_runtime.py +62 -4
  111. package/latticeai/runtime/audit_runtime.py +27 -16
  112. package/latticeai/runtime/automation_runtime.py +22 -7
  113. package/latticeai/runtime/brain_runtime.py +19 -7
  114. package/latticeai/runtime/chat_wiring.py +2 -0
  115. package/latticeai/runtime/config_runtime.py +58 -26
  116. package/latticeai/runtime/context_runtime.py +16 -4
  117. package/latticeai/runtime/history_runtime.py +163 -0
  118. package/latticeai/runtime/hooks_runtime.py +1 -1
  119. package/latticeai/runtime/model_wiring.py +33 -5
  120. package/latticeai/runtime/namespace_runtime.py +163 -0
  121. package/latticeai/runtime/network_config_runtime.py +56 -0
  122. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  123. package/latticeai/runtime/review_wiring.py +1 -1
  124. package/latticeai/runtime/router_registration.py +34 -1
  125. package/latticeai/runtime/security_runtime.py +110 -13
  126. package/latticeai/runtime/sso_config_runtime.py +128 -0
  127. package/latticeai/runtime/stages.py +27 -0
  128. package/latticeai/runtime/user_key_runtime.py +106 -0
  129. package/latticeai/server_app.py +5 -1
  130. package/latticeai/services/architecture_readiness.py +74 -5
  131. package/latticeai/services/brain_automation.py +3 -26
  132. package/latticeai/services/chat_service.py +205 -15
  133. package/latticeai/services/local_knowledge.py +423 -0
  134. package/latticeai/services/memory_service.py +268 -21
  135. package/latticeai/services/model_engines.py +48 -33
  136. package/latticeai/services/model_errors.py +17 -0
  137. package/latticeai/services/model_loading.py +28 -25
  138. package/latticeai/services/model_runtime.py +228 -162
  139. package/latticeai/services/p_reinforce.py +22 -3
  140. package/latticeai/services/platform_runtime.py +92 -24
  141. package/latticeai/services/product_readiness.py +19 -17
  142. package/latticeai/services/review_queue.py +76 -11
  143. package/latticeai/services/router_context.py +1 -0
  144. package/latticeai/services/run_executor.py +25 -9
  145. package/latticeai/services/search_service.py +203 -28
  146. package/latticeai/services/setup_detection.py +80 -0
  147. package/latticeai/services/tool_dispatch.py +28 -5
  148. package/latticeai/services/triggers.py +53 -4
  149. package/latticeai/services/upload_service.py +13 -2
  150. package/latticeai/setup/__init__.py +25 -0
  151. package/latticeai/setup/auto_setup.py +857 -0
  152. package/latticeai/setup/wizard.py +1264 -0
  153. package/latticeai/tools/__init__.py +278 -0
  154. package/{tools → latticeai/tools}/commands.py +67 -7
  155. package/{tools → latticeai/tools}/computer.py +1 -1
  156. package/{tools → latticeai/tools}/documents.py +1 -1
  157. package/{tools → latticeai/tools}/filesystem.py +3 -3
  158. package/latticeai/tools/knowledge.py +178 -0
  159. package/{tools → latticeai/tools}/local_files.py +7 -1
  160. package/{tools → latticeai/tools}/network.py +0 -1
  161. package/local_knowledge_api.py +4 -342
  162. package/package.json +22 -2
  163. package/scripts/brain_quality_eval.py +3 -1
  164. package/scripts/bump_version.py +3 -0
  165. package/scripts/capture_release_evidence.mjs +180 -0
  166. package/scripts/check_current_release_docs.mjs +142 -0
  167. package/scripts/check_i18n_literals.mjs +107 -31
  168. package/scripts/check_openapi_drift.mjs +56 -0
  169. package/scripts/export_openapi.py +67 -7
  170. package/scripts/i18n_literal_allowlist.json +22 -24
  171. package/scripts/run_integration_tests.mjs +72 -10
  172. package/scripts/validate_release_artifacts.py +49 -7
  173. package/scripts/wheel_smoke.py +5 -0
  174. package/setup_wizard.py +3 -1304
  175. package/src-tauri/Cargo.lock +1 -1
  176. package/src-tauri/Cargo.toml +1 -1
  177. package/src-tauri/tauri.conf.json +1 -1
  178. package/static/app/asset-manifest.json +11 -11
  179. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  180. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  181. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  182. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  183. package/static/app/assets/System-80lHW0Ux.js +1 -0
  184. package/static/app/assets/index-BiMofBTM.js +17 -0
  185. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  186. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  187. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  188. package/static/app/index.html +2 -2
  189. package/static/css/tokens.css +4 -2
  190. package/static/sw.js +1 -1
  191. package/tools/__init__.py +21 -269
  192. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  193. package/latticeai/runtime/app_context_runtime.py +0 -13
  194. package/latticeai/runtime/sso_runtime.py +0 -52
  195. package/latticeai/runtime/tail_wiring.py +0 -21
  196. package/scripts/com.pts.claudecode.discord.plist +0 -31
  197. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  198. package/scripts/start-pts-claudecode-discord.sh +0 -51
  199. package/static/app/assets/Act-fZokUnC0.js +0 -1
  200. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  201. package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
  202. package/static/app/assets/Library-C9kyFkSt.js +0 -1
  203. package/static/app/assets/System-VbChmX7r.js +0 -1
  204. package/static/app/assets/index-DCh5AoXt.css +0 -2
  205. package/static/app/assets/index-DPdcPoF0.js +0 -17
  206. package/static/app/assets/primitives-DFeanEV6.js +0 -1
  207. package/static/app/assets/textarea-CD8UNKIy.js +0 -1
  208. package/tools/knowledge.py +0 -95
@@ -93,42 +93,67 @@ class KnowledgeGraphRetrievalMixin:
93
93
  }
94
94
 
95
95
  def workspaces_of(self, node_ids) -> Dict[str, Optional[str]]:
96
- """Map node ids to their workspace scope (None = legacy-global)."""
96
+ """Map known node ids to their workspace scope.
97
+
98
+ ``None`` is returned only for a row that is explicitly present in the
99
+ authoritative v2 projection with a NULL workspace. Missing ids remain
100
+ missing, and projection/query failures propagate so callers can fail
101
+ closed instead of mistaking every candidate for legacy-global data.
102
+ """
97
103
  ids = [str(i) for i in node_ids if i]
98
104
  if not ids:
99
105
  return {}
100
106
  placeholders = ",".join("?" for _ in ids)
101
107
  with self._connect() as conn:
102
- try:
103
- return {
104
- row["id"]: row["workspace_id"]
105
- for row in conn.execute(
106
- f"SELECT id, workspace_id FROM nodes_v2 WHERE id IN ({placeholders})",
107
- ids,
108
- ).fetchall()
109
- }
110
- except Exception:
111
- return {}
108
+ return {
109
+ row["id"]: row["workspace_id"]
110
+ for row in conn.execute(
111
+ f"SELECT id, workspace_id FROM nodes_v2 WHERE id IN ({placeholders})",
112
+ ids,
113
+ ).fetchall()
114
+ }
112
115
 
113
- def filter_scoped_nodes(self, items, allowed_workspaces, *, id_key: str = "id"):
116
+ def filter_scoped_nodes(
117
+ self,
118
+ items,
119
+ allowed_workspaces,
120
+ *,
121
+ id_key: str = "id",
122
+ include_legacy_global: bool = False,
123
+ ):
114
124
  """Drop items scoped to a workspace the caller is not a member of.
115
125
 
116
126
  ``allowed_workspaces=None`` means no scoping (single-user / no-auth
117
- mode). Legacy-global rows (no workspace) stay visible to everyone on
118
- the machine the documented pre-v4 compatibility behavior.
127
+ mode). In scoped/multi-user mode, unknown ids are private and
128
+ legacy-global rows require the explicit ``include_legacy_global=True``
129
+ compatibility opt-in.
119
130
  """
131
+ candidates = list(items)
120
132
  if allowed_workspaces is None:
121
- return list(items)
122
- allowed = set(allowed_workspaces)
123
- scopes = self.workspaces_of([item.get(id_key) for item in items])
124
- return [
125
- item
126
- for item in items
127
- if scopes.get(item.get(id_key)) is None
128
- or scopes.get(item.get(id_key)) in allowed
129
- ]
130
-
131
- def graph(self, limit: int = 300, *, allowed_workspaces=None) -> Dict[str, Any]:
133
+ return candidates
134
+ allowed = {str(workspace_id) for workspace_id in allowed_workspaces if workspace_id}
135
+ scopes = self.workspaces_of([item.get(id_key) for item in candidates])
136
+ visible = []
137
+ for item in candidates:
138
+ node_id = str(item.get(id_key) or "")
139
+ if not node_id or node_id not in scopes:
140
+ # Unknown/unprojected rows are never treated as public.
141
+ continue
142
+ workspace_id = scopes[node_id]
143
+ if workspace_id is None:
144
+ if include_legacy_global:
145
+ visible.append(item)
146
+ elif str(workspace_id) in allowed:
147
+ visible.append(item)
148
+ return visible
149
+
150
+ def graph(
151
+ self,
152
+ limit: int = 300,
153
+ *,
154
+ allowed_workspaces=None,
155
+ include_legacy_global: bool = False,
156
+ ) -> Dict[str, Any]:
132
157
  limit = max(1, min(int(limit or 300), 2000))
133
158
  visible = ",".join(f"'{t}'" for t in self._GRAPH_VISIBLE_TYPES)
134
159
  nt, et = self._read_tables()
@@ -179,7 +204,11 @@ class KnowledgeGraphRetrievalMixin:
179
204
  ]
180
205
 
181
206
  if allowed_workspaces is not None:
182
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
207
+ nodes = self.filter_scoped_nodes(
208
+ nodes,
209
+ allowed_workspaces,
210
+ include_legacy_global=include_legacy_global,
211
+ )
183
212
  kept_ids = {node["id"] for node in nodes}
184
213
  edges = [e for e in edges if e["from"] in kept_ids and e["to"] in kept_ids]
185
214
 
@@ -264,7 +293,14 @@ class KnowledgeGraphRetrievalMixin:
264
293
  node.pop("_raw_importance", None)
265
294
  return {"nodes": nodes, "edges": edges}
266
295
 
267
- def search(self, query: str, limit: int = 30, *, allowed_workspaces=None) -> Dict[str, Any]:
296
+ def search(
297
+ self,
298
+ query: str,
299
+ limit: int = 30,
300
+ *,
301
+ allowed_workspaces=None,
302
+ include_legacy_global: bool = False,
303
+ ) -> Dict[str, Any]:
268
304
  query = str(query or "").strip()
269
305
  q = f"%{query}%"
270
306
  limit = max(1, min(int(limit or 30), 100))
@@ -368,15 +404,31 @@ class KnowledgeGraphRetrievalMixin:
368
404
  for row in rows
369
405
  ]
370
406
  if allowed_workspaces is not None:
371
- matches = self.filter_scoped_nodes(matches, allowed_workspaces)
407
+ matches = self.filter_scoped_nodes(
408
+ matches,
409
+ allowed_workspaces,
410
+ include_legacy_global=include_legacy_global,
411
+ )
372
412
  return {"query": query, "matches": matches}
373
413
 
374
- def context_for_query(self, query: str, limit: int = 6, *, allowed_workspaces=None) -> str:
414
+ def context_for_query(
415
+ self,
416
+ query: str,
417
+ limit: int = 6,
418
+ *,
419
+ allowed_workspaces=None,
420
+ include_legacy_global: bool = False,
421
+ ) -> str:
375
422
  """Return compact graph-backed RAG context for chat generation."""
376
423
  query = str(query or "").strip()
377
424
  if not query:
378
425
  return ""
379
- matches = self.search(query, limit, allowed_workspaces=allowed_workspaces).get("matches", [])
426
+ matches = self.search(
427
+ query,
428
+ limit,
429
+ allowed_workspaces=allowed_workspaces,
430
+ include_legacy_global=include_legacy_global,
431
+ ).get("matches", [])
380
432
  if not matches:
381
433
  topics = _topic_candidates(query, limit=4)
382
434
  if topics:
@@ -414,7 +466,11 @@ class KnowledgeGraphRetrievalMixin:
414
466
  if len(matches) >= limit:
415
467
  break
416
468
  if allowed_workspaces is not None:
417
- matches = self.filter_scoped_nodes(matches, allowed_workspaces)
469
+ matches = self.filter_scoped_nodes(
470
+ matches,
471
+ allowed_workspaces,
472
+ include_legacy_global=include_legacy_global,
473
+ )
418
474
  lines = []
419
475
  for match in matches[:limit]:
420
476
  meta = match.get("metadata") or {}
@@ -431,9 +487,19 @@ class KnowledgeGraphRetrievalMixin:
431
487
  )
432
488
  return "\n".join(lines)
433
489
 
434
- def neighbors(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
490
+ def neighbors(
491
+ self,
492
+ node_id: str,
493
+ *,
494
+ allowed_workspaces=None,
495
+ include_legacy_global: bool = False,
496
+ ) -> Dict[str, Any]:
435
497
  """Return direct neighbors (1-hop) of a node."""
436
- if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
498
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
499
+ [{"id": node_id}],
500
+ allowed_workspaces,
501
+ include_legacy_global=include_legacy_global,
502
+ ):
437
503
  raise ValueError(f"graph node not found: {node_id}")
438
504
  nt, et = self._read_tables()
439
505
  with self._connect() as conn:
@@ -472,7 +538,11 @@ class KnowledgeGraphRetrievalMixin:
472
538
  )
473
539
  ]
474
540
  if allowed_workspaces is not None:
475
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
541
+ nodes = self.filter_scoped_nodes(
542
+ nodes,
543
+ allowed_workspaces,
544
+ include_legacy_global=include_legacy_global,
545
+ )
476
546
  kept = {node.get("id") for node in nodes}
477
547
  edges = [
478
548
  edge for edge in edges
@@ -481,7 +551,13 @@ class KnowledgeGraphRetrievalMixin:
481
551
  ]
482
552
  return {"node_id": node_id, "neighbors": nodes, "edges": edges}
483
553
 
484
- def get_node(self, node_id: str, *, allowed_workspaces=None) -> Dict[str, Any]:
554
+ def get_node(
555
+ self,
556
+ node_id: str,
557
+ *,
558
+ allowed_workspaces=None,
559
+ include_legacy_global: bool = False,
560
+ ) -> Dict[str, Any]:
485
561
  node_id = str(node_id or "").strip()
486
562
  if not node_id:
487
563
  raise ValueError("node_id required")
@@ -510,7 +586,11 @@ class KnowledgeGraphRetrievalMixin:
510
586
  "updated_at": row["updated_at"],
511
587
  "degree": degree,
512
588
  }
513
- if allowed_workspaces is not None and not self.filter_scoped_nodes([node], allowed_workspaces):
589
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
590
+ [node],
591
+ allowed_workspaces,
592
+ include_legacy_global=include_legacy_global,
593
+ ):
514
594
  raise ValueError(f"graph node not found: {node_id}")
515
595
  return node
516
596
 
@@ -522,6 +602,7 @@ class KnowledgeGraphRetrievalMixin:
522
602
  relationship_type: str = "",
523
603
  limit: int = 30,
524
604
  allowed_workspaces=None,
605
+ include_legacy_global: bool = False,
525
606
  ) -> Dict[str, Any]:
526
607
  query = str(query or "").strip()
527
608
  node_id = str(node_id or "").strip()
@@ -591,7 +672,13 @@ class KnowledgeGraphRetrievalMixin:
591
672
  {"id": (rel.get("source") or {}).get("id")},
592
673
  {"id": (rel.get("target") or {}).get("id")},
593
674
  ]
594
- if len(self.filter_scoped_nodes(endpoints, allowed_workspaces)) == 2:
675
+ if len(
676
+ self.filter_scoped_nodes(
677
+ endpoints,
678
+ allowed_workspaces,
679
+ include_legacy_global=include_legacy_global,
680
+ )
681
+ ) == 2:
595
682
  kept.append(rel)
596
683
  relationships = kept
597
684
  return {
@@ -602,12 +689,22 @@ class KnowledgeGraphRetrievalMixin:
602
689
  }
603
690
 
604
691
  def traverse(
605
- self, node_id: str, *, depth: int = 1, limit: int = 100, allowed_workspaces=None
692
+ self,
693
+ node_id: str,
694
+ *,
695
+ depth: int = 1,
696
+ limit: int = 100,
697
+ allowed_workspaces=None,
698
+ include_legacy_global: bool = False,
606
699
  ) -> Dict[str, Any]:
607
700
  node_id = str(node_id or "").strip()
608
701
  if not node_id:
609
702
  raise ValueError("node_id required")
610
- if allowed_workspaces is not None and not self.filter_scoped_nodes([{"id": node_id}], allowed_workspaces):
703
+ if allowed_workspaces is not None and not self.filter_scoped_nodes(
704
+ [{"id": node_id}],
705
+ allowed_workspaces,
706
+ include_legacy_global=include_legacy_global,
707
+ ):
611
708
  raise ValueError(f"graph node not found: {node_id}")
612
709
  depth = max(0, min(int(depth or 1), 4))
613
710
  limit = max(1, min(int(limit or 100), 500))
@@ -668,383 +765,15 @@ class KnowledgeGraphRetrievalMixin:
668
765
  ]
669
766
  edges = list(edges_by_id.values())
670
767
  if allowed_workspaces is not None:
671
- nodes = self.filter_scoped_nodes(nodes, allowed_workspaces)
768
+ nodes = self.filter_scoped_nodes(
769
+ nodes,
770
+ allowed_workspaces,
771
+ include_legacy_global=include_legacy_global,
772
+ )
672
773
  kept = {node.get("id") for node in nodes}
673
774
  edges = [edge for edge in edges if edge.get("from") in kept and edge.get("to") in kept]
674
775
  return {"root": node_id, "depth": depth, "nodes": nodes, "edges": edges}
675
776
 
676
- def _iter_vector_source_items(
677
- self,
678
- conn: sqlite3.Connection,
679
- *,
680
- include_nodes: bool = True,
681
- include_chunks: bool = True,
682
- ) -> List[Dict[str, Any]]:
683
- items: List[Dict[str, Any]] = []
684
- if include_nodes:
685
- for row in conn.execute(
686
- """
687
- SELECT id, type, title, summary, metadata_json
688
- FROM nodes
689
- WHERE type <> 'Chunk'
690
- ORDER BY updated_at DESC, id ASC
691
- """
692
- ).fetchall():
693
- metadata = _safe_loads(row["metadata_json"])
694
- text = self._vector_text_for_node(
695
- title=row["title"],
696
- summary=row["summary"] or "",
697
- metadata=metadata,
698
- )
699
- if text:
700
- items.append(
701
- {
702
- "item_id": row["id"],
703
- "item_type": "node",
704
- "source_node": row["id"],
705
- "text": text,
706
- "metadata": {"node_type": row["type"], **metadata},
707
- }
708
- )
709
- if include_chunks:
710
- for row in conn.execute(
711
- """
712
- SELECT c.id, c.source_node AS parent_source_node, c.text, c.metadata_json
713
- FROM chunks c
714
- JOIN nodes n ON n.id=c.id
715
- ORDER BY c.created_at DESC, c.id ASC
716
- """
717
- ).fetchall():
718
- metadata = _safe_loads(row["metadata_json"])
719
- text = _clean_text(row["text"] or "")
720
- if text:
721
- items.append(
722
- {
723
- "item_id": row["id"],
724
- "item_type": "chunk",
725
- "source_node": row["id"],
726
- "text": text,
727
- "metadata": {
728
- **metadata,
729
- "parent_source_node": row["parent_source_node"],
730
- },
731
- }
732
- )
733
- return items
734
-
735
- def rebuild_vector_index(
736
- self,
737
- *,
738
- full: bool = False,
739
- include_nodes: bool = True,
740
- include_chunks: bool = True,
741
- ) -> Dict[str, Any]:
742
- """Rebuild the derived vector index without mutating graph content."""
743
- op_id = f"vector-op:{_sha256_text(f'{time.time()}:{os.getpid()}')[:24]}"
744
- requested_at = _now()
745
- started = time.perf_counter()
746
- try:
747
- with self._connect() as conn:
748
- conn.execute(
749
- """
750
- INSERT INTO vector_index_operations(
751
- id, operation, status, requested_at, started_at, metadata_json
752
- )
753
- VALUES (?, ?, 'running', ?, ?, ?)
754
- """,
755
- (
756
- op_id,
757
- "rebuild_full" if full else "rebuild_incremental",
758
- requested_at,
759
- requested_at,
760
- _json(
761
- {
762
- "include_nodes": include_nodes,
763
- "include_chunks": include_chunks,
764
- }
765
- ),
766
- ),
767
- )
768
- if full:
769
- filters = []
770
- if include_nodes:
771
- filters.append("'node'")
772
- if include_chunks:
773
- filters.append("'chunk'")
774
- if filters:
775
- conn.execute(
776
- f"DELETE FROM vector_embeddings WHERE item_type IN ({','.join(filters)})"
777
- )
778
- items = self._iter_vector_source_items(
779
- conn,
780
- include_nodes=include_nodes,
781
- include_chunks=include_chunks,
782
- )
783
- indexed = skipped = 0
784
- for item in items:
785
- changed = self._upsert_vector_item(conn, **item)
786
- if changed:
787
- indexed += 1
788
- else:
789
- skipped += 1
790
- duration_ms = round((time.perf_counter() - started) * 1000, 2)
791
- conn.execute(
792
- """
793
- UPDATE vector_index_operations
794
- SET status='completed', completed_at=?, items_total=?,
795
- items_indexed=?, items_skipped=?, metadata_json=?
796
- WHERE id=?
797
- """,
798
- (
799
- _now(),
800
- len(items),
801
- indexed,
802
- skipped,
803
- _json(
804
- {
805
- "include_nodes": include_nodes,
806
- "include_chunks": include_chunks,
807
- "duration_ms": duration_ms,
808
- "embedding_model": self._embedding_model.model_id,
809
- "embedding_dim": self._embedding_model.dim,
810
- }
811
- ),
812
- op_id,
813
- ),
814
- )
815
- return {
816
- "status": "completed",
817
- "operation_id": op_id,
818
- "full": bool(full),
819
- "items_total": len(items),
820
- "items_indexed": indexed,
821
- "items_skipped": skipped,
822
- "duration_ms": duration_ms,
823
- "embedding_model": self._embedding_model.model_id,
824
- "embedding_dim": self._embedding_model.dim,
825
- }
826
- except Exception as exc:
827
- duration_ms = round((time.perf_counter() - started) * 1000, 2)
828
- with self._connect() as conn:
829
- conn.execute(
830
- """
831
- INSERT INTO vector_index_operations(
832
- id, operation, status, requested_at, started_at, completed_at,
833
- error_message, metadata_json
834
- )
835
- VALUES (?, ?, 'failed', ?, ?, ?, ?, ?)
836
- ON CONFLICT(id) DO UPDATE SET
837
- status='failed',
838
- completed_at=excluded.completed_at,
839
- error_message=excluded.error_message,
840
- metadata_json=excluded.metadata_json
841
- """,
842
- (
843
- op_id,
844
- "rebuild_full" if full else "rebuild_incremental",
845
- requested_at,
846
- requested_at,
847
- _now(),
848
- str(exc),
849
- _json({"duration_ms": duration_ms}),
850
- ),
851
- )
852
- raise
853
-
854
- def index_status(self) -> Dict[str, Any]:
855
- storage_capabilities = None
856
- try:
857
- storage_capabilities = self.storage_engine.capabilities().as_dict()
858
- except Exception as exc:
859
- storage_capabilities = {
860
- "engine": "sqlite",
861
- "available": False,
862
- "reason": str(exc),
863
- }
864
- with self._connect() as conn:
865
- vector_counts = {
866
- row["item_type"]: row["count"]
867
- for row in conn.execute(
868
- "SELECT item_type, COUNT(*) AS count FROM vector_embeddings GROUP BY item_type"
869
- )
870
- }
871
- source_items = self._iter_vector_source_items(conn)
872
- vector_rows = {
873
- row["item_id"]: row
874
- for row in conn.execute(
875
- """
876
- SELECT item_id, text_hash, embedding_dim, embedding_model, indexed_at
877
- FROM vector_embeddings
878
- """
879
- ).fetchall()
880
- }
881
- latest_rows = conn.execute(
882
- """
883
- SELECT id, operation, status, requested_at, started_at, completed_at,
884
- items_total, items_indexed, items_skipped, error_message, metadata_json
885
- FROM vector_index_operations
886
- ORDER BY requested_at DESC, id DESC
887
- LIMIT 5
888
- """
889
- ).fetchall()
890
- missing = stale = ready = 0
891
- for item in source_items:
892
- vector_row = vector_rows.get(item["item_id"])
893
- expected_hash = _sha256_text(_clean_text(item["text"]))
894
- if not vector_row:
895
- missing += 1
896
- elif (
897
- vector_row["text_hash"] != expected_hash
898
- or vector_row["embedding_dim"] != self._embedding_model.dim
899
- or vector_row["embedding_model"] != self._embedding_model.model_id
900
- ):
901
- stale += 1
902
- else:
903
- ready += 1
904
- pending = missing + stale
905
- return {
906
- "status": "ready" if pending == 0 else "needs_reindex",
907
- "storage": {
908
- "db_path": str(self.db_path),
909
- "backend": "sqlite",
910
- "embedding_model": self._embedding_model.model_id,
911
- "embedding_dim": self._embedding_model.dim,
912
- # Honest capability report: trigram FTS5 keyword index, or
913
- # LIKE-scan fallback when this SQLite build lacks it.
914
- "fts_enabled": bool(getattr(self, "_fts_enabled", False)),
915
- "engine": storage_capabilities,
916
- "vector_search_backend": (
917
- storage_capabilities.get("vector_backend")
918
- if isinstance(storage_capabilities, dict)
919
- else "bruteforce-cosine"
920
- ),
921
- "vector_search_mode": (
922
- (storage_capabilities.get("metadata") or {}).get("vector_mode")
923
- if isinstance(storage_capabilities, dict)
924
- else "fallback"
925
- ),
926
- "sqlite_vec_ann_available": (
927
- bool((storage_capabilities.get("metadata") or {}).get("sqlite_vec_ann_available"))
928
- if isinstance(storage_capabilities, dict)
929
- else False
930
- ),
931
- },
932
- "source_items": len(source_items),
933
- "indexed_items": sum(vector_counts.values()),
934
- "ready_items": ready,
935
- "missing_items": missing,
936
- "stale_items": stale,
937
- "pending_items": pending,
938
- "by_item_type": vector_counts,
939
- "operations": [
940
- {
941
- "id": row["id"],
942
- "operation": row["operation"],
943
- "status": row["status"],
944
- "requested_at": row["requested_at"],
945
- "started_at": row["started_at"],
946
- "completed_at": row["completed_at"],
947
- "items_total": row["items_total"],
948
- "items_indexed": row["items_indexed"],
949
- "items_skipped": row["items_skipped"],
950
- "error_message": row["error_message"],
951
- "metadata": _safe_loads(row["metadata_json"]),
952
- }
953
- for row in latest_rows
954
- ],
955
- }
956
-
957
- def vector_search(
958
- self,
959
- query: str,
960
- *,
961
- limit: int = 30,
962
- min_score: float = 0.0,
963
- max_candidates: int = 10_000,
964
- ) -> Dict[str, Any]:
965
- query = str(query or "").strip()
966
- limit = max(1, min(int(limit or 30), 100))
967
- min_score = float(min_score or 0.0)
968
- if not query:
969
- return {"query": query, "matches": []}
970
- query_vector = self._embedding_model.embed(query)
971
- max_candidates = max(limit, min(int(max_candidates or 10_000), 50_000))
972
- with self._connect() as conn:
973
- rows = conn.execute(
974
- """
975
- SELECT
976
- ve.item_id, ve.item_type, ve.source_node, ve.embedding,
977
- ve.embedding_dim, ve.embedding_model, ve.metadata_json AS vector_metadata,
978
- n.type AS node_type, n.title AS node_title, n.summary AS node_summary,
979
- n.metadata_json AS node_metadata, n.updated_at AS node_updated_at,
980
- c.text AS chunk_text, c.source_node AS parent_node_id,
981
- pn.type AS parent_type, pn.title AS parent_title,
982
- pn.summary AS parent_summary, pn.metadata_json AS parent_metadata,
983
- pn.updated_at AS parent_updated_at
984
- FROM vector_embeddings ve
985
- LEFT JOIN nodes n ON n.id=ve.source_node
986
- LEFT JOIN chunks c ON c.id=ve.item_id
987
- LEFT JOIN nodes pn ON pn.id=c.source_node
988
- WHERE ve.embedding_model=? AND ve.embedding_dim=?
989
- ORDER BY ve.indexed_at DESC
990
- LIMIT ?
991
- """,
992
- (
993
- self._embedding_model.model_id,
994
- self._embedding_model.dim,
995
- max_candidates,
996
- ),
997
- ).fetchall()
998
- scored = []
999
- for row in rows:
1000
- vector = self._embedding_model.decode(
1001
- row["embedding"], row["embedding_dim"]
1002
- )
1003
- score = self._embedding_model.similarity(query_vector, vector)
1004
- if score < min_score:
1005
- continue
1006
- is_chunk = row["item_type"] == "chunk"
1007
- summary = (
1008
- row["chunk_text"]
1009
- if is_chunk and row["chunk_text"]
1010
- else row["node_summary"]
1011
- )
1012
- parent_metadata = _safe_loads(row["parent_metadata"])
1013
- node_metadata = _safe_loads(row["node_metadata"])
1014
- scored.append(
1015
- {
1016
- "id": row["item_id"],
1017
- "node_id": row["parent_node_id"]
1018
- if is_chunk and row["parent_node_id"]
1019
- else row["source_node"],
1020
- "item_type": row["item_type"],
1021
- "type": "Chunk" if is_chunk else row["node_type"],
1022
- "title": row["parent_title"]
1023
- if is_chunk and row["parent_title"]
1024
- else row["node_title"],
1025
- "summary": _clean_text(summary or "")[:1000],
1026
- "score": round(float(score), 6),
1027
- "metadata": {
1028
- **(parent_metadata if is_chunk else node_metadata),
1029
- "vector": _safe_loads(row["vector_metadata"]),
1030
- "parent_node_id": row["parent_node_id"],
1031
- "parent_type": row["parent_type"],
1032
- },
1033
- "updated_at": row["parent_updated_at"]
1034
- if is_chunk and row["parent_updated_at"]
1035
- else row["node_updated_at"],
1036
- }
1037
- )
1038
- scored.sort(
1039
- key=lambda item: (item["score"], item.get("updated_at") or ""), reverse=True
1040
- )
1041
- return {
1042
- "query": query,
1043
- "embedding_model": self._embedding_model.model_id,
1044
- "embedding_dim": self._embedding_model.dim,
1045
- "matches": scored[:limit],
1046
- }
1047
-
1048
777
  def delete_conversation(self, conversation_id: str) -> Dict[str, Any]:
1049
778
  conversation_id = str(conversation_id or "").strip()
1050
779
  if not conversation_id:
@@ -1180,201 +909,3 @@ class KnowledgeGraphRetrievalMixin:
1180
909
  "local_file_status": local_file_status,
1181
910
  "v2": v2,
1182
911
  }
1183
-
1184
- def search_for_document_generation(
1185
- self, query: str, limit: int = 10
1186
- ) -> List[Dict[str, Any]]:
1187
- """Hybrid retrieval optimized for document generation.
1188
-
1189
- Scoring: 0.5*text_relevance + 0.3*graph_relationship + 0.2*recency
1190
- Returns nodes with rich context for document generation prompts.
1191
- """
1192
- query = str(query or "").strip()
1193
- if not query:
1194
- return []
1195
- limit = max(1, min(int(limit or 10), 50))
1196
- terms = _topic_candidates(query, limit=12)
1197
- now = datetime.now()
1198
- nt, et = self._read_tables()
1199
-
1200
- with self._connect() as conn:
1201
- candidate_rows = []
1202
- seen_ids = set()
1203
-
1204
- if query:
1205
- q = f"%{query}%"
1206
- rows = conn.execute(
1207
- f"""
1208
- SELECT id, type, title, summary, metadata_json, updated_at
1209
- FROM {nt}
1210
- WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
1211
- AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
1212
- 'Spreadsheet', 'Image', 'ImageText', 'Chat',
1213
- 'Decision', 'Task', 'Concept', 'Feature',
1214
- 'Page', 'Slide')
1215
- ORDER BY updated_at DESC, id ASC
1216
- LIMIT ?
1217
- """,
1218
- (q, q, q, limit * 5),
1219
- ).fetchall()
1220
- for row in rows:
1221
- if row["id"] not in seen_ids:
1222
- seen_ids.add(row["id"])
1223
- candidate_rows.append(row)
1224
-
1225
- for term in terms:
1226
- t = f"%{term}%"
1227
- rows = conn.execute(
1228
- f"""
1229
- SELECT id, type, title, summary, metadata_json, updated_at
1230
- FROM {nt}
1231
- WHERE (title LIKE ? OR summary LIKE ? OR metadata_json LIKE ?)
1232
- AND type IN ('Document', 'File', 'CodeFile', 'SlideDeck',
1233
- 'Spreadsheet', 'Image', 'ImageText', 'Chat',
1234
- 'Decision', 'Task', 'Concept', 'Feature',
1235
- 'Page', 'Slide')
1236
- ORDER BY updated_at DESC, id ASC
1237
- LIMIT ?
1238
- """,
1239
- (t, t, t, limit * 3),
1240
- ).fetchall()
1241
- for row in rows:
1242
- if row["id"] not in seen_ids:
1243
- seen_ids.add(row["id"])
1244
- candidate_rows.append(row)
1245
-
1246
- scored_results = []
1247
- for row in candidate_rows:
1248
- haystack = (
1249
- f"{row['title']} {row['summary']} {row['metadata_json']}".lower()
1250
- )
1251
-
1252
- text_hits = sum(1 for term in terms if term.lower() in haystack)
1253
- text_score = min(1.0, text_hits / max(len(terms), 1))
1254
-
1255
- edge_count = conn.execute(
1256
- f"SELECT COUNT(*) AS c FROM {et} WHERE from_node=? OR to_node=?",
1257
- (row["id"], row["id"]),
1258
- ).fetchone()["c"]
1259
- graph_score = min(1.0, math.log1p(edge_count) / 4.0)
1260
-
1261
- recency = _recency_score(
1262
- row["updated_at"], now=now, half_life_days=14.0
1263
- )
1264
-
1265
- doc_type_boost = (
1266
- 1.2
1267
- if row["type"]
1268
- in (
1269
- "Document",
1270
- "File",
1271
- "SlideDeck",
1272
- "Decision",
1273
- )
1274
- else 1.0
1275
- )
1276
-
1277
- hybrid_score = (
1278
- 0.5 * text_score + 0.3 * graph_score + 0.2 * recency
1279
- ) * doc_type_boost
1280
-
1281
- meta = _safe_loads(row["metadata_json"])
1282
- neighbor_concepts = []
1283
- neighbor_rows = conn.execute(
1284
- f"""
1285
- SELECT n.title, n.type FROM {et} e
1286
- JOIN {nt} n ON n.id = CASE WHEN e.from_node = ? THEN e.to_node ELSE e.from_node END
1287
- WHERE (e.from_node = ? OR e.to_node = ?)
1288
- AND n.type IN ('Concept', 'Feature', 'Decision', 'Task')
1289
- LIMIT 8
1290
- """,
1291
- (row["id"], row["id"], row["id"]),
1292
- ).fetchall()
1293
- for nr in neighbor_rows:
1294
- neighbor_concepts.append({"title": nr["title"], "type": nr["type"]})
1295
-
1296
- scored_results.append(
1297
- {
1298
- "id": row["id"],
1299
- "type": row["type"],
1300
- "title": row["title"],
1301
- "summary": row["summary"],
1302
- "metadata": meta,
1303
- "updated_at": row["updated_at"],
1304
- "hybrid_score": round(hybrid_score, 4),
1305
- "scores": {
1306
- "text": round(text_score, 4),
1307
- "graph": round(graph_score, 4),
1308
- "recency": round(recency, 4),
1309
- },
1310
- "related_concepts": neighbor_concepts,
1311
- }
1312
- )
1313
-
1314
- scored_results.sort(key=lambda x: x["hybrid_score"], reverse=True)
1315
- return scored_results[:limit]
1316
-
1317
- def multi_hop_context(
1318
- self, node_ids: List[str], max_hops: int = 2
1319
- ) -> Dict[str, Any]:
1320
- """Multi-hop graph traversal from seed nodes for richer context."""
1321
- visited_nodes = set()
1322
- visited_edges = set()
1323
- all_nodes = []
1324
- all_edges = []
1325
- frontier = set(node_ids)
1326
- nt, et = self._read_tables()
1327
-
1328
- with self._connect() as conn:
1329
- for hop in range(max_hops):
1330
- if not frontier:
1331
- break
1332
- next_frontier = set()
1333
- for nid in frontier:
1334
- if nid in visited_nodes:
1335
- continue
1336
- visited_nodes.add(nid)
1337
- row = conn.execute(
1338
- f"SELECT id, type, title, summary, metadata_json, updated_at FROM {nt} WHERE id=?",
1339
- (nid,),
1340
- ).fetchone()
1341
- if row:
1342
- all_nodes.append(
1343
- {
1344
- "id": row["id"],
1345
- "type": row["type"],
1346
- "title": row["title"],
1347
- "summary": row["summary"],
1348
- "metadata": _safe_loads(row["metadata_json"]),
1349
- "hop": hop,
1350
- }
1351
- )
1352
- edge_rows = conn.execute(
1353
- f"""
1354
- SELECT id, from_node, to_node, type, weight
1355
- FROM {et} WHERE from_node=? OR to_node=?
1356
- ORDER BY id ASC
1357
- """,
1358
- (nid, nid),
1359
- ).fetchall()
1360
- for er in edge_rows:
1361
- if er["id"] not in visited_edges:
1362
- visited_edges.add(er["id"])
1363
- all_edges.append(
1364
- {
1365
- "from": er["from_node"],
1366
- "to": er["to_node"],
1367
- "type": er["type"],
1368
- "weight": er["weight"],
1369
- }
1370
- )
1371
- other = (
1372
- er["to_node"]
1373
- if er["from_node"] == nid
1374
- else er["from_node"]
1375
- )
1376
- if other not in visited_nodes:
1377
- next_frontier.add(other)
1378
- frontier = next_frontier
1379
-
1380
- return {"nodes": all_nodes, "edges": all_edges}