ltcai 3.5.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 (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
package/p_reinforce.py CHANGED
@@ -1,16 +1,21 @@
1
1
  """
2
- P-Reinforce Knowledge Gardener
3
- Raw 데이터를 자동으로 분석해서 구조화된 마크다운 위키로 정리
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 json
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
- return {
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
- f"*Auto-organized by P-Reinforce Gardener*",
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'\s+', query) if len(k) > 1]
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": "3.5.0",
4
- "description": "Lattice AI v3 local-first AI workspace platform with knowledge graph, vector index, hybrid search, agents, and workspace modes.",
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",
@@ -33,7 +33,8 @@
33
33
  "capture:skills": "node scripts/capture/capture_skills.js",
34
34
  "capture:enterprise": "node scripts/capture/capture_enterprise.js",
35
35
  "capture:onboarding": "node scripts/capture/capture_onboarding.js",
36
- "release:artifacts": "npm run build:assets && npm run build:python && npm pack && cd vscode-extension && npm run package:vsix",
36
+ "package:vsix": "node scripts/build_vsix.mjs",
37
+ "release:artifacts": "npm run build:assets && npm run build:python && npm pack && npm run package:vsix",
37
38
  "release:validate": "python3 scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz",
38
39
  "publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
39
40
  "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",
@@ -65,13 +66,11 @@
65
66
  "server.py",
66
67
  "kg_schema.py",
67
68
  "knowledge_graph.py",
68
- "knowledge_graph_api.py",
69
69
  "local_knowledge_api.py",
70
70
  "llm_router.py",
71
71
  "p_reinforce.py",
72
72
  "telegram_bot.py",
73
73
  "tools/",
74
- "codex_telegram_bot.py",
75
74
  "mcp_registry.py",
76
75
  "latticeai/**/*.py",
77
76
  "skills/",
@@ -95,12 +94,16 @@
95
94
  "static/icons/",
96
95
  "plugins/",
97
96
  "scripts/",
98
- "docs/",
99
97
  "!docs/images/tmp_frames/",
100
98
  "!**/__pycache__/",
101
99
  "!**/*.pyc",
102
- "requirements.txt",
103
- "README.md"
100
+ "README.md",
101
+ "setup_wizard.py",
102
+ "knowledge_graph_api.py",
103
+ "static/vendor/",
104
+ "docs/*.md",
105
+ "!docs/assets/",
106
+ "!docs/images/"
104
107
  ],
105
108
  "publishConfig": {
106
109
  "access": "public"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the VS Code extension VSIX into `dist/ltcai-<version>.vsix`.
4
+ *
5
+ * Why a script instead of an inline `cd vscode-extension && npm run package:vsix`:
6
+ *
7
+ * 1. **Single version source.** The output name is derived from the ROOT
8
+ * `package.json` version — the same value `release:validate` checks. The old
9
+ * inline form used the extension's own `$npm_package_version`, so a version
10
+ * drift between root and extension produced a `dist/ltcai-<ext>.vsix` that the
11
+ * validator (run from root) reported as a *missing* `dist/ltcai-<root>.vsix`.
12
+ * 2. **Fresh-checkout / CI safe.** It installs the extension's toolchain
13
+ * (`tsc`, `vsce`) when `node_modules` is absent, so the artifact builds on a
14
+ * clean clone — not only on a warmed-up dev tree.
15
+ * 3. **Fails loudly.** It verifies the compiled entrypoint and the final VSIX
16
+ * exist, exiting non-zero otherwise, so a skipped compile can't yield a
17
+ * silently-empty or missing artifact.
18
+ *
19
+ * Mirrors the tag-driven `.github/workflows/release.yml` VSIX step.
20
+ */
21
+ import { execFileSync } from "node:child_process";
22
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
+ import { dirname, join, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const extDir = join(repoRoot, "vscode-extension");
28
+ const distDir = join(repoRoot, "dist");
29
+
30
+ const version = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")).version;
31
+ const extVersion = JSON.parse(readFileSync(join(extDir, "package.json"), "utf8")).version;
32
+ if (extVersion !== version) {
33
+ console.error(
34
+ `build_vsix: version drift — root package.json is ${version} but ` +
35
+ `vscode-extension/package.json is ${extVersion}. Bump both to the same value.`
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ const outFile = join(distDir, `ltcai-${version}.vsix`);
41
+ const binExt = process.platform === "win32" ? ".cmd" : "";
42
+
43
+ mkdirSync(distDir, { recursive: true });
44
+
45
+ function run(cmd, args, cwd) {
46
+ console.log(`$ (cd ${cwd && cwd.replace(repoRoot, ".")}) ${cmd} ${args.join(" ")}`);
47
+ execFileSync(cmd, args, { cwd, stdio: "inherit" });
48
+ }
49
+
50
+ // 1) Ensure the extension toolchain (tsc + vsce) is installed.
51
+ if (!existsSync(join(extDir, "node_modules", ".bin", `vsce${binExt}`))) {
52
+ const installCmd = existsSync(join(extDir, "package-lock.json")) ? "ci" : "install";
53
+ run(`npm${binExt}`, [installCmd, "--no-audit", "--no-fund"], extDir);
54
+ }
55
+
56
+ // 2) Compile and assert the entrypoint exists (vsce also runs vscode:prepublish,
57
+ // but we fail fast and explicitly here for a clearer error).
58
+ run(`npm${binExt}`, ["run", "compile"], extDir);
59
+ if (!existsSync(join(extDir, "out", "extension.js"))) {
60
+ console.error("build_vsix: vscode-extension/out/extension.js missing after compile");
61
+ process.exit(1);
62
+ }
63
+
64
+ // 3) Package to the root-version-scoped path. `--no-yarn` matches release.yml.
65
+ run(join(extDir, "node_modules", ".bin", `vsce${binExt}`), ["package", "--no-yarn", "-o", outFile], extDir);
66
+
67
+ // 4) Verify the artifact landed where release:validate expects it.
68
+ if (!existsSync(outFile)) {
69
+ console.error(`build_vsix: expected artifact not found: ${outFile}`);
70
+ process.exit(1);
71
+ }
72
+ console.log(`build_vsix: wrote ${outFile.replace(repoRoot, ".")}`);
@@ -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())
@@ -17,7 +17,6 @@ the hero GIF is assembled from via ffmpeg by the caller).
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- import os
21
20
  import sys
22
21
  from pathlib import Path
23
22
 
@@ -1,33 +1,97 @@
1
1
  #!/usr/bin/env node
2
- /* Syntax-check every Lattice AI v3 frontend ES module (node --check, ESM auto-detect).
3
- * Exits non-zero on the first failure so it can gate `npm run lint`. */
4
- import { readdirSync, statSync } from "node:fs";
5
- import { join, dirname } from "node:path";
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 root = join(dirname(fileURLToPath(import.meta.url)), "..", "static", "v3", "js");
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(".js")) out.push(p);
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
- for (const file of files) {
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
- console.log(`ok ${file.replace(root, "static/v3/js")}`);
27
- } else {
28
- failed++;
29
- console.error(`FAIL ${file.replace(root, "static/v3/js")}\n${r.stderr || r.stdout}`);
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: ${files.length - failed}/${files.length} modules pass`);
33
- process.exit(failed ? 1 : 0);
97
+ console.log(`\nv3 frontend lint: all checks pass`);
@@ -20,7 +20,6 @@ from __future__ import annotations
20
20
  import argparse
21
21
  import json
22
22
  import re
23
- import sys
24
23
  import zipfile
25
24
  from pathlib import Path
26
25
  from typing import Dict, List, Optional