ltcai 9.9.2 → 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 (96) hide show
  1. package/README.md +39 -39
  2. package/docs/CHANGELOG.md +44 -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 +27 -2
  22. package/latticeai/api/chat_agent_http.py +191 -3
  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 +189 -1
  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 +14 -0
  33. package/latticeai/core/agent.py +91 -9
  34. package/latticeai/core/agent_eval.py +107 -0
  35. package/latticeai/core/file_generation.py +279 -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 +3 -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 +1 -1
  49. package/latticeai/services/search_service.py +17 -1
  50. package/latticeai/setup/demo_corpus.py +98 -0
  51. package/latticeai/tools/filesystem.py +38 -1
  52. package/package.json +1 -1
  53. package/scripts/bench_models.py +234 -5
  54. package/scripts/check_current_release_docs.mjs +1 -1
  55. package/src-tauri/Cargo.lock +1 -1
  56. package/src-tauri/Cargo.toml +1 -1
  57. package/src-tauri/tauri.conf.json +1 -1
  58. package/static/app/asset-manifest.json +28 -27
  59. package/static/app/assets/Act-DmdruVKV.js +1 -0
  60. package/static/app/assets/{Brain-BnGbytHa.js → Brain-DEY9jLVt.js} +2 -2
  61. package/static/app/assets/BrainHome-CeNaxjP1.js +2 -0
  62. package/static/app/assets/BrainSignals-CStjIqYi.js +1 -0
  63. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-CVZ09QXi.js} +1 -1
  64. package/static/app/assets/CommandPalette-DepwOQFv.js +1 -0
  65. package/static/app/assets/Library-Bp0n-HlW.js +1 -0
  66. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-DxP4efJF.js} +1 -1
  67. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-DRbm7NEq.js} +1 -1
  68. package/static/app/assets/ReviewCard-C4HAO7A3.js +3 -0
  69. package/static/app/assets/{System-DRHc_6Su.js → System-ByQcmJW-.js} +1 -1
  70. package/static/app/assets/{bot-DZkDdh-A.js → bot-BNDyZLR7.js} +1 -1
  71. package/static/app/assets/circle-play-BkhdcHgd.js +1 -0
  72. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-C6jjYm6i.js} +1 -1
  73. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-DjGIvDBQ.js} +1 -1
  74. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-BlSbwSaT.js} +1 -1
  75. package/static/app/assets/index-Bge3DXW7.css +2 -0
  76. package/static/app/assets/{index-BS_3aN1n.js → index-CHu7cgj3.js} +3 -3
  77. package/static/app/assets/{input-B_U7zvbR.js → input-DVDI0YR3.js} +1 -1
  78. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-BddhEWA0.js} +1 -1
  79. package/static/app/assets/{network-BF13HJJF.js → network-pYQt5oBu.js} +1 -1
  80. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-D7gCdEvS.js} +1 -1
  81. package/static/app/assets/shield-alert-K9RKGQeg.js +1 -0
  82. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-sqQmoBKL.js} +1 -1
  83. package/static/app/assets/useFocusTrap-7EV9dFP2.js +1 -0
  84. package/static/app/assets/utils-uQYKXNeq.js +7 -0
  85. package/static/app/index.html +4 -4
  86. package/static/sw.js +1 -1
  87. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  88. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  89. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  90. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  91. package/static/app/assets/Library-PcfVUco6.js +0 -1
  92. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  93. package/static/app/assets/download-C7yxYHvz.js +0 -1
  94. package/static/app/assets/index-DU78Kogz.css +0 -2
  95. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  96. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -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():
@@ -211,10 +284,28 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
211
284
  return False, f"invalid JSON: {exc}"
212
285
  return True, "ok"
213
286
  if ext == ".css":
287
+ if "```" in content:
288
+ return False, "output still contains Markdown fences"
214
289
  if "{" not in content or "}" not in content:
215
290
  return False, "no CSS rule blocks found"
216
291
  return True, "ok"
217
- 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"):
218
309
  if "```" in content:
219
310
  return False, "output still contains Markdown fences"
220
311
  return True, "ok"
@@ -248,13 +339,31 @@ _TYPE_RULES: Dict[str, str] = {
248
339
  ".csv": "Produce CSV with a header row; comma-separated, one record per line.",
249
340
  ".py": "Produce complete runnable Python source code.",
250
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.",
251
347
  }
252
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
+
253
361
 
254
362
  def build_file_generation_context(
255
363
  target_path: str,
256
364
  user_request: str,
257
365
  feedback: Optional[str] = None,
366
+ bundle_files: Optional[List[str]] = None,
258
367
  ) -> str:
259
368
  """Strict, extension-aware generation instructions.
260
369
 
@@ -271,8 +380,13 @@ def build_file_generation_context(
271
380
  "no text before or after the content.",
272
381
  ]
273
382
  type_rule = _TYPE_RULES.get(ext)
383
+ if bundle_files and ext in (".html", ".htm"):
384
+ type_rule = _BUNDLE_HTML_RULE
274
385
  if type_rule:
275
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}")
276
390
  first_line = _FIRST_LINE_HINTS.get(ext)
277
391
  if first_line:
278
392
  parts.append(f"- The very first line of your reply must be: {first_line}")
@@ -309,12 +423,29 @@ def repair_file_content(content: str, target_path: str, user_request: str) -> st
309
423
  ensure_ascii=False,
310
424
  indent=2,
311
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
+ )
312
440
  if salvage:
313
441
  return salvage
314
442
  # Nothing usable at all — leave an honest placeholder in the right format.
315
443
  comment = {
316
444
  ".py": "# TODO: model produced no usable content for: ",
317
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: ",
318
449
  ".css": "/* TODO: model produced no usable content for: ",
319
450
  ".sh": "# TODO: model produced no usable content for: ",
320
451
  ".sql": "-- TODO: model produced no usable content for: ",
@@ -371,6 +502,7 @@ def _repair_html(salvage: str, user_request: str) -> str:
371
502
  PREVIEWABLE_EXTENSIONS = frozenset({
372
503
  ".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
373
504
  ".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
505
+ ".jsx", ".ts", ".tsx", ".vue", ".svelte",
374
506
  })
375
507
 
376
508
 
@@ -457,6 +589,145 @@ def infer_file_target(message: str) -> Optional[str]:
457
589
  return None
458
590
 
459
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
+
460
731
  # ── orchestration ───────────────────────────────────────────────────────
461
732
 
462
733
  async def generate_file_content(
@@ -465,6 +736,7 @@ async def generate_file_content(
465
736
  target_path: str,
466
737
  user_request: str,
467
738
  max_attempts: int = 2,
739
+ bundle_files: Optional[List[str]] = None,
468
740
  ) -> Tuple[str, Dict[str, Any]]:
469
741
  """Generate validated file content with any LLM.
470
742
 
@@ -477,7 +749,9 @@ async def generate_file_content(
477
749
  feedback: Optional[str] = None
478
750
  last_candidate = ""
479
751
  for attempt in range(1, max_attempts + 1):
480
- 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
+ )
481
755
  try:
482
756
  raw = await generate(context)
483
757
  except Exception as exc: # model backend hiccup — repair still delivers
@@ -502,8 +776,11 @@ __all__ = [
502
776
  "extract_file_content",
503
777
  "generate_file_content",
504
778
  "infer_file_target",
779
+ "infer_project_manifest",
505
780
  "looks_like_refusal",
781
+ "repair_bundle_references",
506
782
  "repair_file_content",
507
783
  "sanitize_write_content",
508
784
  "validate_file_content",
785
+ "validate_project_bundle",
509
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.2"
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.2"
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.2"
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
@@ -42,6 +42,9 @@ class AppContext:
42
42
  hooks: Any = None
43
43
  realtime_bus: Any = None
44
44
  capability_registry: Any = None
45
+ # UX funnel metrics (latticeai.services.funnel_metrics); None in tests
46
+ # that don't observe the funnel — every increment site is nil-safe.
47
+ funnel_metrics: Any = None
45
48
 
46
49
  # ── auth / session callables ──────────────────────────────────────────
47
50
  require_user: Optional[Callable[..., str]] = None
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.2"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.3"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",