ltcai 2.2.7 → 3.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 (122) hide show
  1. package/README.md +72 -34
  2. package/docs/CHANGELOG.md +119 -0
  3. package/docs/V3_BACKEND_ARCHITECTURE.md +138 -0
  4. package/docs/V3_FRONTEND.md +139 -0
  5. package/knowledge_graph.py +649 -21
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/admin.py +47 -0
  8. package/latticeai/api/agents.py +54 -31
  9. package/latticeai/api/auth.py +5 -2
  10. package/latticeai/api/chat.py +10 -2
  11. package/latticeai/api/search.py +240 -0
  12. package/latticeai/api/static_routes.py +11 -2
  13. package/latticeai/core/config.py +18 -0
  14. package/latticeai/core/embedding_providers.py +625 -0
  15. package/latticeai/core/local_embeddings.py +86 -0
  16. package/latticeai/core/workspace_os.py +1 -1
  17. package/latticeai/server_app.py +65 -1
  18. package/latticeai/services/agent_runtime.py +245 -0
  19. package/latticeai/services/search_service.py +346 -0
  20. package/package.json +13 -6
  21. package/scripts/build_v3_assets.mjs +164 -0
  22. package/scripts/capture/README.md +28 -0
  23. package/scripts/capture/capture_enterprise.js +8 -0
  24. package/scripts/capture/capture_graph.js +8 -0
  25. package/scripts/capture/capture_onboarding.js +8 -0
  26. package/scripts/capture/capture_page.js +43 -0
  27. package/scripts/capture/capture_release_media.js +125 -0
  28. package/scripts/capture/capture_skills.js +8 -0
  29. package/scripts/capture/capture_workspace.js +8 -0
  30. package/scripts/generate_diagrams.py +513 -0
  31. package/scripts/lint_v3.mjs +33 -0
  32. package/scripts/release-0.3.1.sh +105 -0
  33. package/scripts/take_screenshots.js +69 -0
  34. package/scripts/validate_release_artifacts.py +167 -0
  35. package/static/account.html +9 -9
  36. package/static/activity.html +4 -4
  37. package/static/admin.html +8 -8
  38. package/static/agents.html +4 -4
  39. package/static/chat.html +10 -10
  40. package/static/css/reference/account.css +137 -1
  41. package/static/css/reference/chat.css +31 -37
  42. package/static/css/responsive.css +42 -0
  43. package/static/css/tokens.5a595671.css +260 -0
  44. package/static/css/tokens.css +125 -130
  45. package/static/graph.html +9 -9
  46. package/static/manifest.json +3 -3
  47. package/static/plugins.html +4 -4
  48. package/static/scripts/account.js +4 -4
  49. package/static/scripts/chat.js +40 -8
  50. package/static/scripts/workspace.js +78 -0
  51. package/static/sw.js +3 -1
  52. package/static/v3/asset-manifest.json +47 -0
  53. package/static/v3/css/lattice.base.css +128 -0
  54. package/static/v3/css/lattice.base.e4cdd05d.css +128 -0
  55. package/static/v3/css/lattice.components.011e988b.css +447 -0
  56. package/static/v3/css/lattice.components.css +447 -0
  57. package/static/v3/css/lattice.shell.4920f42d.css +407 -0
  58. package/static/v3/css/lattice.shell.css +407 -0
  59. package/static/v3/css/lattice.tokens.c597ff81.css +132 -0
  60. package/static/v3/css/lattice.tokens.css +132 -0
  61. package/static/v3/css/lattice.views.3ee19d4e.css +277 -0
  62. package/static/v3/css/lattice.views.css +277 -0
  63. package/static/v3/index.html +69 -0
  64. package/static/v3/js/app.46fb61d9.js +26 -0
  65. package/static/v3/js/app.js +26 -0
  66. package/static/v3/js/core/api.22a41d42.js +344 -0
  67. package/static/v3/js/core/api.js +344 -0
  68. package/static/v3/js/core/components.4c83e0a9.js +222 -0
  69. package/static/v3/js/core/components.js +222 -0
  70. package/static/v3/js/core/dom.a2773eb0.js +148 -0
  71. package/static/v3/js/core/dom.js +148 -0
  72. package/static/v3/js/core/router.584570f2.js +37 -0
  73. package/static/v3/js/core/router.js +37 -0
  74. package/static/v3/js/core/routes.f935dd50.js +78 -0
  75. package/static/v3/js/core/routes.js +78 -0
  76. package/static/v3/js/core/shell.1b6199d6.js +363 -0
  77. package/static/v3/js/core/shell.js +363 -0
  78. package/static/v3/js/core/store.34ebd5e6.js +113 -0
  79. package/static/v3/js/core/store.js +113 -0
  80. package/static/v3/js/views/admin-audit.660a1fb1.js +185 -0
  81. package/static/v3/js/views/admin-audit.js +185 -0
  82. package/static/v3/js/views/admin-permissions.a7ae5f09.js +177 -0
  83. package/static/v3/js/views/admin-permissions.js +177 -0
  84. package/static/v3/js/views/admin-policies.3658fd86.js +102 -0
  85. package/static/v3/js/views/admin-policies.js +102 -0
  86. package/static/v3/js/views/admin-private-vpc.7d342d36.js +135 -0
  87. package/static/v3/js/views/admin-private-vpc.js +135 -0
  88. package/static/v3/js/views/admin-security.07c66b72.js +180 -0
  89. package/static/v3/js/views/admin-security.js +180 -0
  90. package/static/v3/js/views/admin-users.03bac88c.js +168 -0
  91. package/static/v3/js/views/admin-users.js +168 -0
  92. package/static/v3/js/views/agents.14e48bdd.js +193 -0
  93. package/static/v3/js/views/agents.js +193 -0
  94. package/static/v3/js/views/chat.718144ce.js +449 -0
  95. package/static/v3/js/views/chat.js +449 -0
  96. package/static/v3/js/views/files.4935197e.js +186 -0
  97. package/static/v3/js/views/files.js +186 -0
  98. package/static/v3/js/views/home.cdde3b32.js +119 -0
  99. package/static/v3/js/views/home.js +119 -0
  100. package/static/v3/js/views/hybrid-search.b22b97e0.js +195 -0
  101. package/static/v3/js/views/hybrid-search.js +195 -0
  102. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +237 -0
  103. package/static/v3/js/views/knowledge-graph.js +237 -0
  104. package/static/v3/js/views/models.a1ffa147.js +256 -0
  105. package/static/v3/js/views/models.js +256 -0
  106. package/static/v3/js/views/my-computer.1b2ff621.js +237 -0
  107. package/static/v3/js/views/my-computer.js +237 -0
  108. package/static/v3/js/views/pipeline.c522f1ce.js +157 -0
  109. package/static/v3/js/views/pipeline.js +157 -0
  110. package/static/v3/js/views/settings.4f777210.js +250 -0
  111. package/static/v3/js/views/settings.js +250 -0
  112. package/static/workflows.html +4 -4
  113. package/static/workspace.css +340 -2
  114. package/static/workspace.html +43 -24
  115. package/docs/images/tmp_frames/frame_00.png +0 -0
  116. package/docs/images/tmp_frames/frame_01.png +0 -0
  117. package/docs/images/tmp_frames/frame_02.png +0 -0
  118. package/docs/images/tmp_frames/frame_03.png +0 -0
  119. package/docs/images/tmp_frames/hero_00.png +0 -0
  120. package/docs/images/tmp_frames/hero_01.png +0 -0
  121. package/docs/images/tmp_frames/hero_02.png +0 -0
  122. package/docs/images/tmp_frames/hero_03.png +0 -0
@@ -0,0 +1,346 @@
1
+ """Backend search orchestration for Lattice AI v3.
2
+
3
+ The service composes the existing knowledge graph, the local vector index, and
4
+ keyword search into UI-ready contracts without tying routers to store internals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict, List, Mapping, Optional
11
+
12
+
13
+ DEFAULT_HYBRID_WEIGHTS = {
14
+ "keyword": 0.35,
15
+ "vector": 0.40,
16
+ "graph": 0.25,
17
+ }
18
+
19
+
20
+ def _clean(text: Any, limit: int = 1000) -> str:
21
+ return " ".join(str(text or "").split())[:limit]
22
+
23
+
24
+ def _result_key(result: Mapping[str, Any]) -> str:
25
+ return str(result.get("id") or result.get("node_id") or "")
26
+
27
+
28
+ @dataclass
29
+ class SearchService:
30
+ graph_store: Any
31
+
32
+ def _require_graph(self) -> Any:
33
+ if self.graph_store is None:
34
+ raise ValueError("knowledge graph is disabled")
35
+ return self.graph_store
36
+
37
+ def keyword_search(self, query: str, *, limit: int = 30) -> Dict[str, Any]:
38
+ graph = self._require_graph()
39
+ payload = graph.search(query, limit)
40
+ matches = []
41
+ for rank, match in enumerate(payload.get("matches", []), start=1):
42
+ matches.append({
43
+ "id": match["id"],
44
+ "node_id": match["id"],
45
+ "item_type": "node",
46
+ "type": match.get("type"),
47
+ "title": match.get("title"),
48
+ "summary": _clean(match.get("summary")),
49
+ "score": round(1.0 / rank, 6),
50
+ "rank": rank,
51
+ "sources": ["keyword"],
52
+ "source_scores": {"keyword": round(1.0 / rank, 6)},
53
+ "metadata": match.get("metadata") or {},
54
+ "updated_at": match.get("updated_at"),
55
+ })
56
+ return {"query": query, "mode": "keyword", "matches": matches}
57
+
58
+ def vector_search(self, query: str, *, limit: int = 30, min_score: float = 0.0) -> Dict[str, Any]:
59
+ graph = self._require_graph()
60
+ payload = graph.vector_search(query, limit=limit, min_score=min_score)
61
+ matches = []
62
+ for rank, match in enumerate(payload.get("matches", []), start=1):
63
+ score = float(match.get("score") or 0.0)
64
+ matches.append({
65
+ "id": match.get("id"),
66
+ "node_id": match.get("node_id"),
67
+ "item_type": match.get("item_type"),
68
+ "type": match.get("type"),
69
+ "title": match.get("title"),
70
+ "summary": _clean(match.get("summary")),
71
+ "score": round(score, 6),
72
+ "rank": rank,
73
+ "sources": ["vector"],
74
+ "source_scores": {"vector": round(score, 6)},
75
+ "metadata": match.get("metadata") or {},
76
+ "updated_at": match.get("updated_at"),
77
+ })
78
+ return {
79
+ "query": query,
80
+ "mode": "vector",
81
+ "embedding_model": payload.get("embedding_model"),
82
+ "embedding_dim": payload.get("embedding_dim"),
83
+ "matches": matches,
84
+ }
85
+
86
+ def graph_search(self, query: str, *, limit: int = 30, expand_depth: int = 1) -> Dict[str, Any]:
87
+ graph = self._require_graph()
88
+ limit = max(1, min(int(limit or 30), 100))
89
+ expand_depth = max(0, min(int(expand_depth or 1), 3))
90
+ direct = graph.search(query, limit=max(limit, 10)).get("matches", [])
91
+ relationships = graph.relationship_search(query=query, limit=limit).get("relationships", [])
92
+ by_id: Dict[str, Dict[str, Any]] = {}
93
+
94
+ def add_node(node: Mapping[str, Any], score: float, reason: str, edge: Optional[Mapping[str, Any]] = None) -> None:
95
+ node_id = str(node.get("id") or "")
96
+ if not node_id:
97
+ return
98
+ current = by_id.get(node_id)
99
+ if not current:
100
+ current = {
101
+ "id": node_id,
102
+ "node_id": node_id,
103
+ "item_type": "node",
104
+ "type": node.get("type"),
105
+ "title": node.get("title"),
106
+ "summary": _clean(node.get("summary")),
107
+ "score": 0.0,
108
+ "sources": ["graph"],
109
+ "source_scores": {"graph": 0.0},
110
+ "metadata": node.get("metadata") or {},
111
+ "updated_at": node.get("updated_at"),
112
+ "graph_context": [],
113
+ }
114
+ by_id[node_id] = current
115
+ current["score"] = max(float(current["score"]), score)
116
+ current["source_scores"]["graph"] = max(float(current["source_scores"]["graph"]), score)
117
+ context = {"reason": reason}
118
+ if edge:
119
+ context["relationship"] = {
120
+ "id": edge.get("id"),
121
+ "type": edge.get("type"),
122
+ "weight": edge.get("weight"),
123
+ "from": edge.get("from") or (edge.get("source") or {}).get("id"),
124
+ "to": edge.get("to") or (edge.get("target") or {}).get("id"),
125
+ }
126
+ current["graph_context"].append(context)
127
+
128
+ for rank, match in enumerate(direct, start=1):
129
+ add_node(match, 1.0 / rank, "direct_match")
130
+ if expand_depth <= 0:
131
+ continue
132
+ try:
133
+ neighborhood = graph.traverse(match["id"], depth=expand_depth, limit=limit * 3)
134
+ except Exception:
135
+ neighborhood = {"nodes": [], "edges": []}
136
+ edge_by_pair = {
137
+ (edge.get("from"), edge.get("to")): edge
138
+ for edge in neighborhood.get("edges", [])
139
+ }
140
+ for node in neighborhood.get("nodes", []):
141
+ if node.get("id") == match.get("id"):
142
+ continue
143
+ related_edge = None
144
+ for pair, edge in edge_by_pair.items():
145
+ if match.get("id") in pair and node.get("id") in pair:
146
+ related_edge = edge
147
+ break
148
+ add_node(node, 0.45 / rank, "neighbor_expansion", related_edge)
149
+
150
+ for rank, rel in enumerate(relationships, start=1):
151
+ rel_score = 0.75 / rank
152
+ add_node(rel.get("source") or {}, rel_score, "relationship_match", rel)
153
+ add_node(rel.get("target") or {}, rel_score, "relationship_match", rel)
154
+
155
+ matches = sorted(by_id.values(), key=lambda item: item["score"], reverse=True)[:limit]
156
+ for rank, match in enumerate(matches, start=1):
157
+ match["rank"] = rank
158
+ match["score"] = round(float(match["score"]), 6)
159
+ match["source_scores"]["graph"] = round(float(match["source_scores"]["graph"]), 6)
160
+ return {"query": query, "mode": "graph", "expand_depth": expand_depth, "matches": matches}
161
+
162
+ def hybrid_search(
163
+ self,
164
+ query: str,
165
+ *,
166
+ limit: int = 30,
167
+ keyword_limit: int = 30,
168
+ vector_limit: int = 30,
169
+ graph_limit: int = 30,
170
+ weights: Optional[Mapping[str, float]] = None,
171
+ ) -> Dict[str, Any]:
172
+ weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights or {})}
173
+ channels = {
174
+ "keyword": self.keyword_search(query, limit=keyword_limit),
175
+ "vector": self.vector_search(query, limit=vector_limit),
176
+ "graph": self.graph_search(query, limit=graph_limit),
177
+ }
178
+ fused: Dict[str, Dict[str, Any]] = {}
179
+ for source, payload in channels.items():
180
+ source_weight = float(weights.get(source, 0.0))
181
+ for rank, result in enumerate(payload.get("matches", []), start=1):
182
+ key = _result_key(result)
183
+ if not key:
184
+ continue
185
+ source_score = float((result.get("source_scores") or {}).get(source, result.get("score") or 0.0))
186
+ rank_score = 1.0 / rank
187
+ contribution = source_weight * max(source_score, rank_score)
188
+ current = fused.get(key)
189
+ if not current:
190
+ current = {
191
+ **result,
192
+ "sources": [],
193
+ "source_scores": {},
194
+ "score": 0.0,
195
+ }
196
+ fused[key] = current
197
+ current["score"] = float(current["score"]) + contribution
198
+ if source not in current["sources"]:
199
+ current["sources"].append(source)
200
+ current["source_scores"][source] = round(source_score, 6)
201
+ if result.get("graph_context"):
202
+ current.setdefault("graph_context", [])
203
+ current["graph_context"].extend(result.get("graph_context") or [])
204
+
205
+ matches = sorted(fused.values(), key=lambda item: item["score"], reverse=True)[: max(1, min(limit, 100))]
206
+ for rank, match in enumerate(matches, start=1):
207
+ match["rank"] = rank
208
+ match["score"] = round(float(match["score"]), 6)
209
+ match["fusion"] = {
210
+ "weights": weights,
211
+ "sources": match.get("sources", []),
212
+ }
213
+ return {
214
+ "query": query,
215
+ "mode": "hybrid",
216
+ "weights": weights,
217
+ "channels": {
218
+ name: {
219
+ key: value
220
+ for key, value in payload.items()
221
+ if key not in {"matches"}
222
+ }
223
+ for name, payload in channels.items()
224
+ },
225
+ "matches": matches,
226
+ }
227
+
228
+ def graph(self, *, limit: int = 300) -> Dict[str, Any]:
229
+ return self._require_graph().graph(limit=limit)
230
+
231
+ def node(self, node_id: str, *, include_neighbors: bool = True, depth: int = 1, limit: int = 100) -> Dict[str, Any]:
232
+ graph = self._require_graph()
233
+ payload = {"node": graph.get_node(node_id)}
234
+ if include_neighbors:
235
+ payload["neighborhood"] = graph.traverse(node_id, depth=depth, limit=limit)
236
+ return payload
237
+
238
+ def relationships(
239
+ self,
240
+ *,
241
+ query: str = "",
242
+ node_id: str = "",
243
+ relationship_type: str = "",
244
+ limit: int = 30,
245
+ ) -> Dict[str, Any]:
246
+ return self._require_graph().relationship_search(
247
+ query=query,
248
+ node_id=node_id,
249
+ relationship_type=relationship_type,
250
+ limit=limit,
251
+ )
252
+
253
+ def index_status(self) -> Dict[str, Any]:
254
+ return self._require_graph().index_status()
255
+
256
+ def embeddings_status(
257
+ self,
258
+ *,
259
+ resolved: Optional[Mapping[str, Any]] = None,
260
+ refresh: bool = False,
261
+ ) -> Dict[str, Any]:
262
+ """Report the active embedding provider for the Models → Embeddings UI.
263
+
264
+ Combines the resolved-provider info (requested vs active, fallback,
265
+ health) with the vector index's identity and last build time. The
266
+ ``state`` is one of ``production`` | ``fallback`` | ``unavailable`` so
267
+ the UI never shows a down provider as live.
268
+ """
269
+ resolved = dict(resolved or {})
270
+ graph = self.graph_store
271
+ embedder = getattr(graph, "_embedding_model", None)
272
+
273
+ meta: Dict[str, Any] = {}
274
+ if embedder is not None and hasattr(embedder, "metadata"):
275
+ try:
276
+ meta = dict(embedder.metadata())
277
+ except Exception:
278
+ meta = {}
279
+ else: # legacy LocalEmbeddingModel
280
+ meta = {
281
+ "provider": "hash",
282
+ "model": getattr(embedder, "model_id", "lattice-local-hash-v1"),
283
+ "model_id": getattr(embedder, "model_id", "lattice-local-hash-v1"),
284
+ "dim": getattr(embedder, "dim", 384),
285
+ "grade": "fallback",
286
+ }
287
+
288
+ health = resolved.get("health") or {"status": "unknown", "detail": ""}
289
+ if refresh and embedder is not None and hasattr(embedder, "health"):
290
+ try:
291
+ health = embedder.health()
292
+ except Exception as exc: # pragma: no cover - defensive
293
+ health = {"status": "unavailable", "detail": str(exc)}
294
+
295
+ fell_back = bool(resolved.get("fell_back"))
296
+ grade = str(meta.get("grade") or ("fallback" if fell_back else "production"))
297
+ if fell_back or health.get("status") == "unavailable":
298
+ state = "unavailable" if fell_back else "fallback"
299
+ else:
300
+ state = "fallback" if grade == "fallback" else "production"
301
+
302
+ index: Dict[str, Any] = {}
303
+ last_indexed_at = None
304
+ if graph is not None:
305
+ try:
306
+ status = graph.index_status()
307
+ index = {
308
+ "status": status.get("status"),
309
+ "source_items": status.get("source_items"),
310
+ "indexed_items": status.get("indexed_items"),
311
+ "ready_items": status.get("ready_items"),
312
+ "pending_items": status.get("pending_items"),
313
+ "stale_items": status.get("stale_items"),
314
+ "embedding_model": (status.get("storage") or {}).get("embedding_model"),
315
+ "embedding_dim": (status.get("storage") or {}).get("embedding_dim"),
316
+ }
317
+ for op in status.get("operations", []):
318
+ if op.get("status") == "completed" and op.get("completed_at"):
319
+ last_indexed_at = op.get("completed_at")
320
+ break
321
+ except Exception as exc: # pragma: no cover - defensive
322
+ index = {"error": str(exc)}
323
+
324
+ return {
325
+ "provider": meta.get("provider"),
326
+ "requested_provider": resolved.get("requested_provider") or meta.get("provider"),
327
+ "active_provider": resolved.get("active_provider") or meta.get("provider"),
328
+ "model": meta.get("model"),
329
+ "model_id": meta.get("model_id"),
330
+ "dimensions": meta.get("dim"),
331
+ "grade": grade,
332
+ "state": state,
333
+ "fell_back": fell_back,
334
+ "health": health,
335
+ "detail": resolved.get("detail", ""),
336
+ "last_indexed_at": last_indexed_at,
337
+ "index": index,
338
+ "available_providers": list(resolved.get("available_providers") or []),
339
+ }
340
+
341
+ def rebuild_index(self, *, full: bool = False, include_nodes: bool = True, include_chunks: bool = True) -> Dict[str, Any]:
342
+ return self._require_graph().rebuild_vector_index(
343
+ full=full,
344
+ include_nodes=include_nodes,
345
+ include_chunks=include_chunks,
346
+ )
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "2.2.7",
4
- "description": "Local-first AI workspace for knowledge graphs, AI pipelines, and multi-agent coding workflows.",
3
+ "version": "3.1.0",
4
+ "description": "Lattice AI v3 local-first AI workspace platform with knowledge graph, vector index, hybrid search, agents, and workspace modes.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
7
7
  "type": "git",
@@ -17,10 +17,12 @@
17
17
  "scripts": {
18
18
  "start": "LTCAI",
19
19
  "dev": "python3 ltcai_cli.py --reload",
20
- "build": "npm run build:python",
20
+ "build": "npm run build:assets && npm run build:python",
21
+ "build:assets": "node scripts/build_v3_assets.mjs",
21
22
  "build:python": "python3 -m build",
22
- "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
23
- "lint": "node --check static/scripts/account.js && node --check static/scripts/admin.js && node --check static/scripts/chat.js && node --check static/scripts/graph.js && node --check static/scripts/platform.js && node --check static/scripts/ux.js && node --check static/scripts/workspace.js && node --check tests/visual/mock_server.cjs",
23
+ "check:python": "python3 -m py_compile ltcai_cli.py server.py latticeai/server_app.py latticeai/api/auth.py latticeai/api/chat.py latticeai/api/computer_use.py latticeai/api/deps.py latticeai/api/garden.py latticeai/api/local_files.py latticeai/api/permissions.py latticeai/api/setup.py latticeai/api/static_routes.py latticeai/api/tools.py latticeai/api/plugins.py latticeai/api/workflow_designer.py latticeai/api/agents.py latticeai/api/realtime.py latticeai/api/marketplace.py latticeai/api/search.py latticeai/services/search_service.py latticeai/core/local_embeddings.py latticeai/core/embedding_providers.py latticeai/services/agent_runtime.py latticeai/core/config.py latticeai/api/admin.py latticeai/services/app_context.py latticeai/services/model_runtime.py latticeai/services/model_catalog.py latticeai/services/model_recommendation.py latticeai/services/tool_dispatch.py latticeai/services/upload_service.py latticeai/core/tool_registry.py latticeai/core/enterprise.py latticeai/core/enterprise_admin.py latticeai/core/agent_prompts.py latticeai/core/workspace_os.py latticeai/core/plugins.py latticeai/core/marketplace.py latticeai/core/workflow_engine.py latticeai/core/multi_agent.py latticeai/core/realtime.py knowledge_graph.py knowledge_graph_api.py local_knowledge_api.py llm_router.py p_reinforce.py telegram_bot.py tools.py codex_telegram_bot.py",
24
+ "lint": "node --check static/scripts/account.js && node --check static/scripts/admin.js && node --check static/scripts/chat.js && node --check static/scripts/graph.js && node --check static/scripts/platform.js && node --check static/scripts/ux.js && node --check static/scripts/workspace.js && node --check tests/visual/mock_server.cjs && node --check tests/visual/v3.spec.js && npm run lint:v3",
25
+ "lint:v3": "node scripts/lint_v3.mjs",
24
26
  "typecheck": "cd vscode-extension && npm run build",
25
27
  "test": "python3 -m pytest tests/ -v",
26
28
  "test:unit": "python3 -m pytest tests/unit/ -v",
@@ -31,7 +33,7 @@
31
33
  "capture:skills": "node scripts/capture/capture_skills.js",
32
34
  "capture:enterprise": "node scripts/capture/capture_enterprise.js",
33
35
  "capture:onboarding": "node scripts/capture/capture_onboarding.js",
34
- "release:artifacts": "npm run build:python && npm pack && cd vscode-extension && npm run package:vsix",
36
+ "release:artifacts": "npm run build:assets && npm run build:python && npm pack && cd vscode-extension && npm run package:vsix",
35
37
  "release:validate": "python3 scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz",
36
38
  "publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
37
39
  "publish:pypi": "npm run build:python && python3 -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
@@ -89,9 +91,14 @@
89
91
  "static/platform.css",
90
92
  "static/scripts/",
91
93
  "static/css/",
94
+ "static/v3/",
92
95
  "static/icons/",
93
96
  "plugins/",
97
+ "scripts/",
94
98
  "docs/",
99
+ "!docs/images/tmp_frames/",
100
+ "!**/__pycache__/",
101
+ "!**/*.pyc",
95
102
  "requirements.txt",
96
103
  "README.md"
97
104
  ],
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Build the v3 browser asset manifest.
4
+ *
5
+ * The source files stay importable in development. This script writes hashed
6
+ * siblings next to each runtime asset, rewrites ES-module imports to those
7
+ * hashed siblings, and emits static/v3/asset-manifest.json for /app.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ statSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { basename, dirname, extname, join, relative } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
23
+ const staticRoot = join(repoRoot, "static");
24
+ const manifestPath = join(staticRoot, "v3", "asset-manifest.json");
25
+
26
+ const cssSources = [
27
+ "static/css/tokens.css",
28
+ "static/v3/css/lattice.tokens.css",
29
+ "static/v3/css/lattice.base.css",
30
+ "static/v3/css/lattice.components.css",
31
+ "static/v3/css/lattice.shell.css",
32
+ "static/v3/css/lattice.views.css",
33
+ ];
34
+
35
+ const moduleRoot = join(staticRoot, "v3", "js");
36
+ const entry = "static/v3/js/app.js";
37
+
38
+ function posix(p) {
39
+ return p.replaceAll("\\", "/");
40
+ }
41
+
42
+ function sha(text) {
43
+ return createHash("sha256").update(text).digest("hex").slice(0, 8);
44
+ }
45
+
46
+ function repoPath(abs) {
47
+ return posix(relative(repoRoot, abs));
48
+ }
49
+
50
+ function publicUrl(repoRel) {
51
+ return "/" + posix(repoRel);
52
+ }
53
+
54
+ function walk(dir) {
55
+ const out = [];
56
+ for (const name of readdirSync(dir)) {
57
+ const p = join(dir, name);
58
+ const st = statSync(p);
59
+ if (st.isDirectory()) out.push(...walk(p));
60
+ else if (name.endsWith(".js")) out.push(p);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function removeGenerated(dir, ext) {
66
+ if (!existsSync(dir)) return;
67
+ for (const name of readdirSync(dir)) {
68
+ const p = join(dir, name);
69
+ const st = statSync(p);
70
+ if (st.isDirectory()) removeGenerated(p, ext);
71
+ else if (new RegExp(`\\.[0-9a-f]{8}\\${ext}$`).test(name)) rmSync(p);
72
+ }
73
+ }
74
+
75
+ removeGenerated(join(staticRoot, "css"), ".css");
76
+ removeGenerated(join(staticRoot, "v3", "css"), ".css");
77
+ removeGenerated(moduleRoot, ".js");
78
+
79
+ const modules = new Map();
80
+ for (const abs of walk(moduleRoot)) {
81
+ modules.set(repoPath(abs), {
82
+ abs,
83
+ rel: repoPath(abs),
84
+ source: readFileSync(abs, "utf8"),
85
+ deps: [],
86
+ });
87
+ }
88
+
89
+ const importFromRe = /\b(?:import|export)\s+(?:[^"'()]*?\s+from\s*)?["']([^"']+\.js)["']/g;
90
+ for (const mod of modules.values()) {
91
+ const deps = [];
92
+ let match;
93
+ while ((match = importFromRe.exec(mod.source))) {
94
+ const spec = match[1];
95
+ if (!spec.startsWith(".")) continue;
96
+ const depRel = repoPath(join(dirname(mod.abs), spec));
97
+ if (modules.has(depRel)) deps.push(depRel);
98
+ }
99
+ mod.deps = deps;
100
+ }
101
+
102
+ const hashMemo = new Map();
103
+ function moduleHash(rel, stack = []) {
104
+ if (hashMemo.has(rel)) return hashMemo.get(rel);
105
+ if (stack.includes(rel)) return sha(modules.get(rel).source);
106
+ const mod = modules.get(rel);
107
+ const depHashes = mod.deps
108
+ .sort()
109
+ .map((dep) => `${dep}:${moduleHash(dep, [...stack, rel])}`)
110
+ .join("\n");
111
+ const h = sha(`${mod.source}\n/* dependency-hashes */\n${depHashes}`);
112
+ hashMemo.set(rel, h);
113
+ return h;
114
+ }
115
+
116
+ for (const rel of modules.keys()) moduleHash(rel);
117
+
118
+ function hashedRel(rel, hash) {
119
+ const ext = extname(rel);
120
+ return posix(join(dirname(rel), `${basename(rel, ext)}.${hash}${ext}`));
121
+ }
122
+
123
+ const assets = {};
124
+
125
+ for (const sourceRel of cssSources) {
126
+ const abs = join(repoRoot, sourceRel);
127
+ const source = readFileSync(abs, "utf8");
128
+ const outRel = hashedRel(sourceRel, sha(source));
129
+ writeFileSync(join(repoRoot, outRel), source, "utf8");
130
+ assets[sourceRel] = publicUrl(outRel);
131
+ }
132
+
133
+ function rewriteModule(mod) {
134
+ return mod.source.replace(importFromRe, (full, spec) => {
135
+ if (!spec.startsWith(".")) return full;
136
+ const depRel = repoPath(join(dirname(mod.abs), spec));
137
+ const depHash = hashMemo.get(depRel);
138
+ if (!depHash) return full;
139
+ const depOutAbs = join(repoRoot, hashedRel(depRel, depHash));
140
+ const nextSpec = posix(relative(dirname(join(repoRoot, hashedRel(mod.rel, hashMemo.get(mod.rel)))), depOutAbs));
141
+ const normalized = nextSpec.startsWith(".") ? nextSpec : `./${nextSpec}`;
142
+ return full.replace(spec, normalized);
143
+ });
144
+ }
145
+
146
+ for (const mod of modules.values()) {
147
+ const outRel = hashedRel(mod.rel, hashMemo.get(mod.rel));
148
+ mkdirSync(dirname(join(repoRoot, outRel)), { recursive: true });
149
+ writeFileSync(join(repoRoot, outRel), rewriteModule(mod), "utf8");
150
+ assets[mod.rel] = publicUrl(outRel);
151
+ }
152
+
153
+ const manifest = {
154
+ version: "3.1.0",
155
+ generated_at: "deterministic",
156
+ entrypoints: {
157
+ app: assets[entry],
158
+ styles: cssSources.map((rel) => assets[rel]),
159
+ },
160
+ assets,
161
+ };
162
+
163
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
164
+ console.log(`wrote ${repoPath(manifestPath)} with ${Object.keys(assets).length} assets`);
@@ -0,0 +1,28 @@
1
+ # Screenshot Capture
2
+
3
+ Reproducible Playwright capture scripts for release screenshots.
4
+
5
+ Prerequisites:
6
+
7
+ ```bash
8
+ npm ci
9
+ npx playwright install chromium
10
+ LTCAI
11
+ ```
12
+
13
+ Optional environment:
14
+
15
+ - `LTCAI_CAPTURE_BASE_URL` defaults to `http://localhost:4825`
16
+ - `SESSION_TOKEN` or `LTCAI_SESSION_TOKEN` injects an authenticated session cookie
17
+ - `LTCAI_CAPTURE_OUT` defaults to `docs/images`
18
+ - `LTCAI_CAPTURE_HEADED=1` runs with a visible browser
19
+
20
+ Commands:
21
+
22
+ ```bash
23
+ npm run capture:workspace
24
+ npm run capture:graph
25
+ npm run capture:skills
26
+ npm run capture:enterprise
27
+ npm run capture:onboarding
28
+ ```
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ const { capturePage } = require("./capture_page");
3
+
4
+ capturePage({ path: "/admin#enterprise", waitFor: "#enterprise-capability-status", filename: "enterprise.png" })
5
+ .catch((error) => {
6
+ console.error(error);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ const { capturePage } = require("./capture_page");
3
+
4
+ capturePage({ path: "/graph", waitFor: "#graph", filename: "graph.png", settleMs: 1400 })
5
+ .catch((error) => {
6
+ console.error(error);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ const { capturePage } = require("./capture_page");
3
+
4
+ capturePage({ path: "/onboarding", waitFor: "#onboarding-steps", filename: "onboarding.png" })
5
+ .catch((error) => {
6
+ console.error(error);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,43 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+
4
+ async function loadPlaywright() {
5
+ try {
6
+ return require("@playwright/test");
7
+ } catch (_) {
8
+ return require("playwright");
9
+ }
10
+ }
11
+
12
+ async function capturePage(options) {
13
+ const { chromium } = await loadPlaywright();
14
+ const baseURL = process.env.LTCAI_CAPTURE_BASE_URL || "http://localhost:4825";
15
+ const outDir = path.resolve(process.env.LTCAI_CAPTURE_OUT || path.join(__dirname, "..", "..", "docs", "images"));
16
+ const sessionToken = process.env.SESSION_TOKEN || process.env.LTCAI_SESSION_TOKEN || "";
17
+ fs.mkdirSync(outDir, { recursive: true });
18
+
19
+ const browser = await chromium.launch({ headless: process.env.LTCAI_CAPTURE_HEADED !== "1" });
20
+ const context = await browser.newContext({
21
+ viewport: { width: Number(process.env.LTCAI_CAPTURE_WIDTH || 1440), height: Number(process.env.LTCAI_CAPTURE_HEIGHT || 920) },
22
+ deviceScaleFactor: Number(process.env.LTCAI_CAPTURE_SCALE || 2),
23
+ });
24
+ if (sessionToken) {
25
+ const host = new URL(baseURL).hostname;
26
+ await context.addCookies([{ name: "session_token", value: sessionToken, domain: host, path: "/", httpOnly: true, secure: false }]);
27
+ }
28
+
29
+ const page = await context.newPage();
30
+ const target = new URL(options.path, baseURL).toString();
31
+ await page.goto(target, { waitUntil: "networkidle", timeout: Number(process.env.LTCAI_CAPTURE_TIMEOUT || 30_000) });
32
+ if (options.hash) await page.evaluate((hash) => { location.hash = hash; }, options.hash);
33
+ if (options.waitFor) await page.waitForSelector(options.waitFor, { timeout: 15_000 });
34
+ await page.waitForTimeout(Number(process.env.LTCAI_CAPTURE_SETTLE_MS || options.settleMs || 900));
35
+
36
+ const outPath = path.join(outDir, options.filename);
37
+ await page.screenshot({ path: outPath, fullPage: Boolean(options.fullPage) });
38
+ await browser.close();
39
+ console.log(outPath);
40
+ return outPath;
41
+ }
42
+
43
+ module.exports = { capturePage };