ltcai 9.9.1 → 9.9.3

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 (97) hide show
  1. package/README.md +40 -39
  2. package/docs/CHANGELOG.md +80 -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/curator.py +141 -0
  12. package/lattice_brain/graph/documents.py +111 -0
  13. package/lattice_brain/graph/fusion.py +179 -0
  14. package/lattice_brain/graph/projection.py +161 -0
  15. package/lattice_brain/graph/retrieval.py +19 -1
  16. package/lattice_brain/ingestion.py +66 -0
  17. package/lattice_brain/runtime/multi_agent.py +1 -1
  18. package/latticeai/__init__.py +1 -1
  19. package/latticeai/api/automation_intelligence.py +159 -0
  20. package/latticeai/api/browser.py +15 -4
  21. package/latticeai/api/chat.py +30 -2
  22. package/latticeai/api/chat_agent_http.py +193 -4
  23. package/latticeai/api/chat_contracts.py +7 -1
  24. package/latticeai/api/chat_helpers.py +147 -0
  25. package/latticeai/api/chat_intents.py +319 -8
  26. package/latticeai/api/chat_stream.py +17 -1
  27. package/latticeai/api/funnel_metrics.py +31 -0
  28. package/latticeai/api/knowledge_graph.py +30 -0
  29. package/latticeai/api/local_files.py +90 -0
  30. package/latticeai/api/setup.py +134 -1
  31. package/latticeai/api/tools.py +33 -3
  32. package/latticeai/app_factory.py +15 -0
  33. package/latticeai/core/agent.py +205 -11
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +337 -2
  36. package/latticeai/core/legacy_compatibility.py +1 -1
  37. package/latticeai/core/marketplace.py +1 -1
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/runtime/persistence_runtime.py +19 -1
  40. package/latticeai/runtime/router_registration.py +8 -1
  41. package/latticeai/services/app_context.py +6 -0
  42. package/latticeai/services/architecture_readiness.py +1 -1
  43. package/latticeai/services/automation_execution.py +266 -0
  44. package/latticeai/services/automation_intelligence.py +7 -0
  45. package/latticeai/services/command_center.py +20 -1
  46. package/latticeai/services/folder_watch.py +391 -0
  47. package/latticeai/services/funnel_metrics.py +202 -0
  48. package/latticeai/services/product_readiness.py +6 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/services/tool_dispatch.py +36 -0
  51. package/latticeai/setup/demo_corpus.py +98 -0
  52. package/latticeai/tools/filesystem.py +38 -1
  53. package/package.json +1 -1
  54. package/scripts/bench_models.py +234 -5
  55. package/scripts/check_current_release_docs.mjs +1 -1
  56. package/src-tauri/Cargo.lock +1 -1
  57. package/src-tauri/Cargo.toml +1 -1
  58. package/src-tauri/tauri.conf.json +1 -1
  59. package/static/app/asset-manifest.json +28 -27
  60. package/static/app/assets/Act-DmdruVKV.js +1 -0
  61. package/static/app/assets/{Brain-CSCDZEHd.js → Brain-DEY9jLVt.js} +2 -2
  62. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  63. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  64. package/static/app/assets/{Capture-BiuerOio.js → Capture-CVZ09QXi.js} +1 -1
  65. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  66. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  67. package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-DxP4efJF.js} +1 -1
  68. package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-DRbm7NEq.js} +1 -1
  69. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  70. package/static/app/assets/{System-D6uJacqu.js → System-ByQcmJW-.js} +1 -1
  71. package/static/app/assets/{bot-DgC7xgUl.js → bot-BNDyZLR7.js} +1 -1
  72. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  73. package/static/app/assets/{cpu-DDGb4XHl.js → cpu-C6jjYm6i.js} +1 -1
  74. package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-DjGIvDBQ.js} +1 -1
  75. package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-BlSbwSaT.js} +1 -1
  76. package/static/app/assets/index-Bge3DXW7.css +2 -0
  77. package/static/app/assets/{index-DrZSkwrE.js → index-CHu7cgj3.js} +3 -3
  78. package/static/app/assets/{input-DPIkTs9n.js → input-DVDI0YR3.js} +1 -1
  79. package/static/app/assets/{navigation-B1mkmyUe.js → navigation-BddhEWA0.js} +1 -1
  80. package/static/app/assets/{network-0XDI1Uf_.js → network-pYQt5oBu.js} +1 -1
  81. package/static/app/assets/{primitives-sIQYKyf5.js → primitives-D7gCdEvS.js} +1 -1
  82. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  83. package/static/app/assets/{textarea-DvnwpnqS.js → textarea-sqQmoBKL.js} +1 -1
  84. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  85. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  86. package/static/app/index.html +4 -4
  87. package/static/sw.js +1 -1
  88. package/static/app/assets/Act-CcSWTQsx.js +0 -1
  89. package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
  90. package/static/app/assets/BrainSignals-CmFD8PWk.js +0 -1
  91. package/static/app/assets/CommandPalette-BrqpT7K2.js +0 -1
  92. package/static/app/assets/Library-BcaiPbum.js +0 -1
  93. package/static/app/assets/ReviewCard-D56TMypk.js +0 -1
  94. package/static/app/assets/download-C-Ei3GFa.js +0 -1
  95. package/static/app/assets/index-CFRhNBuH.css +0 -2
  96. package/static/app/assets/sunrise-DXMjueec.js +0 -1
  97. package/static/app/assets/utils-BrfFHAJl.js +0 -7
@@ -163,6 +163,18 @@ class Scenario:
163
163
  # canned results (node ids, snippets, proposal ids), so an answer that is
164
164
  # not grounded in the retrieved/executed evidence fails the scenario.
165
165
  expect_final_contains: List[str] = field(default_factory=list)
166
+ # Exact per-name counts against the LoopTrace ``repairs`` histogram —
167
+ # filegen scenarios use this to prove the ArtifactWritePipeline actually
168
+ # fired (``artifact_sanitize`` / ``artifact_repair``), not merely that
169
+ # the tool ran.
170
+ expect_repairs: Dict[str, int] = field(default_factory=dict)
171
+ # Content-level assertions on what file-writing tools actually received
172
+ # AFTER sanitize: every ``contains`` needle must appear in at least one
173
+ # written payload, every ``excludes`` needle in none. This is the teeth
174
+ # of the dirty-write scenarios — a fence or chat wrapper reaching the
175
+ # tool port fails the gate.
176
+ expect_write_contains: List[str] = field(default_factory=list)
177
+ expect_write_excludes: List[str] = field(default_factory=list)
166
178
  max_steps: int = 8
167
179
 
168
180
 
@@ -247,6 +259,37 @@ _WRITE = '{"action": "write_file", "args": {"path": "note.txt", "content": "hi"}
247
259
  _FINAL = '{"action": "final", "message": "done"}'
248
260
  _PASS = '{"action": "verdict", "verdict": "PASS", "next_state": "DONE", "reason": "ok"}'
249
261
 
262
+ # ── dirty write_file payloads (ArtifactWritePipeline scenarios) ──────────
263
+ # What weak local models actually put in args.content: chat framing + a
264
+ # Markdown fence around the document. The JSON envelope itself is valid —
265
+ # the dirt is *inside* the content string, so only the write-side sanitize
266
+ # pass (agent.py _dispatch_step → sanitize_write_content) can clean it.
267
+ _DIRTY_HTML_CONTENT = (
268
+ "Sure! Here is your page:\\n"
269
+ "```html\\n"
270
+ "<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
271
+ "<title>Hello</title></head>\\n<body><h1>Hello</h1></body>\\n</html>\\n"
272
+ "```\\n"
273
+ "Hope this helps! Let me know if you need changes."
274
+ )
275
+ _DIRTY_WRITE = (
276
+ '{"thoughts": "produce the page", "action": "write_file", '
277
+ '"args": {"path": "pages/hello.html", "content": "' + _DIRTY_HTML_CONTENT + '"}}'
278
+ )
279
+ # Token-limit casualty: the document just stops mid-body (no </body>/</html>).
280
+ _TRUNCATED_HTML_CONTENT = (
281
+ "<!DOCTYPE html>\\n<html>\\n<head><meta charset=\\\"utf-8\\\">"
282
+ "<title>Weekly</title></head>\\n<body>\\n<h1>Weekly report</h1>"
283
+ )
284
+ _TRUNCATED_WRITE = (
285
+ '{"thoughts": "write the report", "action": "write_file", '
286
+ '"args": {"path": "reports/weekly.html", "content": "' + _TRUNCATED_HTML_CONTENT + '"}}'
287
+ )
288
+ _FILEGEN_PLAN = (
289
+ '{"action": "plan", "goal": "save the requested web page to disk", '
290
+ '"steps": [{"action": "write_file"}]}'
291
+ )
292
+
250
293
 
251
294
  def default_scenarios() -> List[Scenario]:
252
295
  return [
@@ -551,6 +594,54 @@ def default_scenarios() -> List[Scenario]:
551
594
  expected_class="failed",
552
595
  expect_tool_outcomes={"error": 1},
553
596
  ),
597
+ # ── ArtifactWritePipeline (write-side sanitize) ─────────────────
598
+ Scenario(
599
+ name="filegen-dirty-write-sanitized-critic-pass",
600
+ # Weak model wraps args.content in prose + a Markdown fence. The
601
+ # sanitize pass must strip the wrapper BEFORE the tool runs, the
602
+ # trace must record artifact_sanitize, and the critic PASS then
603
+ # completes over a transcript whose written payload is clean.
604
+ replies=[_FILEGEN_PLAN, _DIRTY_WRITE, _FINAL, _PASS],
605
+ expect_exact={"parse_errors": 0},
606
+ expect_tool_outcomes={"ok": 1},
607
+ expect_tool_calls=["write_file"],
608
+ expect_repairs={"artifact_sanitize": 1},
609
+ expect_write_contains=["<!DOCTYPE html>", "</html>"],
610
+ expect_write_excludes=["```", "Sure!", "Hope this helps"],
611
+ ),
612
+ Scenario(
613
+ name="filegen-truncated-write-repaired-critic-pass",
614
+ # Token-limit truncation: no extractable clean document exists, so
615
+ # the pipeline's deterministic repair must close the document
616
+ # (artifact_repair) — the file on disk is still structurally valid.
617
+ replies=[_FILEGEN_PLAN, _TRUNCATED_WRITE, _FINAL, _PASS],
618
+ expect_exact={"parse_errors": 0},
619
+ expect_tool_outcomes={"ok": 1},
620
+ expect_tool_calls=["write_file"],
621
+ expect_repairs={"artifact_repair": 1},
622
+ expect_write_contains=["<h1>Weekly report</h1>", "</body>", "</html>"],
623
+ expect_write_excludes=["```"],
624
+ ),
625
+ Scenario(
626
+ name="filegen-dirty-write-unverifiable-needs-review",
627
+ # The dirty write is sanitized and runs, but the critic never
628
+ # produces a parseable verdict — fail-closed: the run must end
629
+ # NEEDS_REVIEW (never a fabricated DONE), while the written file
630
+ # stays clean because sanitize ran before the tool.
631
+ replies=[
632
+ _FILEGEN_PLAN, _DIRTY_WRITE, _FINAL,
633
+ "the page looks great, approving!", # unparseable critic
634
+ "still prose, still not a JSON verdict", # strict retry fails too
635
+ ],
636
+ expect_state="NEEDS_REVIEW",
637
+ expected_class="needs_review",
638
+ expect_min={"parse_errors": 2},
639
+ expect_tool_outcomes={"ok": 1},
640
+ expect_tool_calls=["write_file"],
641
+ expect_repairs={"artifact_sanitize": 1},
642
+ expect_write_excludes=["```", "Sure!"],
643
+ expect_final_contains=["직접 확인"],
644
+ ),
554
645
  ]
555
646
 
556
647
 
@@ -591,6 +682,22 @@ async def _run_scenario(scenario: Scenario) -> Dict[str, Any]:
591
682
  for needle in scenario.expect_final_contains:
592
683
  if needle not in ctx.final_message:
593
684
  failures.append(f"final_message missing {needle!r}")
685
+ for repair_name, count in scenario.expect_repairs.items():
686
+ seen = int((summary.get("repairs") or {}).get(repair_name, 0))
687
+ if seen != count:
688
+ failures.append(f"repairs[{repair_name}]={seen} != {count}")
689
+ # Payloads the file-writing tools actually received (post-sanitize).
690
+ written = [
691
+ str((call.get("args") or {}).get("content") or "")
692
+ for call in tool_log
693
+ if call["name"] in ("write_file", "generate_file")
694
+ ]
695
+ for needle in scenario.expect_write_contains:
696
+ if not any(needle in payload for payload in written):
697
+ failures.append(f"written content missing {needle!r}")
698
+ for needle in scenario.expect_write_excludes:
699
+ if any(needle in payload for payload in written):
700
+ failures.append(f"written content must not contain {needle!r}")
594
701
  if governor is not None and summary["tool_outcomes"].get("proposed", 0) != len(governor.proposals):
595
702
  failures.append(
596
703
  f"governor proposals={len(governor.proposals)} != "
@@ -25,6 +25,7 @@ The pipeline is pure (no I/O, no FastAPI); the chat layer injects an async
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
+ import ast
28
29
  import html as html_lib
29
30
  import json
30
31
  import re
@@ -91,6 +92,8 @@ _EXT_FENCE_LANGS: Dict[str, Tuple[str, ...]] = {
91
92
  ".xml": ("xml", "svg"),
92
93
  ".csv": ("csv",),
93
94
  ".txt": ("txt", "text", "plaintext"),
95
+ ".vue": ("vue", "html"),
96
+ ".svelte": ("svelte", "html"),
94
97
  }
95
98
 
96
99
 
@@ -181,6 +184,76 @@ def looks_like_refusal(content: str) -> bool:
181
184
  return bool(_REFUSAL_RE.search(head)) and len(content) < 600
182
185
 
183
186
 
187
+ # Braced code types validated structurally (balanced delimiters, no fences).
188
+ _BRACED_CODE_EXTENSIONS = frozenset({".js", ".jsx", ".ts", ".tsx"})
189
+ # Single-file components validated by their block tags being closed.
190
+ _COMPONENT_EXTENSIONS = frozenset({".vue", ".svelte"})
191
+
192
+
193
+ def _strip_code_literals(text: str) -> str:
194
+ """Remove string literals and comments so delimiter counting stays honest.
195
+
196
+ A cheap single-pass scanner (not a parser): quotes ('', "", ``), line
197
+ comments (//) and block comments (/* */) commonly contain lone braces
198
+ that would otherwise false-flag valid JS/TS as unbalanced.
199
+ """
200
+ out: List[str] = []
201
+ i, n = 0, len(text)
202
+ while i < n:
203
+ ch = text[i]
204
+ nxt = text[i + 1] if i + 1 < n else ""
205
+ if ch == "\\":
206
+ i += 2 # escaped char (inside or outside a literal — always skip)
207
+ continue
208
+ if ch in ("'", '"', "`"):
209
+ quote = ch
210
+ i += 1
211
+ while i < n:
212
+ if text[i] == "\\":
213
+ i += 2
214
+ continue
215
+ if text[i] == quote:
216
+ i += 1
217
+ break
218
+ i += 1
219
+ continue
220
+ if ch == "/" and nxt == "/":
221
+ while i < n and text[i] != "\n":
222
+ i += 1
223
+ continue
224
+ if ch == "/" and nxt == "*":
225
+ end = text.find("*/", i + 2)
226
+ i = n if end < 0 else end + 2
227
+ continue
228
+ out.append(ch)
229
+ i += 1
230
+ return "".join(out)
231
+
232
+
233
+ def _check_balanced_delimiters(content: str) -> Tuple[bool, str]:
234
+ """Lenient count-based balance check for braced code (js/ts family).
235
+
236
+ Only *counts* are compared, never ordering, so valid-but-unusual code is
237
+ not rejected; a truncated file with a dangling ``{`` still fails.
238
+ """
239
+ stripped = _strip_code_literals(content)
240
+ for opener, closer, label in (("{", "}", "braces"), ("(", ")", "parentheses"), ("[", "]", "brackets")):
241
+ if stripped.count(opener) != stripped.count(closer):
242
+ return False, f"unbalanced {label} ({opener}{closer}) — the file looks truncated"
243
+ return True, "ok"
244
+
245
+
246
+ def _check_component_blocks(content: str) -> Tuple[bool, str]:
247
+ """Vue/Svelte SFC sanity: every opened block tag must be closed."""
248
+ lower = content.lower()
249
+ for tag in ("template", "script", "style"):
250
+ opened = len(re.findall(rf"<{tag}(?:\s[^>]*)?>", lower))
251
+ closed = lower.count(f"</{tag}>")
252
+ if opened != closed:
253
+ return False, f"<{tag}> block is not closed — the component looks truncated"
254
+ return True, "ok"
255
+
256
+
184
257
  def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
185
258
  """Structural sanity check per file type. Returns (ok, reason)."""
186
259
  if not content.strip():
@@ -195,6 +268,14 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
195
268
  return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
196
269
  if "</html>" not in lower:
197
270
  return False, "HTML document is truncated (missing </html>)"
271
+ # A document that merely *contains* html somewhere is not a valid
272
+ # file payload: fenced/chat-wrapped replies must fail here so the
273
+ # extraction pass gets a chance to slice out the real document.
274
+ stripped = content.lstrip()
275
+ if not (stripped.lower().startswith("<!doctype") or stripped.lower().startswith("<html")):
276
+ return False, "HTML document is wrapped in prose or fences"
277
+ if "```" in content:
278
+ return False, "output still contains Markdown fences"
198
279
  return True, "ok"
199
280
  if ext == ".json":
200
281
  try:
@@ -203,10 +284,28 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
203
284
  return False, f"invalid JSON: {exc}"
204
285
  return True, "ok"
205
286
  if ext == ".css":
287
+ if "```" in content:
288
+ return False, "output still contains Markdown fences"
206
289
  if "{" not in content or "}" not in content:
207
290
  return False, "no CSS rule blocks found"
208
291
  return True, "ok"
209
- if ext in (".py", ".js", ".jsx", ".ts", ".tsx", ".sh", ".sql"):
292
+ if ext == ".py":
293
+ if "```" in content:
294
+ return False, "output still contains Markdown fences"
295
+ try:
296
+ ast.parse(content)
297
+ except SyntaxError as exc:
298
+ return False, f"invalid Python syntax: {exc.msg} (line {exc.lineno})"
299
+ return True, "ok"
300
+ if ext in _BRACED_CODE_EXTENSIONS:
301
+ if "```" in content:
302
+ return False, "output still contains Markdown fences"
303
+ return _check_balanced_delimiters(content)
304
+ if ext in _COMPONENT_EXTENSIONS:
305
+ if "```" in content:
306
+ return False, "output still contains Markdown fences"
307
+ return _check_component_blocks(content)
308
+ if ext in (".sh", ".sql"):
210
309
  if "```" in content:
211
310
  return False, "output still contains Markdown fences"
212
311
  return True, "ok"
@@ -240,13 +339,31 @@ _TYPE_RULES: Dict[str, str] = {
240
339
  ".csv": "Produce CSV with a header row; comma-separated, one record per line.",
241
340
  ".py": "Produce complete runnable Python source code.",
242
341
  ".js": "Produce complete valid JavaScript source code.",
342
+ ".jsx": "Produce one complete React component file in JSX.",
343
+ ".ts": "Produce complete valid TypeScript source code.",
344
+ ".tsx": "Produce one complete React component file in TSX (TypeScript).",
345
+ ".vue": "Produce ONE complete Vue single-file component with closed <template>/<script>/<style> blocks.",
346
+ ".svelte": "Produce ONE complete Svelte component; every <script>/<style> block must be closed.",
243
347
  }
244
348
 
349
+ # Multi-file bundles override the standalone-HTML rule: the page must link
350
+ # its sibling files instead of inlining everything.
351
+ _BUNDLE_HTML_RULE = (
352
+ "Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
353
+ "<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
354
+ "This page is part of a multi-file project: link the project stylesheet(s) "
355
+ "with <link rel=\"stylesheet\" href=\"...\"> and load the project script(s) "
356
+ "with <script src=\"...\"></script> just before </body>. Reference ONLY the "
357
+ "project files listed below — no other external files, no inline <style> "
358
+ "blocks, no inline behavior scripts."
359
+ )
360
+
245
361
 
246
362
  def build_file_generation_context(
247
363
  target_path: str,
248
364
  user_request: str,
249
365
  feedback: Optional[str] = None,
366
+ bundle_files: Optional[List[str]] = None,
250
367
  ) -> str:
251
368
  """Strict, extension-aware generation instructions.
252
369
 
@@ -263,8 +380,13 @@ def build_file_generation_context(
263
380
  "no text before or after the content.",
264
381
  ]
265
382
  type_rule = _TYPE_RULES.get(ext)
383
+ if bundle_files and ext in (".html", ".htm"):
384
+ type_rule = _BUNDLE_HTML_RULE
266
385
  if type_rule:
267
386
  parts.append(f"- {type_rule}")
387
+ if bundle_files:
388
+ listed = ", ".join(bundle_files)
389
+ parts.append(f"- Project files in this bundle: {listed}")
268
390
  first_line = _FIRST_LINE_HINTS.get(ext)
269
391
  if first_line:
270
392
  parts.append(f"- The very first line of your reply must be: {first_line}")
@@ -301,12 +423,29 @@ def repair_file_content(content: str, target_path: str, user_request: str) -> st
301
423
  ensure_ascii=False,
302
424
  indent=2,
303
425
  )
426
+ if ext == ".py" and salvage:
427
+ # The repair guarantee for Python is parseability: unparseable output
428
+ # is preserved honestly as a commented-out draft, never as a broken
429
+ # module the user has to debug.
430
+ try:
431
+ ast.parse(salvage)
432
+ return salvage
433
+ except SyntaxError:
434
+ commented = "\n".join(f"# {line}" for line in salvage.splitlines())
435
+ return (
436
+ f"# TODO: model produced invalid Python for: {user_request}\n"
437
+ "# The draft below is preserved as comments — fix and uncomment.\n"
438
+ f"{commented}\n"
439
+ )
304
440
  if salvage:
305
441
  return salvage
306
442
  # Nothing usable at all — leave an honest placeholder in the right format.
307
443
  comment = {
308
444
  ".py": "# TODO: model produced no usable content for: ",
309
445
  ".js": "// TODO: model produced no usable content for: ",
446
+ ".jsx": "// TODO: model produced no usable content for: ",
447
+ ".ts": "// TODO: model produced no usable content for: ",
448
+ ".tsx": "// TODO: model produced no usable content for: ",
310
449
  ".css": "/* TODO: model produced no usable content for: ",
311
450
  ".sh": "# TODO: model produced no usable content for: ",
312
451
  ".sql": "-- TODO: model produced no usable content for: ",
@@ -359,6 +498,55 @@ def _repair_html(salvage: str, user_request: str) -> str:
359
498
  )
360
499
 
361
500
 
501
+ # Extensions the Brain UI can render inline (preview) after creation.
502
+ PREVIEWABLE_EXTENSIONS = frozenset({
503
+ ".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
504
+ ".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
505
+ ".jsx", ".ts", ".tsx", ".vue", ".svelte",
506
+ })
507
+
508
+
509
+ # ── write-side sanitize (ArtifactWritePipeline) ─────────────────────────
510
+
511
+ def sanitize_write_content(
512
+ target_path: str,
513
+ content: Any,
514
+ user_request: str = "",
515
+ ) -> Tuple[str, Dict[str, Any]]:
516
+ """Single write-side guarantee for model-produced file content.
517
+
518
+ The direct chat path already runs the full generate→validate→repair
519
+ pipeline, but the agent JSON loop historically wrote ``args.content``
520
+ verbatim — weak models routinely put fenced/chatty payloads there. This
521
+ conservative sanitizer closes that gap for *any* write entry point:
522
+
523
+ 1. content that already validates is returned byte-for-byte unchanged
524
+ (trusted/user-authored content is never mangled);
525
+ 2. otherwise the extraction pass strips fences/think-blocks/chat noise
526
+ and is used only when the extracted payload validates;
527
+ 3. otherwise deterministic repair guarantees a structurally valid file.
528
+
529
+ Empty content is left untouched (creating an empty file is a legitimate,
530
+ intentional action — e.g. ``__init__.py``). Returns ``(content, meta)``
531
+ where meta is ``{"sanitized": bool, "repaired": bool, "reason": str}``.
532
+ """
533
+ raw = str(content or "")
534
+ if not raw.strip():
535
+ return raw, {"sanitized": False, "repaired": False, "reason": "empty"}
536
+ ok, reason = validate_file_content(raw, target_path)
537
+ if ok:
538
+ return raw, {"sanitized": False, "repaired": False, "reason": "ok"}
539
+ extracted = extract_file_content(raw, target_path)
540
+ if extracted:
541
+ extracted_ok, _ = validate_file_content(extracted, target_path)
542
+ if extracted_ok:
543
+ return extracted, {"sanitized": True, "repaired": False, "reason": reason}
544
+ repaired = repair_file_content(
545
+ extracted or raw, target_path, user_request or f"content for {target_path}"
546
+ )
547
+ return repaired, {"sanitized": True, "repaired": True, "reason": reason}
548
+
549
+
362
550
  # ── filename inference ──────────────────────────────────────────────────
363
551
 
364
552
  _CREATE_VERB_RE = re.compile(
@@ -401,6 +589,145 @@ def infer_file_target(message: str) -> Optional[str]:
401
589
  return None
402
590
 
403
591
 
592
+ # ── project manifest (multi-file bundles) ───────────────────────────────
593
+
594
+ # ``\b`` fails against Korean particles ("js로") because Hangul is ``\w`` —
595
+ # use ASCII lookarounds so type keywords match with or without a particle.
596
+ _HTML_HINT_RE = re.compile(
597
+ r"(?<![a-z0-9])html(?![a-z0-9])"
598
+ r"|웹\s*페이지|웹페이지|홈페이지|웹\s*사이트|웹사이트|website|web\s*page|landing\s*page",
599
+ )
600
+ _CSS_HINT_RE = re.compile(r"(?<![a-z0-9])css(?![a-z0-9])|스타일\s*시트|stylesheet")
601
+ _JS_HINT_RE = re.compile(
602
+ r"(?<![a-z0-9])js(?![a-z0-9])|javascript|자바스크립트|자바\s*스크립트"
603
+ )
604
+ # An explicit filename means the user is managing paths — keep the
605
+ # deterministic single-file flow untouched.
606
+ _EXPLICIT_FILENAME_RE = re.compile(
607
+ r"[\w-]+\.(?:html?|css|js|jsx|ts|tsx|py|json|md|txt|csv|vue|svelte)\b",
608
+ re.IGNORECASE,
609
+ )
610
+ _PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
611
+
612
+
613
+ def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
614
+ """Infer a multi-file web-project manifest from a creation request.
615
+
616
+ "todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
617
+ inlined page. Deliberately narrow and deterministic (weak local models
618
+ never see this decision): requires a creation verb, an HTML-page intent,
619
+ at least one *additional* named technology (css/js), and no explicit
620
+ filename. Single-type requests return ``None`` so the existing
621
+ single-file flow is completely unchanged.
622
+ """
623
+ text = (message or "").strip()
624
+ if not text or not _CREATE_VERB_RE.search(text):
625
+ return None
626
+ if _EXPLICIT_FILENAME_RE.search(text):
627
+ return None
628
+ lower = text.lower()
629
+ wants_html = bool(_HTML_HINT_RE.search(lower))
630
+ wants_css = bool(_CSS_HINT_RE.search(lower))
631
+ wants_js = bool(_JS_HINT_RE.search(lower))
632
+ if not wants_html or not (wants_css or wants_js):
633
+ return None
634
+
635
+ name_match = _PROJECT_NAME_RE.search(text)
636
+ name = f"{name_match.group(1).lower()}-app" if name_match else "web-project"
637
+
638
+ files: List[Dict[str, str]] = []
639
+ html_refs: List[str] = []
640
+ if wants_css:
641
+ html_refs.append('<link rel="stylesheet" href="style.css"> in <head>')
642
+ if wants_js:
643
+ html_refs.append('<script src="app.js"></script> just before </body>')
644
+ files.append({
645
+ "path": "index.html",
646
+ "brief": (
647
+ "The main HTML page of the project. Reference the sibling files: "
648
+ + " and ".join(html_refs)
649
+ + ". Do not inline styles or behavior scripts."
650
+ ),
651
+ })
652
+ if wants_css:
653
+ files.append({
654
+ "path": "style.css",
655
+ "brief": "All visual styles for index.html (layout, colors, typography).",
656
+ })
657
+ if wants_js:
658
+ files.append({
659
+ "path": "app.js",
660
+ "brief": (
661
+ "All page behavior for index.html as plain browser JavaScript "
662
+ "(no build step, no imports of missing files)."
663
+ ),
664
+ })
665
+ return {"name": name, "kind": "web", "files": files}
666
+
667
+
668
+ _HTML_LOCAL_REF_RE = re.compile(
669
+ r"(?:href|src)\s*=\s*[\"']([^\"'#?]+)[\"']", re.IGNORECASE
670
+ )
671
+ _EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "javascript:")
672
+
673
+
674
+ def _local_bundle_refs(html: str) -> List[str]:
675
+ """File references inside an HTML document that must exist in the bundle."""
676
+ refs: List[str] = []
677
+ for ref in _HTML_LOCAL_REF_RE.findall(html or ""):
678
+ candidate = ref.strip()
679
+ if not candidate or candidate.startswith(_EXTERNAL_REF_PREFIXES):
680
+ continue
681
+ if "." not in candidate.rsplit("/", 1)[-1]:
682
+ continue # anchors / routes, not files
683
+ refs.append(candidate)
684
+ return refs
685
+
686
+
687
+ def repair_bundle_references(files: Dict[str, str]) -> Tuple[Dict[str, str], List[str]]:
688
+ """Deterministically point dangling HTML refs at real bundle files.
689
+
690
+ A weak model asked for ``style.css`` sometimes links ``styles.css``. When
691
+ a referenced file is missing but the bundle contains exactly one file of
692
+ the same extension, the reference is rewritten. Returns ``(files, fixes)``.
693
+ """
694
+ names = {p.rsplit("/", 1)[-1] for p in files}
695
+ fixes: List[str] = []
696
+ repaired = dict(files)
697
+ for path, content in files.items():
698
+ if _ext(path) not in (".html", ".htm"):
699
+ continue
700
+ updated = content
701
+ for ref in _local_bundle_refs(content):
702
+ base = ref.rsplit("/", 1)[-1]
703
+ if base in names:
704
+ continue
705
+ same_ext = [n for n in names if _ext(n) == _ext(base)]
706
+ if len(same_ext) == 1:
707
+ updated = updated.replace(ref, same_ext[0])
708
+ fixes.append(f"{path}: '{ref}' -> '{same_ext[0]}'")
709
+ if updated != content:
710
+ repaired[path] = updated
711
+ return repaired, fixes
712
+
713
+
714
+ def validate_project_bundle(files: Dict[str, str]) -> Dict[str, Any]:
715
+ """Bundle-level verification: every file valid, every HTML ref resolvable."""
716
+ issues: List[str] = []
717
+ per_file: Dict[str, Dict[str, Any]] = {}
718
+ names = {p.rsplit("/", 1)[-1] for p in files}
719
+ for path, content in files.items():
720
+ ok, reason = validate_file_content(content, path)
721
+ per_file[path] = {"valid": ok, "reason": reason}
722
+ if not ok:
723
+ issues.append(f"{path}: {reason}")
724
+ if _ext(path) in (".html", ".htm"):
725
+ for ref in _local_bundle_refs(content):
726
+ if ref.rsplit("/", 1)[-1] not in names:
727
+ issues.append(f"{path}: references missing file '{ref}'")
728
+ return {"ok": not issues, "issues": issues, "files": per_file}
729
+
730
+
404
731
  # ── orchestration ───────────────────────────────────────────────────────
405
732
 
406
733
  async def generate_file_content(
@@ -409,6 +736,7 @@ async def generate_file_content(
409
736
  target_path: str,
410
737
  user_request: str,
411
738
  max_attempts: int = 2,
739
+ bundle_files: Optional[List[str]] = None,
412
740
  ) -> Tuple[str, Dict[str, Any]]:
413
741
  """Generate validated file content with any LLM.
414
742
 
@@ -421,7 +749,9 @@ async def generate_file_content(
421
749
  feedback: Optional[str] = None
422
750
  last_candidate = ""
423
751
  for attempt in range(1, max_attempts + 1):
424
- context = build_file_generation_context(target_path, user_request, feedback=feedback)
752
+ context = build_file_generation_context(
753
+ target_path, user_request, feedback=feedback, bundle_files=bundle_files,
754
+ )
425
755
  try:
426
756
  raw = await generate(context)
427
757
  except Exception as exc: # model backend hiccup — repair still delivers
@@ -441,11 +771,16 @@ async def generate_file_content(
441
771
 
442
772
 
443
773
  __all__ = [
774
+ "PREVIEWABLE_EXTENSIONS",
444
775
  "build_file_generation_context",
445
776
  "extract_file_content",
446
777
  "generate_file_content",
447
778
  "infer_file_target",
779
+ "infer_project_manifest",
448
780
  "looks_like_refusal",
781
+ "repair_bundle_references",
449
782
  "repair_file_content",
783
+ "sanitize_write_content",
450
784
  "validate_file_content",
785
+ "validate_project_bundle",
451
786
  ]
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Any, Dict, List
15
15
 
16
16
 
17
- LEGACY_COMPATIBILITY_VERSION = "9.9.1"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.3"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -11,7 +11,7 @@ from copy import deepcopy
11
11
  from typing import Any, Dict, List, Optional
12
12
 
13
13
 
14
- MARKETPLACE_VERSION = "9.9.1"
14
+ MARKETPLACE_VERSION = "9.9.3"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17
 
@@ -49,7 +49,7 @@ __all__ = [
49
49
  "remove_skill_directory",
50
50
  ]
51
51
 
52
- WORKSPACE_OS_VERSION = "9.9.1"
52
+ WORKSPACE_OS_VERSION = "9.9.3"
53
53
 
54
54
  # Workspace types separate single-user Personal workspaces from shared
55
55
  # Organization workspaces. Both keep the same local-first JSON store; the type
@@ -38,6 +38,7 @@ def build_persistence_runtime(
38
38
  from latticeai.core.workspace_os import WorkspaceOSStore
39
39
  from latticeai.services.automation_intelligence import AutomationIntelligenceService
40
40
  from latticeai.services.brain_intelligence import BrainIntelligenceService
41
+ from latticeai.services.funnel_metrics import FunnelMetricsService
41
42
  from latticeai.services.memory_service import MemoryService
42
43
  from latticeai.services.workspace_service import WorkspaceService
43
44
 
@@ -70,11 +71,27 @@ def build_persistence_runtime(
70
71
  store=workspace_os,
71
72
  enable_graph=enable_graph,
72
73
  )
74
+ # UX funnel metrics (backlog #16): cheap JSON counters under the data dir.
75
+ # The ingestion pipeline's audit seam is wrapped so every successful
76
+ # ingest bumps the funnel (and starts the TTFV clock) without touching
77
+ # lattice_brain internals.
78
+ funnel_metrics = FunnelMetricsService(Path(data_dir) / "funnel_metrics.json")
79
+
80
+ def _funnel_audit(action, detail, user):
81
+ try:
82
+ if action == "kg_ingest":
83
+ funnel_metrics.record_ingest(
84
+ duplicate=bool((detail or {}).get("duplicate"))
85
+ )
86
+ except Exception: # noqa: BLE001 — metrics must never break ingestion
87
+ pass
88
+ audit(action, detail, user)
89
+
73
90
  ingestion_pipeline = IngestionPipeline(
74
91
  knowledge_graph,
75
92
  hooks=hooks_registry,
76
93
  enable_graph=enable_graph,
77
- audit=audit,
94
+ audit=_funnel_audit,
78
95
  )
79
96
  device_identity = DeviceIdentity(data_dir)
80
97
  kg_portability = KGPortabilityService(
@@ -99,4 +116,5 @@ def build_persistence_runtime(
99
116
  "INGESTION_PIPELINE": ingestion_pipeline,
100
117
  "DEVICE_IDENTITY": device_identity,
101
118
  "KG_PORTABILITY": kg_portability,
119
+ "FUNNEL_METRICS": funnel_metrics,
102
120
  }
@@ -605,6 +605,7 @@ def register_review_and_brain_tail_routers(
605
605
  create_setup_router: Any,
606
606
  model_router: Any,
607
607
  change_proposals: Any = None,
608
+ knowledge_graph: Any = None,
608
609
  ) -> Any:
609
610
  """Register the final review/browser/brain tail routes in legacy order."""
610
611
 
@@ -644,6 +645,12 @@ def register_review_and_brain_tail_routers(
644
645
  require_admin=require_admin,
645
646
  ),
646
647
  create_garden_router(gardener=gardener, require_user=require_user),
647
- create_setup_router(model_router=model_router, require_user=require_user),
648
+ create_setup_router(
649
+ model_router=model_router,
650
+ require_user=require_user,
651
+ ingestion_pipeline=ingestion_pipeline,
652
+ knowledge_graph=knowledge_graph,
653
+ workspace_service=workspace_service,
654
+ ),
648
655
  )
649
656
  return brain_network