ltcai 3.6.0 → 4.0.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 (169) hide show
  1. package/README.md +11 -7
  2. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  3. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  4. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  5. package/docs/kg-schema.md +47 -53
  6. package/kg_schema.py +93 -10
  7. package/knowledge_graph.py +362 -33
  8. package/knowledge_graph_api.py +11 -127
  9. package/latticeai/__init__.py +1 -1
  10. package/latticeai/api/admin.py +1 -1
  11. package/latticeai/api/agents.py +7 -1
  12. package/latticeai/api/auth.py +27 -4
  13. package/latticeai/api/chat.py +112 -76
  14. package/latticeai/api/health.py +1 -1
  15. package/latticeai/api/hooks.py +1 -1
  16. package/latticeai/api/knowledge_graph.py +146 -0
  17. package/latticeai/api/local_files.py +1 -1
  18. package/latticeai/api/mcp.py +23 -11
  19. package/latticeai/api/memory.py +1 -1
  20. package/latticeai/api/models.py +1 -1
  21. package/latticeai/api/network.py +81 -0
  22. package/latticeai/api/realtime.py +1 -1
  23. package/latticeai/api/search.py +26 -2
  24. package/latticeai/api/security_dashboard.py +2 -3
  25. package/latticeai/api/setup.py +2 -2
  26. package/latticeai/api/static_routes.py +2 -4
  27. package/latticeai/api/tools.py +3 -0
  28. package/latticeai/api/workflow_designer.py +46 -0
  29. package/latticeai/api/workspace.py +71 -49
  30. package/latticeai/app_factory.py +1710 -0
  31. package/latticeai/brain/__init__.py +18 -0
  32. package/latticeai/brain/context.py +213 -0
  33. package/latticeai/brain/conversations.py +236 -0
  34. package/latticeai/brain/identity.py +175 -0
  35. package/latticeai/brain/memory.py +102 -0
  36. package/latticeai/brain/network.py +205 -0
  37. package/latticeai/core/agent.py +31 -7
  38. package/latticeai/core/audit.py +0 -7
  39. package/latticeai/core/config.py +1 -1
  40. package/latticeai/core/context_builder.py +1 -2
  41. package/latticeai/core/enterprise.py +1 -1
  42. package/latticeai/core/graph_curator.py +2 -2
  43. package/latticeai/core/marketplace.py +1 -1
  44. package/latticeai/core/mcp_registry.py +791 -0
  45. package/latticeai/core/model_compat.py +1 -1
  46. package/latticeai/core/model_resolution.py +0 -1
  47. package/latticeai/core/multi_agent.py +238 -4
  48. package/latticeai/core/security.py +1 -1
  49. package/latticeai/core/sessions.py +37 -7
  50. package/latticeai/core/workflow_engine.py +114 -2
  51. package/latticeai/core/workspace_os.py +58 -10
  52. package/latticeai/models/__init__.py +7 -0
  53. package/latticeai/models/router.py +779 -0
  54. package/latticeai/server_app.py +29 -1536
  55. package/latticeai/services/agent_runtime.py +1 -0
  56. package/latticeai/services/app_context.py +75 -14
  57. package/latticeai/services/ingestion.py +47 -0
  58. package/latticeai/services/kg_portability.py +33 -3
  59. package/latticeai/services/memory_service.py +39 -11
  60. package/latticeai/services/model_runtime.py +2 -5
  61. package/latticeai/services/platform_runtime.py +100 -23
  62. package/latticeai/services/search_service.py +17 -8
  63. package/latticeai/services/tool_dispatch.py +12 -2
  64. package/latticeai/services/triggers.py +241 -0
  65. package/latticeai/services/upload_service.py +37 -12
  66. package/latticeai/services/workspace_service.py +31 -0
  67. package/llm_router.py +29 -772
  68. package/ltcai_cli.py +1 -2
  69. package/mcp_registry.py +25 -788
  70. package/p_reinforce.py +124 -14
  71. package/package.json +9 -7
  72. package/scripts/bump_version.py +99 -0
  73. package/scripts/generate_diagrams.py +0 -1
  74. package/scripts/lint_v3.mjs +82 -18
  75. package/scripts/validate_release_artifacts.py +0 -1
  76. package/scripts/wheel_smoke.py +142 -0
  77. package/server.py +11 -7
  78. package/setup_wizard.py +1142 -0
  79. package/static/account.html +2 -4
  80. package/static/admin.html +3 -5
  81. package/static/chat.html +3 -6
  82. package/static/graph.html +2 -4
  83. package/static/sw.js +81 -52
  84. package/static/v3/asset-manifest.json +20 -19
  85. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  86. package/static/v3/css/lattice.base.css +1 -1
  87. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  88. package/static/v3/css/lattice.components.css +1 -1
  89. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  90. package/static/v3/css/lattice.shell.css +1 -1
  91. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  92. package/static/v3/css/lattice.tokens.css +3 -0
  93. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  94. package/static/v3/css/lattice.views.css +2 -2
  95. package/static/v3/index.html +3 -4
  96. package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
  97. package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
  98. package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
  99. package/static/v3/js/core/routes.js +22 -22
  100. package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
  101. package/static/v3/js/core/shell.js +1 -1
  102. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  103. package/static/v3/js/core/store.js +1 -1
  104. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  105. package/static/v3/js/views/graph-canvas.js +509 -0
  106. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  107. package/static/v3/js/views/hybrid-search.js +1 -2
  108. package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
  109. package/static/v3/js/views/knowledge-graph.js +33 -37
  110. package/static/vendor/chart.umd.min.js +20 -0
  111. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  112. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  113. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  114. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  115. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  116. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  117. package/static/vendor/fonts/inter.css +44 -0
  118. package/static/vendor/icons/tabler-icons.min.css +4 -0
  119. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  120. package/static/vendor/marked.min.js +69 -0
  121. package/static/workspace.html +2 -2
  122. package/telegram_bot.py +1 -2
  123. package/tools/commands.py +4 -2
  124. package/tools/computer.py +1 -1
  125. package/tools/documents.py +1 -3
  126. package/tools/filesystem.py +0 -4
  127. package/tools/knowledge.py +1 -3
  128. package/tools/network.py +1 -3
  129. package/codex_telegram_bot.py +0 -195
  130. package/docs/assets/v3.4.0/agent-run.png +0 -0
  131. package/docs/assets/v3.4.0/agents.png +0 -0
  132. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  133. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  134. package/docs/assets/v3.4.0/chat.png +0 -0
  135. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  136. package/docs/assets/v3.4.0/files.png +0 -0
  137. package/docs/assets/v3.4.0/home.png +0 -0
  138. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  139. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  140. package/docs/assets/v3.4.0/local-agent.png +0 -0
  141. package/docs/assets/v3.4.0/memory.png +0 -0
  142. package/docs/assets/v3.4.0/settings.png +0 -0
  143. package/docs/assets/v3.4.0/vision-input.png +0 -0
  144. package/docs/assets/v3.4.0/workflows.png +0 -0
  145. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  146. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  147. package/docs/assets/v3.4.1/local-agent.png +0 -0
  148. package/docs/images/admin-dashboard.png +0 -0
  149. package/docs/images/architecture.png +0 -0
  150. package/docs/images/enterprise.png +0 -0
  151. package/docs/images/graph.png +0 -0
  152. package/docs/images/hero.gif +0 -0
  153. package/docs/images/knowledge-graph.png +0 -0
  154. package/docs/images/lattice-ai-demo.gif +0 -0
  155. package/docs/images/lattice-ai-hero.png +0 -0
  156. package/docs/images/logo.svg +0 -33
  157. package/docs/images/mobile-responsive.png +0 -0
  158. package/docs/images/model-recommendation.png +0 -0
  159. package/docs/images/onboarding.png +0 -0
  160. package/docs/images/organization.png +0 -0
  161. package/docs/images/pipeline.png +0 -0
  162. package/docs/images/screenshot-admin.png +0 -0
  163. package/docs/images/screenshot-chat.png +0 -0
  164. package/docs/images/screenshot-graph.png +0 -0
  165. package/docs/images/skills.png +0 -0
  166. package/docs/images/workspace-dark.png +0 -0
  167. package/docs/images/workspace-light.png +0 -0
  168. package/docs/images/workspace.png +0 -0
  169. package/requirements.txt +0 -16
@@ -242,6 +242,7 @@ class AgentRuntime:
242
242
  user_email=user_email or None,
243
243
  graph=self._workspace_graph(),
244
244
  workspace_id=scope,
245
+ mode=getattr(result, "mode", "simulation"),
245
246
  )
246
247
  self._append_audit_event(
247
248
  "multi_agent_run",
@@ -1,27 +1,88 @@
1
1
  """Application dependency context for router assembly.
2
2
 
3
- The concrete FastAPI app is still assembled in ``server_app``. This dataclass
4
- documents the shared dependency boundary for routers and services so future
5
- extractions can receive a typed context instead of importing the app module.
3
+ ``latticeai.app_factory.create_app`` builds one ``AppContext`` per app and
4
+ hands it to router factories, replacing the historical 25-30-kwarg closure
5
+ wiring. Every field defaults to ``None``-ish so tests can construct a context
6
+ carrying only the dependencies a router actually touches.
7
+
8
+ Fields are grouped by the consumer that motivated them; routers must treat the
9
+ context as read-only.
6
10
  """
7
11
 
8
12
  from __future__ import annotations
9
13
 
10
14
  from dataclasses import dataclass
11
15
  from pathlib import Path
12
- from typing import Any, Callable
16
+ from typing import Any, Callable, Optional
13
17
 
14
18
 
15
19
  @dataclass(frozen=True)
16
20
  class AppContext:
17
- config: Any
18
- data_dir: Path
19
- static_dir: Path
20
- model_router: Any
21
- workspace_store: Any
22
- workspace_service: Any
23
- knowledge_graph: Any
24
- local_kg_watcher: Any
25
- require_user: Callable[..., str]
26
- require_admin: Callable[..., tuple]
21
+ # ── core configuration / paths ────────────────────────────────────────
22
+ config: Any = None
23
+ data_dir: Optional[Path] = None
24
+ static_dir: Optional[Path] = None
25
+ base_dir: Optional[Path] = None
26
+ skills_dir: Optional[Path] = None
27
+
28
+ # ── singletons ────────────────────────────────────────────────────────
29
+ model_router: Any = None
30
+ workspace_store: Any = None
31
+ workspace_service: Any = None
32
+ knowledge_graph: Any = None
33
+ local_kg_watcher: Any = None
34
+ chat_service: Any = None
35
+ context_assembler: Any = None
36
+ brain_memory: Any = None
37
+ gardener: Any = None
38
+ hooks: Any = None
39
+ realtime_bus: Any = None
40
+ capability_registry: Any = None
41
+
42
+ # ── auth / session callables ──────────────────────────────────────────
43
+ require_user: Optional[Callable[..., str]] = None
44
+ require_admin: Optional[Callable[..., tuple]] = None
45
+ get_current_user: Optional[Callable[..., Optional[str]]] = None
46
+ load_users: Optional[Callable[[], dict]] = None
47
+ get_user_role: Optional[Callable[..., str]] = None
48
+ enforce_rate_limit: Optional[Callable[..., None]] = None
49
+
50
+ # ── audit / history callables ─────────────────────────────────────────
51
+ append_audit_event: Optional[Callable[..., None]] = None
52
+ get_audit_log: Optional[Callable[[], list]] = None
53
+ get_history: Optional[Callable[[], list]] = None
54
+ get_history_user: Optional[Callable[..., dict]] = None
55
+ save_to_history: Optional[Callable[..., None]] = None
56
+ clear_history: Optional[Callable[..., dict]] = None
57
+ clear_conversation: Optional[Callable[..., dict]] = None
58
+ group_history_conversations: Optional[Callable[..., list]] = None
59
+ get_conversation_messages: Optional[Callable[..., list]] = None
60
+ conversation_title: Optional[Callable[..., str]] = None
61
+
62
+ # ── knowledge graph access ────────────────────────────────────────────
63
+ enable_graph: bool = False
64
+ require_graph: Optional[Callable[[], None]] = None
65
+ workspace_graph: Optional[Callable[[], Any]] = None
66
+ graph_stats: Optional[Callable[[], dict]] = None
67
+
68
+ # ── workspace payload providers / skills ──────────────────────────────
69
+ workspace_models: Optional[Callable[[], dict]] = None
70
+ workspace_settings: Optional[Callable[[], dict]] = None
71
+ scan_environment: Optional[Callable[[], Any]] = None
72
+ local_sysinfo: Optional[Callable[..., Any]] = None
73
+ get_recommendations: Optional[Callable[..., Any]] = None
74
+ fetch_skills_marketplace: Optional[Callable[..., Any]] = None
75
+ install_skill: Optional[Callable[..., Any]] = None
76
+ remove_skill_directory: Optional[Callable[..., dict]] = None
77
+ redact_secret_text: Optional[Callable[[str], str]] = None
78
+ ui_file_response: Optional[Callable[..., Any]] = None
79
+
80
+ # ── models ────────────────────────────────────────────────────────────
81
+ public_model: str = ""
82
+ local_model: str = ""
27
83
 
84
+ # ── integrations ──────────────────────────────────────────────────────
85
+ # Fired as on_chat_message(role, text, source) after a chat exchange is
86
+ # persisted; ``None`` means no external chat mirror is registered. The
87
+ # telegram bridge subscribes here only when ENABLE_TELEGRAM is truthy.
88
+ on_chat_message: Optional[Callable[..., None]] = None
@@ -33,6 +33,15 @@ FILE_SOURCE_TYPES = frozenset({"file", "local_file", "upload", "pdf"})
33
33
  TEXT_SOURCE_TYPES = frozenset(
34
34
  {"web_url", "browser_tab", "text", "markdown", "note", "code", "clipboard"}
35
35
  )
36
+ # Conversational exchanges (read via ingest_message — role/content semantics,
37
+ # conversation chaining). v4: chat and MCP messages stop bypassing the
38
+ # pipeline, so they carry provenance and fire the hook lifecycle like every
39
+ # other source.
40
+ CHAT_SOURCE_TYPES = frozenset({"chat_message", "mcp_message"})
41
+ # Typed memory records (read via ingest_event → Decision/Experience/Event
42
+ # nodes). The Memory System writes through the same door as everything else.
43
+ MEMORY_SOURCE_TYPES = frozenset({"decision", "experience", "workspace_event"})
44
+ _MEMORY_NODE_TYPES = {"decision": "Decision", "experience": "Experience", "workspace_event": "Event"}
36
45
 
37
46
  DEFAULT_MAX_TEXT_BYTES = 5 * 1024 * 1024 # 5 MB of extracted text per item
38
47
 
@@ -143,6 +152,10 @@ class IngestionPipeline:
143
152
  }
144
153
 
145
154
  def _run() -> Dict[str, Any]:
155
+ if source_type in CHAT_SOURCE_TYPES:
156
+ return self._ingest_chat(item, source_type=source_type, owner=owner)
157
+ if source_type in MEMORY_SOURCE_TYPES:
158
+ return self._ingest_memory_record(item, source_type=source_type, owner=owner)
146
159
  if source_type in FILE_SOURCE_TYPES or (item.path and not item.text):
147
160
  return self._ingest_file(item, source_type=source_type, owner=owner, captured_at=captured_at)
148
161
  return self._ingest_text(item, source_type=source_type, owner=owner, captured_at=captured_at)
@@ -243,6 +256,40 @@ class IngestionPipeline:
243
256
  metadata={"mime_type": item.mime_type, **(item.metadata or {})},
244
257
  )
245
258
 
259
+ def _ingest_chat(self, item, *, source_type, owner) -> Dict[str, Any]:
260
+ text = item.text or ""
261
+ meta = item.metadata or {}
262
+ role = str(meta.get("role") or "user")
263
+ result = self._kg.ingest_message(
264
+ role,
265
+ text,
266
+ user_email=owner,
267
+ user_nickname=meta.get("user_nickname"),
268
+ source=meta.get("source") or source_type,
269
+ conversation_id=item.conversation_id,
270
+ raw=meta.get("raw"),
271
+ )
272
+ # ingest_message reports message/response node ids; normalize the keys
273
+ # the provenance step expects.
274
+ result.setdefault("node_id", result.get("node_id") or result.get("message_node_id") or result.get("id"))
275
+ result.setdefault("title", item.title or text[:80])
276
+ return result
277
+
278
+ def _ingest_memory_record(self, item, *, source_type, owner) -> Dict[str, Any]:
279
+ node_type = _MEMORY_NODE_TYPES[source_type]
280
+ meta = item.metadata or {}
281
+ result = self._kg.ingest_event(
282
+ node_type,
283
+ item.title or (item.text or node_type)[:120],
284
+ user_email=owner,
285
+ source=meta.get("source") or source_type,
286
+ conversation_id=item.conversation_id,
287
+ metadata={**meta, "detail": (item.text or "")[:2000]},
288
+ )
289
+ result.setdefault("node_id", result.get("node_id") or result.get("id"))
290
+ result.setdefault("title", item.title)
291
+ return result
292
+
246
293
  def _ingest_file(self, item, *, source_type, owner, captured_at) -> Dict[str, Any]:
247
294
  if not item.path:
248
295
  raise ValueError("File ingestion requires a path.")
@@ -44,11 +44,16 @@ def _sha256_file(path: Path) -> str:
44
44
 
45
45
 
46
46
  class KGPortabilityService:
47
- def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True) -> None:
47
+ def __init__(self, *, knowledge_graph: Any, data_dir, enable_graph: bool = True, device_identity: Any = None) -> None:
48
48
  self._kg = knowledge_graph
49
49
  self._data_dir = Path(data_dir)
50
50
  self._enable = bool(enable_graph)
51
51
  self._exports_dir = self._data_dir / "workspace_exports"
52
+ # v4 sovereignty: when a DeviceIdentity is wired, exports are signed
53
+ # and imports record origin provenance. Pre-v4 unsigned bundles stay
54
+ # importable locally (origin='unsigned-legacy') — signatures are
55
+ # mandatory only on the Brain Network peer path.
56
+ self._identity = device_identity
52
57
 
53
58
  def available(self) -> bool:
54
59
  return self._enable and self._kg is not None
@@ -60,7 +65,7 @@ class KGPortabilityService:
60
65
  # ── logical export / import ──────────────────────────────────────────────
61
66
  def export(self, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
62
67
  self._require()
63
- data = self._kg.export_graph_data()
68
+ data = self._kg.export_graph_data(workspace_id=workspace_id)
64
69
  header = {
65
70
  "format": FORMAT,
66
71
  "format_version": FORMAT_VERSION,
@@ -69,7 +74,10 @@ class KGPortabilityService:
69
74
  "workspace_id": workspace_id,
70
75
  "counts": data.get("counts"),
71
76
  }
72
- return {"header": header, **data}
77
+ artifact = {"header": header, **data}
78
+ if self._identity is not None:
79
+ artifact["signature"] = self._identity.sign_manifest(header)
80
+ return artifact
73
81
 
74
82
  def export_to_file(self, path=None, *, workspace_id: Optional[str] = None) -> Dict[str, Any]:
75
83
  artifact = self.export(workspace_id=workspace_id)
@@ -84,8 +92,30 @@ class KGPortabilityService:
84
92
  raise ValueError("Invalid Knowledge Graph export artifact.")
85
93
  if mode not in ("merge", "replace"):
86
94
  raise ValueError("mode must be 'merge' or 'replace'.")
95
+ origin = "unsigned-legacy"
96
+ signature = artifact.get("signature")
97
+ if signature:
98
+ from latticeai.brain.identity import verify_manifest
99
+
100
+ if not verify_manifest(artifact.get("header") or {}, signature):
101
+ raise ValueError("Bundle signature verification failed — refusing to import.")
102
+ origin = f"device:{signature.get('fingerprint') or 'unknown'}"
87
103
  result = self._kg.import_graph_data(artifact, mode=mode, dry_run=dry_run)
88
104
  result["header"] = artifact.get("header")
105
+ result["origin"] = origin
106
+ result["signed"] = bool(signature)
107
+ if not dry_run:
108
+ try:
109
+ self._kg.record_provenance(
110
+ node_id="import:" + str((artifact.get("header") or {}).get("exported_at") or _now_iso()),
111
+ source_type="bundle_import",
112
+ pipeline="kg-portability",
113
+ owner=None,
114
+ metadata={"origin": origin, "mode": mode,
115
+ "counts": (artifact.get("header") or {}).get("counts")},
116
+ )
117
+ except Exception:
118
+ pass
89
119
  return result
90
120
 
91
121
  def import_from_file(self, path, *, mode: str = "merge", dry_run: bool = False) -> Dict[str, Any]:
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  import json
25
25
  from datetime import datetime
26
26
  from pathlib import Path
27
- from typing import Any, Callable, Dict, List, Optional
27
+ from typing import Any, Dict, List, Optional
28
28
 
29
29
  # Personal workspace memory kinds (from WorkspaceOS.MEMORY_KINDS).
30
30
  WORKSPACE_KINDS = (
@@ -60,12 +60,16 @@ class MemoryService:
60
60
  knowledge_graph: Any = None,
61
61
  enable_graph: bool = True,
62
62
  history_file: Optional[Path] = None,
63
+ conversation_store: Any = None,
63
64
  ):
64
65
  self._store = store
65
66
  self._kg = knowledge_graph
66
67
  self._enable_graph = bool(enable_graph and knowledge_graph is not None)
67
68
  self._data_dir = Path(data_dir)
68
69
  self._history_file = Path(history_file) if history_file else (self._data_dir / "chat_history.json")
70
+ # v4: the durable SQLite conversation store supersedes the JSON file
71
+ # as the conversation tier's backing store when provided.
72
+ self._conversation_store = conversation_store
69
73
 
70
74
  # ── helpers over the underlying stores ────────────────────────────────
71
75
  def _workspace_memories(self, *, user_email: Optional[str], workspace_id: Optional[str]) -> List[Dict[str, Any]]:
@@ -87,6 +91,14 @@ class MemoryService:
87
91
  return []
88
92
 
89
93
  def _conversations(self) -> List[Dict[str, Any]]:
94
+ if self._conversation_store is not None:
95
+ try:
96
+ grouped: Dict[str, List[Dict[str, Any]]] = {}
97
+ for item in self._conversation_store.history():
98
+ grouped.setdefault(item.get("conversation_id") or "legacy-previous-history", []).append(item)
99
+ return [{"id": conv_id, "messages": msgs} for conv_id, msgs in grouped.items()]
100
+ except Exception:
101
+ return []
90
102
  if not self._history_file.exists():
91
103
  return []
92
104
  try:
@@ -130,7 +142,10 @@ class MemoryService:
130
142
 
131
143
  ws_bytes = _file_size(self._data_dir / "workspace_os.json")
132
144
  kg_bytes = _file_size(self._data_dir / "knowledge_graph.sqlite")
133
- conv_bytes = _file_size(self._history_file)
145
+ if self._conversation_store is not None:
146
+ conv_bytes = int(getattr(self._conversation_store, "size_bytes", lambda: 0)())
147
+ else:
148
+ conv_bytes = _file_size(self._history_file)
134
149
 
135
150
  node_total = sum((kg_stats or {}).get("nodes", {}).values()) if kg_stats else None
136
151
  edge_total = sum((kg_stats or {}).get("edges", {}).values()) if kg_stats else None
@@ -159,7 +174,7 @@ class MemoryService:
159
174
  {
160
175
  "id": "conversation", "type": "conversation", "label": "Conversation Memory",
161
176
  "count": len(convs), "size_bytes": conv_bytes,
162
- "health": "ok" if self._history_file.exists() else "empty",
177
+ "health": "ok" if (self._conversation_store is not None or self._history_file.exists()) else "empty",
163
178
  "detail": "Historical interaction memory from chat.",
164
179
  },
165
180
  {
@@ -202,6 +217,18 @@ class MemoryService:
202
217
  limit: int = 20,
203
218
  ) -> Dict[str, Any]:
204
219
  q = str(query or "").strip()
220
+ query_tokens = [tok for tok in q.lower().split() if tok]
221
+
222
+ def _lexical_score(*texts: Any) -> float:
223
+ # Honest, comparable relevance: fraction of query tokens present.
224
+ # Both tiers share this scorer so the cross-tier ranking is real,
225
+ # not an artifact of per-tier constants.
226
+ if not query_tokens:
227
+ return 0.0
228
+ haystack = " ".join(str(t or "") for t in texts).lower()
229
+ hits = sum(1 for tok in query_tokens if tok in haystack)
230
+ return round(hits / len(query_tokens), 4)
231
+
205
232
  results: List[Dict[str, Any]] = []
206
233
 
207
234
  try:
@@ -215,23 +242,24 @@ class MemoryService:
215
242
  "title": (m.get("kind") or "memory"),
216
243
  "snippet": str(m.get("content") or "")[:240],
217
244
  "kind": m.get("kind"),
218
- "score": 0.6,
245
+ "score": _lexical_score(m.get("content"), " ".join(m.get("tags") or []), m.get("kind")),
219
246
  "tags": m.get("tags") or [],
220
247
  })
221
248
 
222
249
  if self._enable_graph and q:
223
250
  try:
224
- hits = self._kg.search(q, limit).get("results", [])
251
+ # KnowledgeGraph.search returns {"query": ..., "matches": [...]}.
252
+ hits = self._kg.search(q, limit).get("matches", [])
225
253
  except Exception:
226
254
  hits = []
227
- for hsit in hits[:limit]:
255
+ for hit in hits[:limit]:
228
256
  results.append({
229
257
  "source": "graph",
230
- "id": hsit.get("id") or hsit.get("node_id"),
231
- "title": hsit.get("title") or hsit.get("name") or "node",
232
- "snippet": str(hsit.get("summary") or hsit.get("content") or "")[:240],
233
- "kind": hsit.get("type") or "node",
234
- "score": float(hsit.get("score") or 0.5),
258
+ "id": hit.get("id") or hit.get("node_id"),
259
+ "title": hit.get("title") or hit.get("name") or "node",
260
+ "snippet": str(hit.get("summary") or hit.get("content") or "")[:240],
261
+ "kind": hit.get("type") or "node",
262
+ "score": _lexical_score(hit.get("title"), hit.get("name"), hit.get("summary"), hit.get("content")),
235
263
  })
236
264
 
237
265
  results.sort(key=lambda r: r.get("score", 0), reverse=True)
@@ -18,7 +18,6 @@ import re
18
18
  import shutil
19
19
  import subprocess
20
20
  import sys
21
- import tempfile
22
21
  import threading
23
22
  import time
24
23
  import urllib.error
@@ -26,16 +25,14 @@ import urllib.request
26
25
  from pathlib import Path
27
26
  from typing import AsyncIterator, Dict, List, Optional
28
27
 
29
- import httpx
30
28
  from fastapi import HTTPException, Request
31
29
 
32
- from llm_router import (
30
+ from latticeai.models.router import (
33
31
  AsyncOpenAI,
34
32
  HF_MODELS_ROOT,
35
33
  OPENAI_COMPATIBLE_PROVIDERS,
36
34
  ensure_mlx_runtime,
37
35
  hf_model_dir,
38
- normalize_branding,
39
36
  parse_model_ref,
40
37
  )
41
38
  from latticeai.core.model_compat import (
@@ -89,7 +86,7 @@ def configure_model_runtime(**deps) -> None:
89
86
  # Catalog data + version-dedup helpers live in ``model_catalog``; re-exported
90
87
  # here so existing ``from ...model_runtime import ENGINE_MODEL_CATALOG`` imports
91
88
  # keep working.
92
- from latticeai.services.model_catalog import ( # noqa: F401 (re-export)
89
+ from latticeai.services.model_catalog import ( # noqa: E402, F401 (re-export after the module globals it documents)
93
90
  ENGINE_INSTALLERS,
94
91
  ENGINE_MODEL_CATALOG,
95
92
  MODEL_ENGINE_ALIASES,
@@ -18,8 +18,9 @@ from typing import Any, Callable, Dict, Optional, Set
18
18
  from fastapi import HTTPException, Request
19
19
 
20
20
  from latticeai.core.hooks import dispatch_tool
21
- from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner
22
- from latticeai.core.workflow_engine import WorkflowEngine
21
+ from latticeai.core.multi_agent import MultiAgentOrchestrator, default_role_runner, llm_role_runner
22
+ from latticeai.core.workflow_engine import ApprovalRequired, WorkflowEngine
23
+ from tools import execute_tool
23
24
 
24
25
 
25
26
  class PlatformRuntime:
@@ -34,6 +35,9 @@ class PlatformRuntime:
34
35
  workspace_scope_from_request: Callable[[Request], Optional[str]],
35
36
  get_tool_permission: Callable[..., Dict[str, Any]],
36
37
  hooks: Any = None,
38
+ llm_generate: Optional[Callable[..., str]] = None,
39
+ llm_available: Optional[Callable[[], bool]] = None,
40
+ agent_registry: Any = None,
37
41
  ):
38
42
  self.store = store
39
43
  self.svc = workspace_service
@@ -45,6 +49,12 @@ class PlatformRuntime:
45
49
  # Lifecycle hooks registry — wires the workflow runtime + workflow tool
46
50
  # nodes into the same pre_*/post_* lifecycle as the HTTP + agent paths.
47
51
  self.hooks = hooks
52
+ # v4 (T7b): a synchronous model bridge. When a model is loaded,
53
+ # build_orchestrator returns the REAL (mode='llm') runner; otherwise
54
+ # the deterministic runner, honestly labeled mode='simulation'.
55
+ self.llm_generate = llm_generate
56
+ self.llm_available = llm_available or (lambda: False)
57
+ self.agent_registry = agent_registry
48
58
 
49
59
  # ── request gating ────────────────────────────────────────────────────
50
60
 
@@ -77,31 +87,57 @@ class PlatformRuntime:
77
87
  # ── shared node runners ───────────────────────────────────────────────
78
88
 
79
89
  def _tool_node_runner(self):
80
- """Workflow tool node: records the invocation + governance decision but
81
- never silently executes exec/destructive tools (those need approval)."""
90
+ """Workflow tool node: EXECUTES the tool under governance (v4).
91
+
92
+ Auto-approve tools run immediately through the shared dispatch_tool
93
+ lifecycle. Tools whose policy requires approval raise
94
+ :class:`ApprovalRequired` so the engine pauses the run into
95
+ ``awaiting_approval`` — never a silent ``{recorded: true}`` success,
96
+ never an unapproved execution. A resumed run carries the approved
97
+ node id in ``context['__approved_nodes__']``.
98
+ """
82
99
  def runner(*, node, context):
83
100
  cfg = node.get("config") or {}
84
101
  name = cfg.get("tool") or ""
85
102
  args = cfg.get("args") or {}
86
-
87
- def _record():
88
- try:
89
- permission = dict(self.get_tool_permission(name))
90
- except Exception:
91
- permission = {"tool": name, "risk": "unknown"}
92
- return {"tool": name, "args": args, "recorded": True, "permission": permission}
103
+ if not name:
104
+ raise ValueError("tool node has no tool configured")
105
+ try:
106
+ permission = dict(self.get_tool_permission(name, args))
107
+ except TypeError:
108
+ permission = dict(self.get_tool_permission(name))
109
+ approved_nodes = set(context.get("__approved_nodes__") or [])
110
+ if permission.get("requires_approval") and node.get("id") not in approved_nodes:
111
+ raise ApprovalRequired(
112
+ f"tool '{name}' requires explicit approval before a workflow may run it",
113
+ tool=name, args=args, permission=permission,
114
+ )
115
+
116
+ def _execute():
117
+ return execute_tool(name, args)
93
118
 
94
119
  # Same tool lifecycle as the HTTP + agent paths (a pre_tool block
95
120
  # raises PermissionError, surfaced as the node error by the engine).
96
- return dispatch_tool(self.hooks, name or "tool", args, _record, source="workflow")
121
+ result = dispatch_tool(self.hooks, name, args, _execute, source="workflow")
122
+ return {"tool": name, "args": args, "executed": True,
123
+ "permission": permission, "result": result}
97
124
  return runner
98
125
 
99
126
  def _skill_node_runner(self):
127
+ """Skill nodes refuse honestly: a skill is an instruction package for
128
+ an LLM; without a model-driven executor there is nothing to run, and
129
+ pretending otherwise (the pre-v4 existence check that reported 'ok')
130
+ is exactly the fake functionality v4 bans."""
100
131
  def runner(*, node, context):
101
132
  cfg = node.get("config") or {}
102
133
  name = cfg.get("skill") or ""
103
134
  entry = self.store.load_state().get("skill_registry", {}).get(name) or {}
104
- return {"skill": name, "found": bool(entry), "enabled": bool(entry.get("enabled"))}
135
+ if not entry:
136
+ raise ValueError(f"skill '{name}' is not installed")
137
+ raise RuntimeError(
138
+ f"skill '{name}' requires LLM-driven execution, which workflow "
139
+ "skill nodes do not provide in this build — refusing to fake a result"
140
+ )
105
141
  return runner
106
142
 
107
143
  def _context_provider(self, user, scope):
@@ -116,15 +152,25 @@ class PlatformRuntime:
116
152
  def plugin_capability_runners(self, user, scope) -> Dict[str, Callable[..., Any]]:
117
153
  """Runners the Plugin SDK boundary dispatches to (one per capability)."""
118
154
  def run_skill(*, plugin_id, action, args, manifest):
119
- return {"plugin": plugin_id, "ran_skills": manifest.provides.get("skills", [])}
155
+ raise RuntimeError(
156
+ f"plugin '{plugin_id}' skill execution requires an LLM-driven "
157
+ "runner, which this build does not provide — refusing to fake a result"
158
+ )
120
159
 
121
160
  def run_tool(*, plugin_id, action, args, manifest):
122
161
  tool = args.get("tool") or (manifest.provides.get("tools") or [None])[0]
123
- try:
124
- permission = dict(self.get_tool_permission(tool)) if tool else {}
125
- except Exception:
126
- permission = {}
127
- return {"plugin": plugin_id, "tool": tool, "permission": permission, "recorded": True}
162
+ if not tool:
163
+ raise ValueError(f"plugin '{plugin_id}' run_tool needs a tool name")
164
+ permission = dict(self.get_tool_permission(tool))
165
+ if permission.get("requires_approval"):
166
+ raise ApprovalRequired(
167
+ f"plugin tool '{tool}' requires explicit approval",
168
+ tool=tool, args=args, permission=permission,
169
+ )
170
+ result = dispatch_tool(self.hooks, tool, args, lambda: execute_tool(tool, args),
171
+ source=f"plugin:{plugin_id}")
172
+ return {"plugin": plugin_id, "tool": tool, "permission": permission,
173
+ "executed": True, "result": result}
128
174
 
129
175
  def run_workflow(*, plugin_id, action, args, manifest):
130
176
  wf_id = args.get("workflow_id")
@@ -175,6 +221,9 @@ class PlatformRuntime:
175
221
  workflow_id=workflow_id, name=workflow.get("name") or "workflow",
176
222
  status=result.status, timeline=result.timeline, outputs=result.outputs,
177
223
  user_email=user, graph=self.workspace_graph(), workspace_id=scope,
224
+ mode="live",
225
+ pause={"node": result.paused_node, "pending": result.pending_approval,
226
+ "context": result.paused_context} if result.status == "awaiting_approval" else None,
178
227
  )
179
228
  return {"workflow_run_id": run["id"], "status": result.status}
180
229
 
@@ -209,8 +258,36 @@ class PlatformRuntime:
209
258
  }
210
259
 
211
260
  def build_orchestrator(self, user, scope) -> MultiAgentOrchestrator:
261
+ workflow_runner = lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs) # noqa: E731
262
+ plugin_runner = lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict() # noqa: E731
263
+ context_provider = self._context_provider(user, scope)
264
+ custom_agents = {}
265
+ if self.agent_registry is not None:
266
+ try:
267
+ custom_agents = {
268
+ a["id"]: a for a in self.agent_registry.all()
269
+ if str(a.get("id", "")).startswith("agent:custom:") and a.get("enabled", True)
270
+ }
271
+ except Exception:
272
+ custom_agents = {}
273
+ if self.llm_generate is not None and self.llm_available():
274
+ from latticeai.core.agent_prompts import CRITIC_PROMPT, PLANNER_PROMPT
275
+
276
+ return MultiAgentOrchestrator(
277
+ role_runner=llm_role_runner(
278
+ generate=self.llm_generate,
279
+ planner_prompt=PLANNER_PROMPT,
280
+ critic_prompt=CRITIC_PROMPT,
281
+ context_provider=context_provider,
282
+ workflow_runner=workflow_runner,
283
+ plugin_runner=plugin_runner,
284
+ custom_agents=custom_agents,
285
+ ),
286
+ mode="llm",
287
+ custom_agents=custom_agents,
288
+ )
212
289
  return MultiAgentOrchestrator(role_runner=default_role_runner(
213
- workflow_runner=lambda wf_ref, ctx: self.run_workflow_by_id(wf_ref, user, scope, with_agent=False, inputs=ctx.inputs),
214
- plugin_runner=lambda pid, ctx: self.registry.execute_action(pid, "run_skill", {}, runners=self.plugin_capability_runners(user, scope), workspace_id=scope).as_dict(),
215
- context_provider=self._context_provider(user, scope),
216
- ))
290
+ workflow_runner=workflow_runner,
291
+ plugin_runner=plugin_runner,
292
+ context_provider=context_provider,
293
+ ), mode="simulation", custom_agents=custom_agents)