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.
- package/README.md +11 -7
- package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
- package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
- package/docs/kg-schema.md +47 -53
- package/kg_schema.py +93 -10
- package/knowledge_graph.py +362 -33
- package/knowledge_graph_api.py +11 -127
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agents.py +7 -1
- package/latticeai/api/auth.py +27 -4
- package/latticeai/api/chat.py +112 -76
- package/latticeai/api/health.py +1 -1
- package/latticeai/api/hooks.py +1 -1
- package/latticeai/api/knowledge_graph.py +146 -0
- package/latticeai/api/local_files.py +1 -1
- package/latticeai/api/mcp.py +23 -11
- package/latticeai/api/memory.py +1 -1
- package/latticeai/api/models.py +1 -1
- package/latticeai/api/network.py +81 -0
- package/latticeai/api/realtime.py +1 -1
- package/latticeai/api/search.py +26 -2
- package/latticeai/api/security_dashboard.py +2 -3
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +2 -4
- package/latticeai/api/tools.py +3 -0
- package/latticeai/api/workflow_designer.py +46 -0
- package/latticeai/api/workspace.py +71 -49
- package/latticeai/app_factory.py +1710 -0
- package/latticeai/brain/__init__.py +18 -0
- package/latticeai/brain/context.py +213 -0
- package/latticeai/brain/conversations.py +236 -0
- package/latticeai/brain/identity.py +175 -0
- package/latticeai/brain/memory.py +102 -0
- package/latticeai/brain/network.py +205 -0
- package/latticeai/core/agent.py +31 -7
- package/latticeai/core/audit.py +0 -7
- package/latticeai/core/config.py +1 -1
- package/latticeai/core/context_builder.py +1 -2
- package/latticeai/core/enterprise.py +1 -1
- package/latticeai/core/graph_curator.py +2 -2
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/mcp_registry.py +791 -0
- package/latticeai/core/model_compat.py +1 -1
- package/latticeai/core/model_resolution.py +0 -1
- package/latticeai/core/multi_agent.py +238 -4
- package/latticeai/core/security.py +1 -1
- package/latticeai/core/sessions.py +37 -7
- package/latticeai/core/workflow_engine.py +114 -2
- package/latticeai/core/workspace_os.py +58 -10
- package/latticeai/models/__init__.py +7 -0
- package/latticeai/models/router.py +779 -0
- package/latticeai/server_app.py +29 -1536
- package/latticeai/services/agent_runtime.py +1 -0
- package/latticeai/services/app_context.py +75 -14
- package/latticeai/services/ingestion.py +47 -0
- package/latticeai/services/kg_portability.py +33 -3
- package/latticeai/services/memory_service.py +39 -11
- package/latticeai/services/model_runtime.py +2 -5
- package/latticeai/services/platform_runtime.py +100 -23
- package/latticeai/services/search_service.py +17 -8
- package/latticeai/services/tool_dispatch.py +12 -2
- package/latticeai/services/triggers.py +241 -0
- package/latticeai/services/upload_service.py +37 -12
- package/latticeai/services/workspace_service.py +31 -0
- package/llm_router.py +29 -772
- package/ltcai_cli.py +1 -2
- package/mcp_registry.py +25 -788
- package/p_reinforce.py +124 -14
- package/package.json +9 -7
- package/scripts/bump_version.py +99 -0
- package/scripts/generate_diagrams.py +0 -1
- package/scripts/lint_v3.mjs +82 -18
- package/scripts/validate_release_artifacts.py +0 -1
- package/scripts/wheel_smoke.py +142 -0
- package/server.py +11 -7
- package/setup_wizard.py +1142 -0
- package/static/account.html +2 -4
- package/static/admin.html +3 -5
- package/static/chat.html +3 -6
- package/static/graph.html +2 -4
- package/static/sw.js +81 -52
- package/static/v3/asset-manifest.json +20 -19
- package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
- package/static/v3/css/lattice.base.css +1 -1
- package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
- package/static/v3/css/lattice.components.css +1 -1
- package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
- package/static/v3/css/lattice.shell.css +1 -1
- package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
- package/static/v3/css/lattice.tokens.css +3 -0
- package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
- package/static/v3/css/lattice.views.css +2 -2
- package/static/v3/index.html +3 -4
- package/static/v3/js/{app.c541f955.js → app.356e6452.js} +1 -1
- package/static/v3/js/core/{api.33d6320e.js → api.7a308b89.js} +1 -1
- package/static/v3/js/core/{routes.2ce3815a.js → routes.7222343d.js} +22 -22
- package/static/v3/js/core/routes.js +22 -22
- package/static/v3/js/core/{shell.8c163e0e.js → shell.a1657f20.js} +4 -4
- package/static/v3/js/core/shell.js +1 -1
- package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
- package/static/v3/js/core/store.js +1 -1
- package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
- package/static/v3/js/views/graph-canvas.js +509 -0
- package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
- package/static/v3/js/views/hybrid-search.js +1 -2
- package/static/v3/js/views/{knowledge-graph.a96040a5.js → knowledge-graph.5e40cbeb.js} +33 -37
- package/static/v3/js/views/knowledge-graph.js +33 -37
- package/static/vendor/chart.umd.min.js +20 -0
- package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
- package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
- package/static/vendor/fonts/inter.css +44 -0
- package/static/vendor/icons/tabler-icons.min.css +4 -0
- package/static/vendor/icons/tabler-icons.woff2 +0 -0
- package/static/vendor/marked.min.js +69 -0
- package/static/workspace.html +2 -2
- package/telegram_bot.py +1 -2
- package/tools/commands.py +4 -2
- package/tools/computer.py +1 -1
- package/tools/documents.py +1 -3
- package/tools/filesystem.py +0 -4
- package/tools/knowledge.py +1 -3
- package/tools/network.py +1 -3
- package/codex_telegram_bot.py +0 -195
- package/docs/assets/v3.4.0/agent-run.png +0 -0
- package/docs/assets/v3.4.0/agents.png +0 -0
- package/docs/assets/v3.4.0/before/chat-before.png +0 -0
- package/docs/assets/v3.4.0/before/files-before.png +0 -0
- package/docs/assets/v3.4.0/chat.png +0 -0
- package/docs/assets/v3.4.0/connect-folder.png +0 -0
- package/docs/assets/v3.4.0/files.png +0 -0
- package/docs/assets/v3.4.0/home.png +0 -0
- package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
- package/docs/assets/v3.4.0/local-agent.png +0 -0
- package/docs/assets/v3.4.0/memory.png +0 -0
- package/docs/assets/v3.4.0/settings.png +0 -0
- package/docs/assets/v3.4.0/vision-input.png +0 -0
- package/docs/assets/v3.4.0/workflows.png +0 -0
- package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
- package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
- package/docs/assets/v3.4.1/local-agent.png +0 -0
- package/docs/images/admin-dashboard.png +0 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/enterprise.png +0 -0
- package/docs/images/graph.png +0 -0
- package/docs/images/hero.gif +0 -0
- package/docs/images/knowledge-graph.png +0 -0
- package/docs/images/lattice-ai-demo.gif +0 -0
- package/docs/images/lattice-ai-hero.png +0 -0
- package/docs/images/logo.svg +0 -33
- package/docs/images/mobile-responsive.png +0 -0
- package/docs/images/model-recommendation.png +0 -0
- package/docs/images/onboarding.png +0 -0
- package/docs/images/organization.png +0 -0
- package/docs/images/pipeline.png +0 -0
- package/docs/images/screenshot-admin.png +0 -0
- package/docs/images/screenshot-chat.png +0 -0
- package/docs/images/screenshot-graph.png +0 -0
- package/docs/images/skills.png +0 -0
- package/docs/images/workspace-dark.png +0 -0
- package/docs/images/workspace-light.png +0 -0
- package/docs/images/workspace.png +0 -0
- package/requirements.txt +0 -16
package/p_reinforce.py
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
"""
|
|
2
|
-
P-Reinforce Knowledge Gardener
|
|
3
|
-
|
|
2
|
+
P-Reinforce Knowledge Gardener — notes capture with a brain-backed memory.
|
|
3
|
+
|
|
4
|
+
v4 (T4.3 garden absorption): the markdown vault is no longer a second brain.
|
|
5
|
+
The vault stays as the user-owned, Obsidian-compatible *mirror* (capability
|
|
6
|
+
preserved), but the Knowledge Graph is authoritative: notes created through
|
|
7
|
+
the API are ingested through the unified pipeline (provenance + hooks), the
|
|
8
|
+
existing vault is imported idempotently, and chat context comes from brain
|
|
9
|
+
queries instead of an O(n) vault scan per message.
|
|
4
10
|
"""
|
|
5
11
|
|
|
6
|
-
import
|
|
12
|
+
import logging
|
|
7
13
|
import os
|
|
8
14
|
import re
|
|
9
|
-
import time
|
|
10
15
|
import shutil
|
|
11
16
|
from datetime import datetime
|
|
12
17
|
from pathlib import Path
|
|
13
|
-
from typing import Optional
|
|
18
|
+
from typing import Any, Optional
|
|
14
19
|
|
|
15
20
|
BRAIN_DIR = Path(
|
|
16
21
|
os.getenv("LATTICEAI_OBSIDIAN_VAULT_DIR")
|
|
@@ -28,7 +33,9 @@ STRUCTURE = {
|
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
class PReinforceGardener:
|
|
31
|
-
def __init__(self):
|
|
36
|
+
def __init__(self, ingestion_pipeline: Any = None, knowledge_graph: Any = None):
|
|
37
|
+
self._pipeline = ingestion_pipeline
|
|
38
|
+
self._kg = knowledge_graph
|
|
32
39
|
self._ensure_structure()
|
|
33
40
|
|
|
34
41
|
def _ensure_structure(self):
|
|
@@ -43,6 +50,7 @@ class PReinforceGardener:
|
|
|
43
50
|
lines = ["# 🧠 Lattice AI Brain — P-Reinforce Index\n"]
|
|
44
51
|
lines.append(f"*Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}*\n")
|
|
45
52
|
lines.append("\nThis folder is an Obsidian-compatible Markdown vault.\n")
|
|
53
|
+
lines.append("\nThe Knowledge Graph is the authoritative store; this vault is the\nuser-owned markdown mirror of garden notes.\n")
|
|
46
54
|
for folder, desc in STRUCTURE.items():
|
|
47
55
|
lines.append(f"## [{folder}](./{folder}/)\n_{desc}_\n")
|
|
48
56
|
lines.append("## Connector Status\n")
|
|
@@ -87,14 +95,14 @@ class PReinforceGardener:
|
|
|
87
95
|
filename = self._make_filename(raw_data, folder)
|
|
88
96
|
filepath = BRAIN_DIR / folder / filename
|
|
89
97
|
|
|
90
|
-
# 마크다운
|
|
98
|
+
# 마크다운 미러 (사용자 소유 Obsidian 호환 아티팩트)
|
|
91
99
|
content = self._wrap_markdown(raw_data, folder)
|
|
92
100
|
filepath.write_text(content, encoding="utf-8")
|
|
93
101
|
|
|
94
102
|
# 오늘 로그에도 기록
|
|
95
103
|
self._append_log(raw_data[:200], folder, filename)
|
|
96
104
|
|
|
97
|
-
|
|
105
|
+
result = {
|
|
98
106
|
"status": "saved",
|
|
99
107
|
"folder": folder,
|
|
100
108
|
"filename": filename,
|
|
@@ -102,6 +110,68 @@ class PReinforceGardener:
|
|
|
102
110
|
"classified_as": folder,
|
|
103
111
|
"description": STRUCTURE[folder],
|
|
104
112
|
}
|
|
113
|
+
# 두뇌(Knowledge Graph)가 정식 저장소: 통합 수집 파이프라인으로 ingest.
|
|
114
|
+
result.update(self._ingest_note(raw_data, source_uri=str(filepath), folder=folder))
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def _ingest_note(self, text: str, *, source_uri: str, folder: str, title: Optional[str] = None) -> dict:
|
|
118
|
+
if self._pipeline is None:
|
|
119
|
+
return {"graph": "unavailable", "graph_detail": "ingestion pipeline not wired"}
|
|
120
|
+
try:
|
|
121
|
+
from latticeai.services.ingestion import IngestionItem
|
|
122
|
+
|
|
123
|
+
ingest = self._pipeline.ingest(
|
|
124
|
+
IngestionItem(
|
|
125
|
+
source_type="note",
|
|
126
|
+
title=title or text.strip().split("\n")[0][:80],
|
|
127
|
+
text=text,
|
|
128
|
+
source_uri=source_uri,
|
|
129
|
+
metadata={"garden_folder": folder, "pipeline": "p-reinforce"},
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
if ingest.status != "ok":
|
|
133
|
+
return {"graph": ingest.status, "graph_detail": ingest.detail}
|
|
134
|
+
return {
|
|
135
|
+
"graph": "ok",
|
|
136
|
+
"graph_node_id": ingest.node_id,
|
|
137
|
+
"provenance_id": ingest.provenance_id,
|
|
138
|
+
"duplicate": ingest.duplicate,
|
|
139
|
+
}
|
|
140
|
+
except Exception as exc:
|
|
141
|
+
logging.warning("garden note ingest failed: %s", exc)
|
|
142
|
+
return {"graph": "failed", "graph_detail": str(exc)}
|
|
143
|
+
|
|
144
|
+
def import_vault(self) -> dict:
|
|
145
|
+
"""Idempotent import of every existing vault note into the brain.
|
|
146
|
+
|
|
147
|
+
Content-hash dedup in the store makes re-runs safe; vault files are
|
|
148
|
+
never modified or deleted. INDEX.md and the daily logs are skipped.
|
|
149
|
+
"""
|
|
150
|
+
if self._pipeline is None:
|
|
151
|
+
return {"status": "unavailable", "imported": 0}
|
|
152
|
+
imported = duplicates = failed = 0
|
|
153
|
+
for file_path in sorted(BRAIN_DIR.rglob("*.md")):
|
|
154
|
+
if file_path.name == "INDEX.md" or "40_Log" in file_path.parts:
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
text = file_path.read_text(encoding="utf-8")
|
|
158
|
+
except Exception:
|
|
159
|
+
failed += 1
|
|
160
|
+
continue
|
|
161
|
+
folder = file_path.parent.name if file_path.parent != BRAIN_DIR else "00_Raw"
|
|
162
|
+
outcome = self._ingest_note(
|
|
163
|
+
text, source_uri=str(file_path), folder=folder, title=file_path.stem
|
|
164
|
+
)
|
|
165
|
+
if outcome.get("graph") == "ok":
|
|
166
|
+
if outcome.get("duplicate"):
|
|
167
|
+
duplicates += 1
|
|
168
|
+
else:
|
|
169
|
+
imported += 1
|
|
170
|
+
else:
|
|
171
|
+
failed += 1
|
|
172
|
+
if imported:
|
|
173
|
+
logging.info("garden: imported %d vault notes into the brain (%d already known)", imported, duplicates)
|
|
174
|
+
return {"status": "ok", "imported": imported, "duplicates": duplicates, "failed": failed}
|
|
105
175
|
|
|
106
176
|
def _wrap_markdown(self, raw: str, folder: str) -> str:
|
|
107
177
|
now = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
@@ -112,7 +182,7 @@ class PReinforceGardener:
|
|
|
112
182
|
"---\n",
|
|
113
183
|
raw,
|
|
114
184
|
"\n\n---",
|
|
115
|
-
|
|
185
|
+
"*Auto-organized by P-Reinforce Gardener*",
|
|
116
186
|
]
|
|
117
187
|
return "\n".join(lines)
|
|
118
188
|
|
|
@@ -127,22 +197,62 @@ class PReinforceGardener:
|
|
|
127
197
|
|
|
128
198
|
# ── Tree ──────────────────────────────────────────────────────────────────
|
|
129
199
|
|
|
200
|
+
def get_tree(self) -> dict:
|
|
201
|
+
"""지식 정원 파일트리 (마크다운 미러 기준)."""
|
|
202
|
+
folders = []
|
|
203
|
+
for folder, desc in STRUCTURE.items():
|
|
204
|
+
folder_path = BRAIN_DIR / folder
|
|
205
|
+
files = []
|
|
206
|
+
if folder_path.exists():
|
|
207
|
+
for file_path in sorted(folder_path.glob("*.md")):
|
|
208
|
+
try:
|
|
209
|
+
stat = file_path.stat()
|
|
210
|
+
files.append({
|
|
211
|
+
"name": file_path.name,
|
|
212
|
+
"size_bytes": stat.st_size,
|
|
213
|
+
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
|
|
214
|
+
})
|
|
215
|
+
except OSError:
|
|
216
|
+
continue
|
|
217
|
+
folders.append({"name": folder, "description": desc, "files": files, "count": len(files)})
|
|
218
|
+
return {"root": str(BRAIN_DIR), "folders": folders}
|
|
219
|
+
|
|
130
220
|
def get_relevant_context(self, query: str, limit: int = 3) -> str:
|
|
131
|
-
"""질문과 관련된
|
|
221
|
+
"""질문과 관련된 정원 노트를 두뇌에서 검색해 컨텍스트로 반환.
|
|
222
|
+
|
|
223
|
+
v4: 채팅마다 vault 전체를 rglob 하던 O(n) 스캔을 브레인 검색으로
|
|
224
|
+
대체. 그래프가 없으면(비활성) 기존 파일 스캔으로 정직하게 폴백.
|
|
225
|
+
"""
|
|
226
|
+
if self._kg is not None:
|
|
227
|
+
try:
|
|
228
|
+
matches = self._kg.search(query, max(limit * 4, 8)).get("matches", [])
|
|
229
|
+
results = []
|
|
230
|
+
for match in matches:
|
|
231
|
+
meta = match.get("metadata") or {}
|
|
232
|
+
if not (meta.get("garden_folder") or meta.get("pipeline") == "p-reinforce"):
|
|
233
|
+
continue
|
|
234
|
+
title = match.get("title") or "note"
|
|
235
|
+
body = match.get("summary") or ""
|
|
236
|
+
results.append(f"--- Document: {title} ---\n{body[:800]}")
|
|
237
|
+
if len(results) >= limit:
|
|
238
|
+
break
|
|
239
|
+
return "\n\n".join(results)
|
|
240
|
+
except Exception as exc:
|
|
241
|
+
logging.debug("garden brain context failed, falling back to vault scan: %s", exc)
|
|
242
|
+
return self._scan_vault_context(query, limit)
|
|
243
|
+
|
|
244
|
+
def _scan_vault_context(self, query: str, limit: int = 3) -> str:
|
|
132
245
|
results = []
|
|
133
|
-
# 모든 마크다운 파일 탐색 (INDEX.md 및 Log 제외)
|
|
134
246
|
for file_path in BRAIN_DIR.rglob("*.md"):
|
|
135
247
|
if file_path.name == "INDEX.md" or "40_Log" in str(file_path):
|
|
136
248
|
continue
|
|
137
|
-
|
|
138
249
|
try:
|
|
139
250
|
content = file_path.read_text(encoding="utf-8")
|
|
140
|
-
keywords = [k for k in re.split(r
|
|
251
|
+
keywords = [k for k in re.split(r"\s+", query) if len(k) > 1]
|
|
141
252
|
if any(k.lower() in content.lower() for k in keywords):
|
|
142
253
|
results.append(f"--- Document: {file_path.name} ---\n{content[:800]}")
|
|
143
254
|
if len(results) >= limit:
|
|
144
255
|
break
|
|
145
256
|
except Exception:
|
|
146
257
|
continue
|
|
147
|
-
|
|
148
258
|
return "\n\n".join(results)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Lattice AI
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Lattice AI — local-first Digital Brain Platform (knowledge graph, durable memory, hybrid search, agents, signed brain exchange)",
|
|
5
5
|
"homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -66,13 +66,11 @@
|
|
|
66
66
|
"server.py",
|
|
67
67
|
"kg_schema.py",
|
|
68
68
|
"knowledge_graph.py",
|
|
69
|
-
"knowledge_graph_api.py",
|
|
70
69
|
"local_knowledge_api.py",
|
|
71
70
|
"llm_router.py",
|
|
72
71
|
"p_reinforce.py",
|
|
73
72
|
"telegram_bot.py",
|
|
74
73
|
"tools/",
|
|
75
|
-
"codex_telegram_bot.py",
|
|
76
74
|
"mcp_registry.py",
|
|
77
75
|
"latticeai/**/*.py",
|
|
78
76
|
"skills/",
|
|
@@ -96,12 +94,16 @@
|
|
|
96
94
|
"static/icons/",
|
|
97
95
|
"plugins/",
|
|
98
96
|
"scripts/",
|
|
99
|
-
"docs/",
|
|
100
97
|
"!docs/images/tmp_frames/",
|
|
101
98
|
"!**/__pycache__/",
|
|
102
99
|
"!**/*.pyc",
|
|
103
|
-
"
|
|
104
|
-
"
|
|
100
|
+
"README.md",
|
|
101
|
+
"setup_wizard.py",
|
|
102
|
+
"knowledge_graph_api.py",
|
|
103
|
+
"static/vendor/",
|
|
104
|
+
"docs/*.md",
|
|
105
|
+
"!docs/assets/",
|
|
106
|
+
"!docs/images/"
|
|
105
107
|
],
|
|
106
108
|
"publishConfig": {
|
|
107
109
|
"access": "public"
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Single-source version bump — rewrites every synchronized version copy.
|
|
3
|
+
|
|
4
|
+
The canonical version has always been nine hand-edited copies guarded by
|
|
5
|
+
tests/unit/test_version_consistency.py. This script makes the bump one
|
|
6
|
+
command; the consistency test keeps guarding the result.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python scripts/bump_version.py 4.0.0
|
|
10
|
+
python scripts/bump_version.py 4.0.0 --check # verify only, no writes
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
22
|
+
|
|
23
|
+
# (path, kind, pattern) — pattern groups: (prefix, version)
|
|
24
|
+
TARGETS = [
|
|
25
|
+
("latticeai/__init__.py", "regex", r'(__version__ = ")([^"]+)(")'),
|
|
26
|
+
("latticeai/core/workspace_os.py", "regex", r'(WORKSPACE_OS_VERSION = ")([^"]+)(")'),
|
|
27
|
+
("latticeai/core/marketplace.py", "regex", r'(MARKETPLACE_VERSION = ")([^"]+)(")'),
|
|
28
|
+
("latticeai/core/multi_agent.py", "regex", r'(MULTI_AGENT_VERSION = ")([^"]+)(")'),
|
|
29
|
+
("pyproject.toml", "regex", r'(^version = ")([^"]+)(")'),
|
|
30
|
+
("package.json", "json", "version"),
|
|
31
|
+
("package-lock.json", "package-lock", None),
|
|
32
|
+
("vscode-extension/package.json", "json", "version"),
|
|
33
|
+
("vscode-extension/package-lock.json", "package-lock", None),
|
|
34
|
+
("static/v3/asset-manifest.json", "json", "version"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def bump(version: str, *, check: bool = False) -> int:
|
|
39
|
+
if not re.fullmatch(r"\d+\.\d+\.\d+([-.][0-9A-Za-z.]+)?", version):
|
|
40
|
+
print(f"error: '{version}' is not a sane semantic version", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
failures = 0
|
|
43
|
+
for rel, kind, spec in TARGETS:
|
|
44
|
+
path = REPO / rel
|
|
45
|
+
if not path.exists():
|
|
46
|
+
print(f" skip {rel} (missing)")
|
|
47
|
+
continue
|
|
48
|
+
text = path.read_text(encoding="utf-8")
|
|
49
|
+
if kind == "regex":
|
|
50
|
+
new, n = re.subn(spec, lambda m: m.group(1) + version + m.group(3), text, count=1, flags=re.MULTILINE)
|
|
51
|
+
changed = n == 1 and new != text
|
|
52
|
+
ok = n == 1
|
|
53
|
+
elif kind == "json":
|
|
54
|
+
data = json.loads(text)
|
|
55
|
+
ok = spec in data
|
|
56
|
+
changed = ok and data.get(spec) != version
|
|
57
|
+
if ok:
|
|
58
|
+
data[spec] = version
|
|
59
|
+
new = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
60
|
+
elif kind == "package-lock":
|
|
61
|
+
data = json.loads(text)
|
|
62
|
+
ok = "version" in data
|
|
63
|
+
changed = ok and data.get("version") != version
|
|
64
|
+
if ok:
|
|
65
|
+
data["version"] = version
|
|
66
|
+
if "packages" in data and "" in data["packages"]:
|
|
67
|
+
data["packages"][""]["version"] = version
|
|
68
|
+
new = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
|
69
|
+
else: # pragma: no cover
|
|
70
|
+
raise AssertionError(kind)
|
|
71
|
+
if not ok:
|
|
72
|
+
print(f" FAIL {rel}: version field not found")
|
|
73
|
+
failures += 1
|
|
74
|
+
continue
|
|
75
|
+
if check:
|
|
76
|
+
current = re.search(spec, text, flags=re.MULTILINE).group(2) if kind == "regex" else json.loads(text)["version"]
|
|
77
|
+
status = "ok" if current == version else f"MISMATCH ({current})"
|
|
78
|
+
if current != version:
|
|
79
|
+
failures += 1
|
|
80
|
+
print(f" {status:>9} {rel}")
|
|
81
|
+
continue
|
|
82
|
+
if changed:
|
|
83
|
+
path.write_text(new, encoding="utf-8")
|
|
84
|
+
print(f" bumped {rel}")
|
|
85
|
+
else:
|
|
86
|
+
print(f" ok {rel}")
|
|
87
|
+
return 1 if failures else 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main() -> int:
|
|
91
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
92
|
+
parser.add_argument("version")
|
|
93
|
+
parser.add_argument("--check", action="store_true", help="verify only; write nothing")
|
|
94
|
+
args = parser.parse_args()
|
|
95
|
+
return bump(args.version, check=args.check)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
package/scripts/lint_v3.mjs
CHANGED
|
@@ -1,33 +1,97 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
/* Lattice v3 frontend lint. Gates `npm run lint`:
|
|
3
|
+
* 1. Syntax-check every v3 ES module (node --check).
|
|
4
|
+
* 2. Design tokens: no raw hex/rgb colors in static/v3/css outside the two
|
|
5
|
+
* token files (lattice.tokens.css, tokens.css) — themed surfaces must use
|
|
6
|
+
* var(--…) tokens.
|
|
7
|
+
* 3. No inline style colors in view JS (style="…color: #…" or
|
|
8
|
+
* style.color = "#…" literals).
|
|
9
|
+
* 4. Privacy: zero CDN/external URLs in shipped static HTML/CSS/JS —
|
|
10
|
+
* fonts/icons/libs are vendored under static/vendor.
|
|
11
|
+
* Exits non-zero on any failure. */
|
|
12
|
+
import { readdirSync, statSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join, dirname, relative } from "node:path";
|
|
6
14
|
import { fileURLToPath } from "node:url";
|
|
7
15
|
import { spawnSync } from "node:child_process";
|
|
8
16
|
|
|
9
|
-
const
|
|
17
|
+
const repo = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
const v3js = join(repo, "static", "v3", "js");
|
|
19
|
+
const v3css = join(repo, "static", "v3", "css");
|
|
20
|
+
const staticRoot = join(repo, "static");
|
|
10
21
|
|
|
11
|
-
function walk(dir) {
|
|
22
|
+
function walk(dir, ext) {
|
|
12
23
|
const out = [];
|
|
13
24
|
for (const name of readdirSync(dir)) {
|
|
14
25
|
const p = join(dir, name);
|
|
15
|
-
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
16
|
-
else if (name.endsWith(
|
|
26
|
+
if (statSync(p).isDirectory()) out.push(...walk(p, ext));
|
|
27
|
+
else if (ext.some((e) => name.endsWith(e))) out.push(p);
|
|
17
28
|
}
|
|
18
29
|
return out;
|
|
19
30
|
}
|
|
20
31
|
|
|
21
|
-
const files = walk(root).sort();
|
|
22
32
|
let failed = 0;
|
|
23
|
-
|
|
33
|
+
const fail = (msg) => { failed++; console.error(`FAIL ${msg}`); };
|
|
34
|
+
|
|
35
|
+
// ── 1. syntax ────────────────────────────────────────────────────────────
|
|
36
|
+
const modules = walk(v3js, [".js"]).sort();
|
|
37
|
+
let syntaxOk = 0;
|
|
38
|
+
for (const file of modules) {
|
|
24
39
|
const r = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
|
|
25
|
-
if (r.status === 0)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
if (r.status === 0) syntaxOk++;
|
|
41
|
+
else fail(`${relative(repo, file)}\n${r.stderr || r.stdout}`);
|
|
42
|
+
}
|
|
43
|
+
console.log(`syntax: ${syntaxOk}/${modules.length} v3 modules pass`);
|
|
44
|
+
|
|
45
|
+
// ── 2. raw colors in v3 css (outside token files) ────────────────────────
|
|
46
|
+
const TOKEN_FILES = new Set(["lattice.tokens.css", "tokens.css"]);
|
|
47
|
+
const colorRe = /#[0-9a-fA-F]{3,8}\b|rgba?\(/;
|
|
48
|
+
let cssChecked = 0;
|
|
49
|
+
for (const file of walk(v3css, [".css"]).sort()) {
|
|
50
|
+
const base = file.split("/").pop();
|
|
51
|
+
if (TOKEN_FILES.has(base) || /\.[0-9a-f]{8}\.css$/.test(base)) continue; // tokens + hashed builds
|
|
52
|
+
cssChecked++;
|
|
53
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
54
|
+
lines.forEach((line, i) => {
|
|
55
|
+
const code = line.split("/*")[0];
|
|
56
|
+
// mask-image gradients use #000/transparent as ALPHA values, not themed
|
|
57
|
+
// colors — they are theme-independent and exempt.
|
|
58
|
+
if (/mask-image|-webkit-mask/.test(code)) return;
|
|
59
|
+
if (colorRe.test(code)) fail(`${relative(repo, file)}:${i + 1} raw color (use a var(--…) token): ${line.trim().slice(0, 90)}`);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
console.log(`tokens: ${cssChecked} non-token v3 css files scanned for raw colors`);
|
|
63
|
+
|
|
64
|
+
// ── 3. inline style colors in view JS ────────────────────────────────────
|
|
65
|
+
const inlineColorRe = /style\s*=\s*["'`][^"'`]*(?:color|background)\s*:\s*(#|rgb)/i;
|
|
66
|
+
const styleAssignRe = /\.style\.(color|background(?:Color)?)\s*=\s*["'`](#|rgb)/i;
|
|
67
|
+
let jsChecked = 0;
|
|
68
|
+
for (const file of modules) {
|
|
69
|
+
if (/\.[0-9a-f]{8}\.js$/.test(file)) continue; // hashed builds mirror sources
|
|
70
|
+
jsChecked++;
|
|
71
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
72
|
+
lines.forEach((line, i) => {
|
|
73
|
+
if (inlineColorRe.test(line) || styleAssignRe.test(line)) {
|
|
74
|
+
fail(`${relative(repo, file)}:${i + 1} inline style color (use a token/class): ${line.trim().slice(0, 90)}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
console.log(`inline-style: ${jsChecked} v3 source modules scanned`);
|
|
79
|
+
|
|
80
|
+
// ── 4. no CDN/external asset URLs in shipped static files ────────────────
|
|
81
|
+
const cdnRe = /https?:\/\/(fonts\.googleapis\.com|fonts\.gstatic\.com|cdn\.jsdelivr\.net|unpkg\.com|cdnjs\.cloudflare\.com)/;
|
|
82
|
+
let shippedChecked = 0;
|
|
83
|
+
for (const file of walk(staticRoot, [".html", ".css", ".js"]).sort()) {
|
|
84
|
+
if (file.includes(`${join("static", "vendor")}`)) continue; // vendored copies may cite origins in comments
|
|
85
|
+
shippedChecked++;
|
|
86
|
+
const lines = readFileSync(file, "utf8").split("\n");
|
|
87
|
+
lines.forEach((line, i) => {
|
|
88
|
+
if (cdnRe.test(line)) fail(`${relative(repo, file)}:${i + 1} CDN reference (vendor it under static/vendor): ${line.trim().slice(0, 90)}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
console.log(`privacy: ${shippedChecked} shipped static files scanned for CDN URLs`);
|
|
92
|
+
|
|
93
|
+
if (failed) {
|
|
94
|
+
console.error(`\nv3 frontend lint: ${failed} failure(s)`);
|
|
95
|
+
process.exit(1);
|
|
31
96
|
}
|
|
32
|
-
console.log(`\nv3 frontend:
|
|
33
|
-
process.exit(failed ? 1 : 0);
|
|
97
|
+
console.log(`\nv3 frontend lint: all checks pass`);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Installed-wheel smoke test.
|
|
3
|
+
|
|
4
|
+
Builds the wheel, installs it into a *fresh* venv, and verifies — from a
|
|
5
|
+
non-repo working directory — that every module the wheel ships actually
|
|
6
|
+
imports and that the FastAPI app boots and answers ``/health``. This kills the
|
|
7
|
+
class of "works only with ``pip install -e .`` from the repo root" failures
|
|
8
|
+
(e.g. the v3.x wheels that omitted the root ``setup.py`` wizard module while
|
|
9
|
+
``latticeai.server_app`` imported it).
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python scripts/wheel_smoke.py # build + install + import + /health
|
|
13
|
+
python scripts/wheel_smoke.py --wheel dist/ltcai-X.Y.Z-py3-none-any.whl
|
|
14
|
+
python scripts/wheel_smoke.py --skip-health # imports only
|
|
15
|
+
|
|
16
|
+
The build step prefers ``python -m build --wheel`` and falls back to
|
|
17
|
+
``pip wheel . --no-deps`` when the ``build`` package is unavailable.
|
|
18
|
+
Exit code is non-zero on any failure so CI can gate on it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import os
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import venv
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
32
|
+
|
|
33
|
+
# Every importable module the wheel ships (pyproject py-modules + packages).
|
|
34
|
+
WHEEL_MODULES = [
|
|
35
|
+
"setup_wizard",
|
|
36
|
+
"latticeai",
|
|
37
|
+
"latticeai.server_app",
|
|
38
|
+
"latticeai.app_factory",
|
|
39
|
+
"latticeai.models.router",
|
|
40
|
+
"latticeai.core.mcp_registry",
|
|
41
|
+
"latticeai.api.knowledge_graph",
|
|
42
|
+
"ltcai_cli",
|
|
43
|
+
"auto_setup",
|
|
44
|
+
"server",
|
|
45
|
+
"mcp_registry",
|
|
46
|
+
"kg_schema",
|
|
47
|
+
"knowledge_graph",
|
|
48
|
+
"knowledge_graph_api",
|
|
49
|
+
"local_knowledge_api",
|
|
50
|
+
"llm_router",
|
|
51
|
+
"p_reinforce",
|
|
52
|
+
"telegram_bot",
|
|
53
|
+
"tools",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
IMPORT_CHECK = (
|
|
57
|
+
"import importlib\n"
|
|
58
|
+
+ "".join(f"importlib.import_module({mod!r})\n" for mod in WHEEL_MODULES)
|
|
59
|
+
+ f"print('wheel imports ok: {len(WHEEL_MODULES)} modules')\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
HEALTH_CHECK = """
|
|
63
|
+
from fastapi.testclient import TestClient
|
|
64
|
+
from latticeai.app_factory import create_app
|
|
65
|
+
|
|
66
|
+
app = create_app()
|
|
67
|
+
response = TestClient(app).get("/health")
|
|
68
|
+
assert response.status_code == 200, f"/health returned {response.status_code}"
|
|
69
|
+
payload = response.json()
|
|
70
|
+
assert "version" in payload, f"/health payload missing version: {payload}"
|
|
71
|
+
print("health ok:", payload.get("version"))
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run(cmd: list[str], **kwargs) -> None:
|
|
76
|
+
print("+", " ".join(str(c) for c in cmd), flush=True)
|
|
77
|
+
subprocess.run(cmd, check=True, **kwargs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_wheel(out_dir: Path) -> Path:
|
|
81
|
+
try:
|
|
82
|
+
import build # noqa: F401 — probe only
|
|
83
|
+
run([sys.executable, "-m", "build", "--wheel", "--outdir", str(out_dir)], cwd=REPO_ROOT)
|
|
84
|
+
except ImportError:
|
|
85
|
+
print("'build' package unavailable; falling back to pip wheel --no-deps", flush=True)
|
|
86
|
+
run(
|
|
87
|
+
[sys.executable, "-m", "pip", "wheel", str(REPO_ROOT), "--no-deps", "-w", str(out_dir)],
|
|
88
|
+
cwd=REPO_ROOT,
|
|
89
|
+
)
|
|
90
|
+
wheels = sorted(out_dir.glob("ltcai-*.whl"))
|
|
91
|
+
if not wheels:
|
|
92
|
+
raise SystemExit(f"no wheel produced in {out_dir}")
|
|
93
|
+
return wheels[-1]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main() -> int:
|
|
97
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
98
|
+
parser.add_argument("--wheel", type=Path, default=None, help="use an existing wheel instead of building")
|
|
99
|
+
parser.add_argument("--skip-health", action="store_true", help="run import checks only (no app boot)")
|
|
100
|
+
args = parser.parse_args()
|
|
101
|
+
|
|
102
|
+
with tempfile.TemporaryDirectory(prefix="ltcai-wheel-smoke-") as tmp:
|
|
103
|
+
tmp_path = Path(tmp)
|
|
104
|
+
|
|
105
|
+
wheel = args.wheel.resolve() if args.wheel else build_wheel(tmp_path / "dist")
|
|
106
|
+
print(f"wheel under test: {wheel}", flush=True)
|
|
107
|
+
|
|
108
|
+
venv_dir = tmp_path / "venv"
|
|
109
|
+
venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir)
|
|
110
|
+
bin_dir = "Scripts" if os.name == "nt" else "bin"
|
|
111
|
+
python = venv_dir / bin_dir / ("python.exe" if os.name == "nt" else "python")
|
|
112
|
+
|
|
113
|
+
run([str(python), "-m", "pip", "install", "--quiet", str(wheel)])
|
|
114
|
+
|
|
115
|
+
# A cwd that is NOT the repo: imports must resolve from site-packages,
|
|
116
|
+
# never from checkout-relative files.
|
|
117
|
+
work_dir = tmp_path / "non-repo-cwd"
|
|
118
|
+
work_dir.mkdir()
|
|
119
|
+
# Sandbox all user-data writes so the smoke test never touches ~/.ltcai.
|
|
120
|
+
env = {
|
|
121
|
+
**os.environ,
|
|
122
|
+
"HOME": str(tmp_path / "home"),
|
|
123
|
+
"LATTICEAI_DATA_DIR": str(tmp_path / "home" / ".ltcai"),
|
|
124
|
+
"LATTICEAI_AGENT_ROOT": str(tmp_path / "home" / "agent_workspace"),
|
|
125
|
+
"LATTICEAI_BRAIN_DIR": str(tmp_path / "home" / ".ltcai-brain"),
|
|
126
|
+
"LATTICEAI_ENABLE_TELEGRAM": "false",
|
|
127
|
+
"LATTICEAI_AUTOLOAD_MODELS": "false",
|
|
128
|
+
"PYTHONPATH": "",
|
|
129
|
+
}
|
|
130
|
+
(tmp_path / "home").mkdir()
|
|
131
|
+
|
|
132
|
+
run([str(python), "-c", IMPORT_CHECK], cwd=work_dir, env=env)
|
|
133
|
+
|
|
134
|
+
if not args.skip_health:
|
|
135
|
+
run([str(python), "-c", HEALTH_CHECK], cwd=work_dir, env=env)
|
|
136
|
+
|
|
137
|
+
print("wheel smoke test passed")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
raise SystemExit(main())
|
package/server.py
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
"""Thin compatibility entrypoint for the Lattice AI FastAPI app.
|
|
2
2
|
|
|
3
|
-
The application
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The application is built by ``latticeai.app_factory.create_app``; this module
|
|
4
|
+
keeps the historical ``server:app`` import path used by uvicorn, Docker, CLI
|
|
5
|
+
scripts, and older tests. Attribute access is proxied lazily so that simply
|
|
6
|
+
importing ``server`` performs no construction — ``uvicorn server:app`` (or
|
|
7
|
+
``from server import app``) triggers the factory on first access.
|
|
6
8
|
"""
|
|
7
9
|
|
|
8
10
|
from __future__ import annotations
|
|
9
11
|
|
|
12
|
+
from typing import Any, List
|
|
13
|
+
|
|
10
14
|
from latticeai import server_app as _server_app
|
|
11
15
|
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
globals()[_name] = getattr(_server_app, _name)
|
|
17
|
+
def __getattr__(name: str) -> Any:
|
|
18
|
+
return getattr(_server_app, name)
|
|
16
19
|
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
def __dir__() -> List[str]:
|
|
22
|
+
return sorted(set(globals()) | set(dir(_server_app)))
|
|
19
23
|
|
|
20
24
|
|
|
21
25
|
def main() -> None:
|