ltcai 10.5.0 → 10.6.2

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 (101) hide show
  1. package/README.md +62 -45
  2. package/docs/CHANGELOG.md +212 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/TRUST_MODEL.md +1 -1
  8. package/docs/WHY_LATTICE.md +1 -1
  9. package/docs/kg-schema.md +1 -1
  10. package/lattice_brain/__init__.py +1 -1
  11. package/lattice_brain/graph/_kg_contract.py +15 -0
  12. package/lattice_brain/graph/provenance.py +61 -5
  13. package/lattice_brain/graph/retrieval.py +16 -4
  14. package/lattice_brain/graph/retrieval_reads.py +136 -23
  15. package/lattice_brain/graph/retrieval_vector.py +156 -8
  16. package/lattice_brain/ingestion.py +14 -1
  17. package/lattice_brain/ingestion_jobs.py +255 -5
  18. package/lattice_brain/portability.py +5 -2
  19. package/lattice_brain/runtime/multi_agent.py +1 -1
  20. package/latticeai/__init__.py +1 -1
  21. package/latticeai/api/browser.py +8 -14
  22. package/latticeai/api/knowledge_graph.py +46 -16
  23. package/latticeai/api/workspace.py +4 -11
  24. package/latticeai/api/workspace_scope.py +125 -0
  25. package/latticeai/core/agent.py +68 -3
  26. package/latticeai/core/config.py +6 -0
  27. package/latticeai/core/csrf.py +293 -0
  28. package/latticeai/core/legacy_compatibility.py +1 -1
  29. package/latticeai/core/marketplace.py +1 -1
  30. package/latticeai/core/workspace_os_constants.py +1 -1
  31. package/latticeai/models/router.py +61 -16
  32. package/latticeai/runtime/build_phases.py +2 -0
  33. package/latticeai/runtime/config_runtime.py +2 -0
  34. package/latticeai/runtime/runtime_context.py +1 -0
  35. package/latticeai/runtime/web_runtime.py +31 -4
  36. package/latticeai/services/architecture_readiness.py +1 -1
  37. package/latticeai/services/product_readiness.py +1 -1
  38. package/latticeai/services/search_service.py +9 -1
  39. package/latticeai/services/tool_dispatch.py +13 -7
  40. package/latticeai/tools/__init__.py +1 -0
  41. package/latticeai/tools/documents.py +44 -13
  42. package/package.json +1 -1
  43. package/scripts/build_frontend_assets.mjs +21 -4
  44. package/scripts/capture_release_evidence.mjs +13 -5
  45. package/scripts/check_current_release_docs.mjs +1 -1
  46. package/scripts/check_frontend_build_freshness.mjs +170 -0
  47. package/src-tauri/Cargo.lock +1 -1
  48. package/src-tauri/Cargo.toml +1 -1
  49. package/src-tauri/tauri.conf.json +1 -1
  50. package/static/app/asset-manifest.json +40 -38
  51. package/static/app/assets/{Act-CLsUUE4V.js → Act-aNud-lKL.js} +1 -1
  52. package/static/app/assets/AdminConsole-QiCTH68K.js +1 -0
  53. package/static/app/assets/Brain-CMFh5q6k.js +321 -0
  54. package/static/app/assets/{BrainHome-DMMhKb8j.js → BrainHome-B4Ar3atu.js} +2 -2
  55. package/static/app/assets/{BrainSignals-CcvYpBGn.js → BrainSignals-RVMlmTGC.js} +1 -1
  56. package/static/app/assets/Capture-DEBo-0vZ.js +1 -0
  57. package/static/app/assets/CommandPalette-bW5WxWWZ.js +1 -0
  58. package/static/app/assets/Library-5gFexm83.js +1 -0
  59. package/static/app/assets/{LivingBrain-BVFU-llN.js → LivingBrain-FlHFfu9i.js} +1 -1
  60. package/static/app/assets/ProductFlow-CFUNDOHu.js +1 -0
  61. package/static/app/assets/{ReviewCard-DODh3z_D.js → ReviewCard-BHj86h2Z.js} +2 -2
  62. package/static/app/assets/System-CeNHoZRu.js +1 -0
  63. package/static/app/assets/{activity-DYlZpTrt.js → activity-7_ZmZqN0.js} +1 -1
  64. package/static/app/assets/arrow-left-Dv2Tiwhe.js +1 -0
  65. package/static/app/assets/{bot-BBmfPYKv.js → bot-D1gX4xks.js} +1 -1
  66. package/static/app/assets/brain-Dn_bDfl4.js +1 -0
  67. package/static/app/assets/{button-DXSLgXUl.js → button-DPFZ9lGw.js} +1 -1
  68. package/static/app/assets/{circle-pause-B8TPRvnt.js → circle-pause-B2P72YOO.js} +1 -1
  69. package/static/app/assets/{circle-play-C5aQnYUv.js → circle-play-BiC8z2vg.js} +1 -1
  70. package/static/app/assets/{cpu-BvOUS0hg.js → cpu-Cx-wRR_V.js} +1 -1
  71. package/static/app/assets/{download-BM2Y4baa.js → download-CKxlzxsK.js} +1 -1
  72. package/static/app/assets/{folder-open-DATq5giq.js → folder-open-Bbjju0tt.js} +1 -1
  73. package/static/app/assets/{hard-drive-D2iumtyP.js → hard-drive-Bon1VBvq.js} +1 -1
  74. package/static/app/assets/index-DYUs0cWy.css +2 -0
  75. package/static/app/assets/index-mLP0-YNO.js +10 -0
  76. package/static/app/assets/{input-Ptpha52f.js → input-DrMc0Xns.js} +1 -1
  77. package/static/app/assets/{permissionCopy-DLSOKW1h.js → permissionCopy-BGUsI7vw.js} +1 -1
  78. package/static/app/assets/{primitives-BvxTiz4R.js → primitives-CEMTjBz1.js} +1 -1
  79. package/static/app/assets/search-HsIji1wY.js +1 -0
  80. package/static/app/assets/share-2-D7THHq5K.js +1 -0
  81. package/static/app/assets/{shield-alert-B6RHxSXi.js → shield-alert-BoU4_8r9.js} +1 -1
  82. package/static/app/assets/{textarea-CCZqVitr.js → textarea-rU1Lb7ia.js} +1 -1
  83. package/static/app/assets/{useFocusTrap-QR9BiUDX.js → useFocusTrap-BgvK4Nkx.js} +1 -1
  84. package/static/app/assets/{useQuery-B42HOaUm.js → useQuery-CT2ChyuU.js} +1 -1
  85. package/static/app/assets/{users-D9PLGmAE.js → users-DlbfHBQV.js} +1 -1
  86. package/static/app/assets/{utils-CAuwfT8i.js → utils-fEGWreKB.js} +1 -1
  87. package/static/app/assets/workspace-ClDBz_0f.js +1 -0
  88. package/static/app/index.html +4 -4
  89. package/static/sw.js +1 -1
  90. package/static/app/assets/AdminConsole-D1PBR5Mk.js +0 -1
  91. package/static/app/assets/Brain-Cnb_YCYH.js +0 -321
  92. package/static/app/assets/Capture-DU1HZWqB.js +0 -1
  93. package/static/app/assets/CommandPalette-tU229JbI.js +0 -1
  94. package/static/app/assets/Library-BeutJd8j.js +0 -1
  95. package/static/app/assets/ProductFlow-CZp8oHh_.js +0 -1
  96. package/static/app/assets/System-CTYfZXfv.js +0 -1
  97. package/static/app/assets/brain-RRpEg40e.js +0 -1
  98. package/static/app/assets/index-B1n87vO-.css +0 -2
  99. package/static/app/assets/index-D10Yd0gh.js +0 -10
  100. package/static/app/assets/search-DtMikKf0.js +0 -1
  101. package/static/app/assets/workspace-DT2v1d3T.js +0 -1
@@ -1,14 +1,15 @@
1
1
  """FastAPI shell assembly for the application factory.
2
2
 
3
- This seam owns only the outer web shell: app construction, CORS middleware,
4
- and static asset mounts. Router registration stays in ``app_factory`` until
5
- the route snapshot/reorder step is reviewed.
3
+ This seam owns only the outer web shell: app construction, the request-side
4
+ middleware (CSRF origin guard, CORS), and static asset mounts. Router
5
+ registration stays in ``app_factory`` until the route snapshot/reorder step is
6
+ reviewed.
6
7
  """
7
8
 
8
9
  from __future__ import annotations
9
10
 
10
11
  from pathlib import Path
11
- from typing import Any, Dict, Iterable
12
+ from typing import Any, Dict, Iterable, List
12
13
 
13
14
 
14
15
  def build_web_runtime(
@@ -21,6 +22,7 @@ def build_web_runtime(
21
22
  cors_extra_origins: Iterable[str],
22
23
  cors_allow_network: bool,
23
24
  static_dir: Path,
25
+ csrf_trusted_origins: Iterable[str] = (),
24
26
  ) -> Dict[str, Any]:
25
27
  """Create the FastAPI shell and mount static assets in legacy order."""
26
28
 
@@ -28,6 +30,9 @@ def build_web_runtime(
28
30
  from fastapi.middleware.cors import CORSMiddleware
29
31
  from fastapi.staticfiles import StaticFiles
30
32
 
33
+ from latticeai.core.csrf import CSRFOriginGuardMiddleware, CSRFOriginPolicy
34
+ from latticeai.core.security import host_is_loopback
35
+
31
36
  app = FastAPI(
32
37
  title=f"Lattice AI Server ({app_mode})",
33
38
  version=app_version,
@@ -45,6 +50,27 @@ def build_web_runtime(
45
50
  f"https://{default_host}:{default_port}",
46
51
  ]
47
52
 
53
+ # An origin that CORS already lets send *credentialed* cross-origin
54
+ # requests is, by that decision, trusted with the session cookie; listing
55
+ # it again here would be a second place to forget. The explicit
56
+ # LATTICEAI_CSRF_TRUSTED_ORIGINS entries are for the reverse-proxy case,
57
+ # where the public hostname is not the bind address.
58
+ csrf_allowed_origins: List[str] = [
59
+ *cors_allowed_origins,
60
+ *csrf_trusted_origins,
61
+ ]
62
+ csrf_policy = CSRFOriginPolicy(
63
+ trusted_origins=csrf_allowed_origins,
64
+ server_host=default_host,
65
+ server_port=default_port,
66
+ bind_is_loopback=host_is_loopback(default_host),
67
+ )
68
+
69
+ # Registration order is stack order in reverse: the LAST middleware added
70
+ # is the OUTERMOST. CORS must stay outside the guard so a rejected request
71
+ # still comes back with the CORS headers the browser needs to surface the
72
+ # 403 instead of an opaque network error.
73
+ app.add_middleware(CSRFOriginGuardMiddleware, policy=csrf_policy)
48
74
  app.add_middleware(
49
75
  CORSMiddleware,
50
76
  allow_origins=cors_allowed_origins,
@@ -62,4 +88,5 @@ def build_web_runtime(
62
88
  return {
63
89
  "app": app,
64
90
  "CORS_ALLOWED_ORIGINS": cors_allowed_origins,
91
+ "CSRF_ALLOWED_ORIGINS": csrf_allowed_origins,
65
92
  }
@@ -16,7 +16,7 @@ from typing import Any, Dict, List
16
16
 
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
- ARCHITECTURE_VERSION_TARGET = "10.5.0"
19
+ ARCHITECTURE_VERSION_TARGET = "10.6.2"
20
20
 
21
21
  PREFERRED_REFACTORING_ORDER = [
22
22
  "agent-runtime",
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "10.5.0"
21
+ PRODUCT_VERSION_TARGET = "10.6.2"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -267,7 +267,7 @@ class SearchService:
267
267
  "metadata": match.get("metadata") or {},
268
268
  "updated_at": match.get("updated_at"),
269
269
  })
270
- return {
270
+ result = {
271
271
  "query": query,
272
272
  "mode": "vector",
273
273
  "embedding_model": payload.get("embedding_model"),
@@ -278,6 +278,14 @@ class SearchService:
278
278
  include_legacy_global=include_legacy_global,
279
279
  ),
280
280
  }
281
+ # Honest recall passthrough: the graph layer scores a capped slice of a
282
+ # large vector index and says so. Dropping it here would turn "partial
283
+ # recall" into "these are all the matches" at the API boundary. It also
284
+ # rides into hybrid_search()["channels"]["vector"] for free.
285
+ recall = payload.get("recall")
286
+ if isinstance(recall, dict):
287
+ result["recall"] = recall
288
+ return result
281
289
 
282
290
  def graph_search(
283
291
  self,
@@ -34,6 +34,7 @@ from latticeai.tools import (
34
34
  AGENT_ROOT,
35
35
  DEFAULT_TOOL_REGISTRY,
36
36
  ToolError,
37
+ document_output_target,
37
38
  ensure_agent_root,
38
39
  )
39
40
 
@@ -163,16 +164,20 @@ class ToolDispatchService:
163
164
  detail=f"'{tool_name}' 툴에는 '{capability}' capability가 필요합니다.",
164
165
  )
165
166
 
166
- def _governed_path_exists(self, path: str) -> bool:
167
+ def _governed_path_exists(self, tool_name: str, path: str) -> bool:
167
168
  """Best-effort existence check for governance classification.
168
169
 
169
- Resolves workspace-relative paths under ``AGENT_ROOT`` and honors
170
- absolute paths (home-sandbox writes). Never raises — an unresolvable
171
- path is treated as non-existent (additive), which the fail-closed
172
- guard only escalates when the target actually exists.
170
+ The document creators rewrite ``filename`` into their own output
171
+ directory, so the raw argument is resolved through
172
+ :func:`document_output_target` first — checking the argument verbatim
173
+ inspects a path nothing ever writes. Workspace-relative paths resolve
174
+ under ``AGENT_ROOT``; absolute paths (home-sandbox writes) are honored
175
+ as-is. Never raises — an unresolvable path is treated as non-existent
176
+ (additive), which the fail-closed guard only escalates when the target
177
+ actually exists.
173
178
  """
174
179
  try:
175
- candidate = Path(path)
180
+ candidate = Path(document_output_target(tool_name, path) or path)
176
181
  if not candidate.is_absolute():
177
182
  candidate = Path(AGENT_ROOT) / candidate
178
183
  return candidate.exists()
@@ -244,7 +249,8 @@ class ToolDispatchService:
244
249
  # overwrite has no safe apply path in any mode, so it stays blocked.
245
250
  verdict = classify_tool_call(
246
251
  tool_name, args or {},
247
- policy=dict(policy), path_exists=self._governed_path_exists,
252
+ policy=dict(policy),
253
+ path_exists=lambda candidate: self._governed_path_exists(tool_name, candidate),
248
254
  )
249
255
  if verdict.get("fail_closed"):
250
256
  raise HTTPException(
@@ -269,6 +269,7 @@ __all__ = [
269
269
  "search_files", "inspect_html", "preview_url", "create_web_project",
270
270
  "todo_read", "todo_write",
271
271
  "create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
272
+ "document_output_target",
272
273
  "local_list", "local_read", "local_write", "desktop_bridge_status",
273
274
  "knowledge_save", "knowledge_search", "knowledge_tree", "knowledge_scope_root",
274
275
  "obsidian_save", "obsidian_search", "obsidian_tree",
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any, Dict, List
6
+ from typing import Any, Dict, List, Optional, Tuple
7
7
 
8
8
  from latticeai.core.quiet import quiet
9
9
  from latticeai.tools import (
@@ -28,6 +28,45 @@ def _safe_filename(name: str, suffix: str) -> str:
28
28
  return safe or f"artifact{suffix}"
29
29
 
30
30
 
31
+ #: tool name → (output directory, enforced suffix). The creators below are the
32
+ #: only writers, so this table is the single source of truth for "where does
33
+ #: this document actually land?".
34
+ _DOCUMENT_TOOL_TARGETS: Dict[str, Tuple[str, str]] = {
35
+ "create_docx": (DOCUMENT_OUTPUT_DIR, ".docx"),
36
+ "create_xlsx": (SPREADSHEET_OUTPUT_DIR, ".xlsx"),
37
+ "create_pptx": (PRESENTATION_OUTPUT_DIR, ".pptx"),
38
+ "create_pdf": (PDF_OUTPUT_DIR, ".pdf"),
39
+ }
40
+
41
+
42
+ def document_output_target(tool_name: str, filename: str) -> Optional[str]:
43
+ """Workspace-relative path ``tool_name`` will write ``filename`` to.
44
+
45
+ Governance needs this: the creators sanitize the caller's ``filename``
46
+ into their own output directory, so an "does the target already exist?"
47
+ check against the raw argument looks at a path nothing ever writes — and
48
+ the fail-closed overwrite guard would silently never fire.
49
+
50
+ Returns ``None`` for tools that write wherever the caller points them, so
51
+ callers fall back to the raw argument.
52
+ """
53
+ entry = _DOCUMENT_TOOL_TARGETS.get(tool_name)
54
+ if entry is None:
55
+ return None
56
+ output_dir, suffix = entry
57
+ return f"{output_dir}/{_safe_filename(filename, suffix)}"
58
+
59
+
60
+ def _document_target(tool_name: str, filename: str) -> Path:
61
+ """Resolved, workspace-confined target for a document creator."""
62
+ relative = document_output_target(tool_name, filename)
63
+ if relative is None: # pragma: no cover — every creator is in the table
64
+ raise ToolError(f"'{tool_name}' has no document output target.")
65
+ target = _resolve_path(relative)
66
+ target.parent.mkdir(parents=True, exist_ok=True)
67
+ return target
68
+
69
+
31
70
  def _body_to_str(body) -> str:
32
71
  if isinstance(body, list):
33
72
  return "\n\n".join(str(item) for item in body)
@@ -40,9 +79,7 @@ def create_docx(title: str, body, filename: str = "document.docx") -> Dict[str,
40
79
  except Exception as exc:
41
80
  raise ToolError("python-docx is not installed. Run `pip install -r requirements.txt`.") from exc
42
81
 
43
- output_dir = _resolve_path(DOCUMENT_OUTPUT_DIR)
44
- output_dir.mkdir(parents=True, exist_ok=True)
45
- target = output_dir / _safe_filename(filename, ".docx")
82
+ target = _document_target("create_docx", filename)
46
83
 
47
84
  document = Document()
48
85
  if title:
@@ -64,9 +101,7 @@ def create_xlsx(rows: List[List[Any]], filename: str = "spreadsheet.xlsx", sheet
64
101
  if not isinstance(rows, list) or not all(isinstance(row, list) for row in rows):
65
102
  raise ToolError("Rows must be a list of lists.")
66
103
 
67
- output_dir = _resolve_path(SPREADSHEET_OUTPUT_DIR)
68
- output_dir.mkdir(parents=True, exist_ok=True)
69
- target = output_dir / _safe_filename(filename, ".xlsx")
104
+ target = _document_target("create_xlsx", filename)
70
105
 
71
106
  workbook = Workbook()
72
107
  sheet = workbook.active
@@ -83,9 +118,7 @@ def create_pptx(title: str, slides: List[Dict[str, Any]], filename: str = "prese
83
118
  except Exception as exc:
84
119
  raise ToolError("python-pptx is not installed. Run `pip install -r requirements.txt`.") from exc
85
120
 
86
- output_dir = _resolve_path(PRESENTATION_OUTPUT_DIR)
87
- output_dir.mkdir(parents=True, exist_ok=True)
88
- target = output_dir / _safe_filename(filename, ".pptx")
121
+ target = _document_target("create_pptx", filename)
89
122
 
90
123
  presentation = Presentation()
91
124
  first_layout = presentation.slide_layouts[0]
@@ -124,9 +157,7 @@ def create_pdf(title: str, body, filename: str = "document.pdf") -> Dict[str, An
124
157
  except Exception as exc:
125
158
  raise ToolError("reportlab is not installed. Run `pip install reportlab`.") from exc
126
159
 
127
- output_dir = _resolve_path(PDF_OUTPUT_DIR)
128
- output_dir.mkdir(parents=True, exist_ok=True)
129
- target = output_dir / _safe_filename(filename, ".pdf")
160
+ target = _document_target("create_pdf", filename)
130
161
 
131
162
  # CJK 폰트 등록
132
163
  font_name = "Helvetica"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "10.5.0",
3
+ "version": "10.6.2",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -1,9 +1,26 @@
1
1
  #!/usr/bin/env node
2
+ // Post-processes the `vite build` output in place: normalises trailing
3
+ // whitespace in emitted css/js, rewrites the raw vite manifest into the
4
+ // app-facing asset-manifest.json, and stamps the service-worker cache version.
5
+ //
6
+ // `--out-dir <dir>` points the first two steps at a directory other than
7
+ // static/app so scripts/check_frontend_build_freshness.mjs can reproduce the
8
+ // exact shipped bytes into a scratch tree. In that mode the static/sw.js
9
+ // rewrite is skipped: it is a repo-level side effect, not part of the app dir,
10
+ // and a freshness check must never mutate the working tree.
2
11
  import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
12
+ import { join, resolve } from "node:path";
13
+
14
+ const argv = process.argv.slice(2);
15
+ const outDirFlag = argv.indexOf("--out-dir");
16
+ const outDirArg = outDirFlag === -1 ? null : argv[outDirFlag + 1];
17
+ if (outDirFlag !== -1 && !outDirArg) {
18
+ console.error("--out-dir requires a directory argument");
19
+ process.exit(2);
20
+ }
4
21
 
5
22
  const repo = join(import.meta.dirname, "..");
6
- const appDir = join(repo, "static", "app");
23
+ const appDir = outDirArg ? resolve(outDirArg) : join(repo, "static", "app");
7
24
  const nestedViteManifest = join(appDir, ".vite", "asset-manifest.json");
8
25
  const publicManifest = join(appDir, "asset-manifest.json");
9
26
  const serviceWorker = join(repo, "static", "sw.js");
@@ -46,7 +63,7 @@ const manifest = {
46
63
  };
47
64
 
48
65
  writeFileSync(publicManifest, JSON.stringify(manifest, null, 2) + "\n", "utf8");
49
- if (existsSync(serviceWorker)) {
66
+ if (!outDirArg && existsSync(serviceWorker)) {
50
67
  const cacheVersion = pkg.version.replace(/\D/g, "");
51
68
  const source = readFileSync(serviceWorker, "utf8");
52
69
  const normalized = source.replace(
@@ -55,4 +72,4 @@ if (existsSync(serviceWorker)) {
55
72
  );
56
73
  if (normalized !== source) writeFileSync(serviceWorker, normalized, "utf8");
57
74
  }
58
- console.log(`wrote static/app/asset-manifest.json with ${Object.keys(assets).length} assets`);
75
+ console.log(`wrote ${publicManifest} with ${Object.keys(assets).length} assets`);
@@ -128,12 +128,20 @@ async function main() {
128
128
  await page.waitForTimeout(250);
129
129
  await shot(page, "10-admin-console.png");
130
130
 
131
- // The three named steps a file walks on its way into memory. This tab was
132
- // hidden from plain mode and held two raw API payloads before this release,
133
- // which is why it is worth publishing now.
131
+ // The three named steps a file walks on its way into memory. 10.6.0 moved
132
+ // this out of a tab of its own and into Capture's second row, where it is now
133
+ // always on screen — so `#/pipeline` renders the same page as `#/capture`,
134
+ // and a full-page shot here came out byte-identical to 06. The README prints
135
+ // the two as different tiles, so frame the card this tile is actually about.
134
136
  await page.goto(`${baseURL}/app#/pipeline`, { waitUntil: "networkidle" });
135
- await page.getByRole("list", { name: "자료가 기억이 되는 3단계" }).waitFor();
136
- await shot(page, "11-knowledge-journey.png");
137
+ const journeyList = page.getByRole("list", { name: "자료가 기억이 되는 3단계" });
138
+ await journeyList.waitFor();
139
+ const journeyCard = page
140
+ .locator(".capture-secondary-column > *")
141
+ .filter({ has: journeyList })
142
+ .first();
143
+ await page.waitForTimeout(120);
144
+ await journeyCard.screenshot({ path: path.join(screenshots, "11-knowledge-journey.png") });
137
145
 
138
146
  await page.goto(`${baseURL}/app#/review`, { waitUntil: "networkidle" });
139
147
  await page.getByText(/Review Center|리뷰 센터|검토함/).first().waitFor();
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Everyday Words";
9
+ const releaseTheme = "Ask First";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Frontend build freshness gate.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * The Playwright visual suite (`npm run test:visual`) drives the *committed*
8
+ * build output in `static/app/`. Nothing else proves that directory was built
9
+ * from the current `frontend/src/`. Change a component, forget
10
+ * `npm run build:assets`, and CI goes green against a UI that no longer exists.
11
+ *
12
+ * What it does
13
+ * ------------
14
+ * Reproduces `npm run build:assets` into a scratch directory and compares the
15
+ * result byte-for-byte with the committed `static/app/`. It runs the same two
16
+ * steps as the real build, in the same order:
17
+ * 1. `vite build --outDir <scratch> --emptyOutDir`
18
+ * 2. `scripts/build_frontend_assets.mjs --out-dir <scratch>`
19
+ * Step 2 matters: it strips trailing whitespace from the emitted css/js *after*
20
+ * vite has already computed the content hash in the filename, so the shipped
21
+ * bytes are not the raw rollup output. Comparing against raw vite output alone
22
+ * produces a guaranteed false failure on at least one chunk.
23
+ *
24
+ * It additionally checks that `static/sw.js` carries the cache constant that
25
+ * `build:assets` stamps from package.json, because that write is the one part
26
+ * of the build that lands outside `static/app/`.
27
+ *
28
+ * Determinism (measured, not assumed)
29
+ * -----------------------------------
30
+ * On an unchanged tree two consecutive `vite build` runs are byte-identical,
31
+ * including the content-hashed filenames, and the output does not depend on
32
+ * where `--outDir` points (verified with outDirs inside and outside the repo).
33
+ * So byte-exact comparison is the strictest gate that is actually stable, and
34
+ * that is what this script does.
35
+ *
36
+ * What it does NOT catch
37
+ * ----------------------
38
+ * * Toolchain drift. It compares a build made *now, here* against bytes
39
+ * committed *then, there*. A different vite/rolldown/tailwind version, or a
40
+ * platform whose native bindings emit different bytes, would fail this gate
41
+ * even though the source and the build output agree. `npm ci` against the
42
+ * committed package-lock.json is what keeps that from happening; if this
43
+ * gate ever fails with an identical file *set* but differing bytes, suspect
44
+ * the toolchain before suspecting the author.
45
+ * * Whether the built UI is *correct*. That is the visual suite's job. This
46
+ * only proves the visual suite is looking at the current source.
47
+ * * Anything outside `static/app/` and the `static/sw.js` cache constant.
48
+ *
49
+ * Exit codes: 0 fresh, 1 stale, 2 could not run the comparison at all.
50
+ */
51
+ import { spawnSync } from "node:child_process";
52
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
53
+ import { tmpdir } from "node:os";
54
+ import { dirname, join, relative, resolve } from "node:path";
55
+ import { fileURLToPath } from "node:url";
56
+
57
+ const repo = resolve(dirname(fileURLToPath(import.meta.url)), "..");
58
+ const committedDir = join(repo, "static", "app");
59
+ const viteCli = join(repo, "node_modules", "vite", "bin", "vite.js");
60
+ const postBuild = join(repo, "scripts", "build_frontend_assets.mjs");
61
+
62
+ // vite writes its raw manifest to a `.vite/` subdirectory under some configs.
63
+ // build_frontend_assets.mjs reads it from either location and the repo does not
64
+ // commit it, so excluding it from both sides avoids a false "extra file".
65
+ const IGNORED_PREFIXES = [".vite/"];
66
+
67
+ function fail(message, code = 1) {
68
+ console.error(message);
69
+ process.exit(code);
70
+ }
71
+
72
+ function run(label, file, args) {
73
+ const result = spawnSync(process.execPath, [file, ...args], {
74
+ cwd: repo,
75
+ env: process.env,
76
+ stdio: "inherit",
77
+ });
78
+ if (result.error) fail(`${label} could not start: ${result.error.message}`, 2);
79
+ if (result.status !== 0) fail(`${label} failed with status ${result.status ?? "unknown"}`, 2);
80
+ }
81
+
82
+ function walk(root, base = root, out = new Map()) {
83
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
84
+ const full = join(root, entry.name);
85
+ const rel = relative(base, full).split("\\").join("/");
86
+ if (IGNORED_PREFIXES.some((prefix) => rel === prefix.slice(0, -1) || rel.startsWith(prefix))) {
87
+ continue;
88
+ }
89
+ if (entry.isDirectory()) walk(full, base, out);
90
+ else out.set(rel, full);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function checkServiceWorker() {
96
+ const swPath = join(repo, "static", "sw.js");
97
+ if (!existsSync(swPath)) return null;
98
+ const version = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).version;
99
+ const expected = `const CACHE = "lattice-v${version.replace(/\D/g, "")}";`;
100
+ return readFileSync(swPath, "utf8").includes(expected)
101
+ ? null
102
+ : `static/sw.js is missing ${expected} (build:assets stamps it from package.json).`;
103
+ }
104
+
105
+ if (!existsSync(committedDir)) {
106
+ fail(`Committed build output is missing: ${committedDir}\nRun \`npm run build:assets\` and commit static/app/.`);
107
+ }
108
+ if (!existsSync(viteCli)) {
109
+ fail(`vite is not installed at ${viteCli}. Run \`npm ci\` first.`, 2);
110
+ }
111
+
112
+ const scratchRoot = mkdtempSync(join(tmpdir(), "ltcai-frontend-freshness-"));
113
+ const freshDir = join(scratchRoot, "app");
114
+ let exitCode = 0;
115
+
116
+ try {
117
+ run("vite build", viteCli, ["build", "--outDir", freshDir, "--emptyOutDir"]);
118
+ run("build_frontend_assets.mjs", postBuild, ["--out-dir", freshDir]);
119
+
120
+ const fresh = walk(freshDir);
121
+ const committed = walk(committedDir);
122
+
123
+ const missing = [...committed.keys()].filter((rel) => !fresh.has(rel)).sort();
124
+ const extra = [...fresh.keys()].filter((rel) => !committed.has(rel)).sort();
125
+ const changed = [...fresh.keys()]
126
+ .filter((rel) => committed.has(rel))
127
+ .filter((rel) => !readFileSync(fresh.get(rel)).equals(readFileSync(committed.get(rel))))
128
+ .sort();
129
+
130
+ const swProblem = checkServiceWorker();
131
+
132
+ if (!missing.length && !extra.length && !changed.length && !swProblem) {
133
+ console.log(
134
+ `static/app/ is in sync with frontend/src/ (${committed.size} files rebuilt and byte-identical).`,
135
+ );
136
+ } else {
137
+ exitCode = 1;
138
+ console.error("\nstatic/app/ is STALE — it was not built from the current frontend/src/.\n");
139
+ if (extra.length) {
140
+ console.error(` ${extra.length} file(s) the current source produces but static/app/ does not have:`);
141
+ for (const rel of extra.slice(0, 20)) console.error(` + ${rel}`);
142
+ if (extra.length > 20) console.error(` ... and ${extra.length - 20} more`);
143
+ }
144
+ if (missing.length) {
145
+ console.error(` ${missing.length} committed file(s) the current source no longer produces:`);
146
+ for (const rel of missing.slice(0, 20)) console.error(` - ${rel}`);
147
+ if (missing.length > 20) console.error(` ... and ${missing.length - 20} more`);
148
+ }
149
+ if (changed.length) {
150
+ console.error(` ${changed.length} file(s) with the same name but different bytes:`);
151
+ for (const rel of changed.slice(0, 20)) {
152
+ const a = statSync(committed.get(rel)).size;
153
+ const b = statSync(fresh.get(rel)).size;
154
+ console.error(` ~ ${rel} (committed ${a} bytes, rebuilt ${b} bytes)`);
155
+ }
156
+ if (changed.length > 20) console.error(` ... and ${changed.length - 20} more`);
157
+ console.error(
158
+ " Same names with different bytes and no added/removed files can also mean\n" +
159
+ " toolchain drift rather than stale output — check that `npm ci` installed the\n" +
160
+ " versions in package-lock.json before assuming the source changed.",
161
+ );
162
+ }
163
+ if (swProblem) console.error(` ${swProblem}`);
164
+ console.error("\nFix: run `npm run build:assets` and commit static/app/ (and static/sw.js).\n");
165
+ }
166
+ } finally {
167
+ rmSync(scratchRoot, { recursive: true, force: true });
168
+ }
169
+
170
+ process.exit(exitCode);
@@ -1584,7 +1584,7 @@ dependencies = [
1584
1584
 
1585
1585
  [[package]]
1586
1586
  name = "lattice-ai-desktop"
1587
- version = "10.5.0"
1587
+ version = "10.6.2"
1588
1588
  dependencies = [
1589
1589
  "plist",
1590
1590
  "serde",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "lattice-ai-desktop"
3
- version = "10.5.0"
3
+ version = "10.6.2"
4
4
  description = "Lattice AI Digital Brain desktop shell"
5
5
  authors = ["TaeSoo Park"]
6
6
  edition = "2021"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://schema.tauri.app/config/2",
3
3
  "productName": "Lattice AI",
4
- "version": "10.5.0",
4
+ "version": "10.6.2",
5
5
  "identifier": "ai.lattice.desktop",
6
6
  "build": {
7
7
  "beforeDevCommand": "npm run frontend:dev",