ltcai 9.9.2 → 9.9.4

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 (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -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/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.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)} != "
@@ -52,6 +52,11 @@ class EnterpriseCapability(str, Enum):
52
52
  DLP_POLICY = "dlp_policy"
53
53
  EDISCOVERY = "ediscovery"
54
54
  ADMIN_POLICY_PACKS = "admin_policy_packs"
55
+ # Human review-before-promote governance for knowledge-graph concept
56
+ # promotions (review 2026-07-25 Wave 4). Community users can opt in per
57
+ # deployment via LATTICEAI_GRAPH_PROMOTION_REVIEW; this capability is the
58
+ # seam for Enterprise policy packs that mandate reviewed promotions.
59
+ GRAPH_PROMOTION_REVIEW = "graph_promotion_review"
55
60
 
56
61
 
57
62
  @runtime_checkable
@@ -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,48 @@ _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
+
361
+ # Vite/React bundles need a module entry point, not classic script tags.
362
+ _BUNDLE_HTML_MODULE_RULE = (
363
+ "Produce ONE complete HTML5 document: <!DOCTYPE html>, <html>, <head> with "
364
+ "<meta charset=\"utf-8\"> and a <title>, and a closed </html> tag. "
365
+ "This page is the Vite entry of a React project: the <body> must contain "
366
+ "<div id=\"root\"></div> and load the app with "
367
+ "<script type=\"module\" src=\"/src/main.jsx\"></script> just before "
368
+ "</body>. No inline <style> blocks, no other scripts, no external files."
369
+ )
370
+
371
+
372
+ def _bundle_html_rule(bundle_files: List[str]) -> str:
373
+ """Pick the HTML bundle rule that matches the bundle's technology."""
374
+ if any(str(path).lower().endswith((".jsx", ".tsx")) for path in bundle_files):
375
+ return _BUNDLE_HTML_MODULE_RULE
376
+ return _BUNDLE_HTML_RULE
377
+
253
378
 
254
379
  def build_file_generation_context(
255
380
  target_path: str,
256
381
  user_request: str,
257
382
  feedback: Optional[str] = None,
383
+ bundle_files: Optional[List[str]] = None,
258
384
  ) -> str:
259
385
  """Strict, extension-aware generation instructions.
260
386
 
@@ -271,8 +397,13 @@ def build_file_generation_context(
271
397
  "no text before or after the content.",
272
398
  ]
273
399
  type_rule = _TYPE_RULES.get(ext)
400
+ if bundle_files and ext in (".html", ".htm"):
401
+ type_rule = _bundle_html_rule(bundle_files)
274
402
  if type_rule:
275
403
  parts.append(f"- {type_rule}")
404
+ if bundle_files:
405
+ listed = ", ".join(bundle_files)
406
+ parts.append(f"- Project files in this bundle: {listed}")
276
407
  first_line = _FIRST_LINE_HINTS.get(ext)
277
408
  if first_line:
278
409
  parts.append(f"- The very first line of your reply must be: {first_line}")
@@ -309,12 +440,29 @@ def repair_file_content(content: str, target_path: str, user_request: str) -> st
309
440
  ensure_ascii=False,
310
441
  indent=2,
311
442
  )
443
+ if ext == ".py" and salvage:
444
+ # The repair guarantee for Python is parseability: unparseable output
445
+ # is preserved honestly as a commented-out draft, never as a broken
446
+ # module the user has to debug.
447
+ try:
448
+ ast.parse(salvage)
449
+ return salvage
450
+ except SyntaxError:
451
+ commented = "\n".join(f"# {line}" for line in salvage.splitlines())
452
+ return (
453
+ f"# TODO: model produced invalid Python for: {user_request}\n"
454
+ "# The draft below is preserved as comments — fix and uncomment.\n"
455
+ f"{commented}\n"
456
+ )
312
457
  if salvage:
313
458
  return salvage
314
459
  # Nothing usable at all — leave an honest placeholder in the right format.
315
460
  comment = {
316
461
  ".py": "# TODO: model produced no usable content for: ",
317
462
  ".js": "// TODO: model produced no usable content for: ",
463
+ ".jsx": "// TODO: model produced no usable content for: ",
464
+ ".ts": "// TODO: model produced no usable content for: ",
465
+ ".tsx": "// TODO: model produced no usable content for: ",
318
466
  ".css": "/* TODO: model produced no usable content for: ",
319
467
  ".sh": "# TODO: model produced no usable content for: ",
320
468
  ".sql": "-- TODO: model produced no usable content for: ",
@@ -371,6 +519,7 @@ def _repair_html(salvage: str, user_request: str) -> str:
371
519
  PREVIEWABLE_EXTENSIONS = frozenset({
372
520
  ".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
373
521
  ".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
522
+ ".jsx", ".ts", ".tsx", ".vue", ".svelte",
374
523
  })
375
524
 
376
525
 
@@ -457,6 +606,253 @@ def infer_file_target(message: str) -> Optional[str]:
457
606
  return None
458
607
 
459
608
 
609
+ # ── project manifest (multi-file bundles) ───────────────────────────────
610
+
611
+ # ``\b`` fails against Korean particles ("js로") because Hangul is ``\w`` —
612
+ # use ASCII lookarounds so type keywords match with or without a particle.
613
+ _HTML_HINT_RE = re.compile(
614
+ r"(?<![a-z0-9])html(?![a-z0-9])"
615
+ r"|웹\s*페이지|웹페이지|홈페이지|웹\s*사이트|웹사이트|website|web\s*page|landing\s*page",
616
+ )
617
+ _CSS_HINT_RE = re.compile(r"(?<![a-z0-9])css(?![a-z0-9])|스타일\s*시트|stylesheet")
618
+ _JS_HINT_RE = re.compile(
619
+ r"(?<![a-z0-9])js(?![a-z0-9])|javascript|자바스크립트|자바\s*스크립트"
620
+ )
621
+ # An explicit filename means the user is managing paths — keep the
622
+ # deterministic single-file flow untouched.
623
+ _EXPLICIT_FILENAME_RE = re.compile(
624
+ r"[\w-]+\.(?:html?|css|js|jsx|ts|tsx|py|json|md|txt|csv|vue|svelte)\b",
625
+ re.IGNORECASE,
626
+ )
627
+ _PROJECT_NAME_RE = re.compile(r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:앱|app\b)", re.IGNORECASE)
628
+ # React/Vite intent: the react keyword is specific enough on its own.
629
+ _REACT_HINT_RE = re.compile(r"(?<![a-z0-9])react(?![a-z0-9])|리액트")
630
+ _VITE_HINT_RE = re.compile(r"(?<![a-z0-9])vite(?![a-z0-9])")
631
+ # Python package intent: language + package word, both required.
632
+ _PYTHON_HINT_RE = re.compile(r"(?<![a-z0-9])python(?![a-z0-9])|파이썬")
633
+ _PACKAGE_HINT_RE = re.compile(r"패키지|(?<![a-z0-9])package(?![a-z0-9])")
634
+ _PKG_NAME_RE = re.compile(
635
+ r"([A-Za-z][A-Za-z0-9_-]{1,30})\s*(?:패키지|package\b)", re.IGNORECASE
636
+ )
637
+
638
+
639
+ def _react_manifest(text: str) -> Dict[str, Any]:
640
+ """Vite + React starter manifest (review Wave 4: manifest 확장)."""
641
+ name_match = _PROJECT_NAME_RE.search(text)
642
+ name = f"{name_match.group(1).lower()}-app" if name_match else "react-app"
643
+ return {
644
+ "name": name,
645
+ "kind": "react",
646
+ "files": [
647
+ {
648
+ "path": "package.json",
649
+ "brief": (
650
+ f'Vite React app manifest: strictly valid JSON with "name": "{name}", '
651
+ '"private": true, "type": "module", "scripts" {"dev": "vite", '
652
+ '"build": "vite build", "preview": "vite preview"}, "dependencies" '
653
+ 'with react and react-dom (^18), and "devDependencies" with vite '
654
+ "and @vitejs/plugin-react."
655
+ ),
656
+ },
657
+ {
658
+ "path": "index.html",
659
+ "brief": (
660
+ "The Vite entry HTML: <div id=\"root\"></div> in <body> and "
661
+ "<script type=\"module\" src=\"/src/main.jsx\"></script> just "
662
+ "before </body>. No inline styles or scripts."
663
+ ),
664
+ },
665
+ {
666
+ "path": "src/main.jsx",
667
+ "brief": (
668
+ "React entry: createRoot from react-dom/client rendering <App /> "
669
+ "into #root; imports ./App.jsx and ./App.css."
670
+ ),
671
+ },
672
+ {
673
+ "path": "src/App.jsx",
674
+ "brief": (
675
+ "The main App component implementing the user's request as one "
676
+ "self-contained React component (hooks allowed, no extra deps)."
677
+ ),
678
+ },
679
+ {
680
+ "path": "src/App.css",
681
+ "brief": "All visual styles for the App component.",
682
+ },
683
+ ],
684
+ }
685
+
686
+
687
+ def _python_package_manifest(text: str) -> Dict[str, Any]:
688
+ """Multi-file Python package manifest (review Wave 4: manifest 확장)."""
689
+ name_match = _PKG_NAME_RE.search(text)
690
+ raw_name = name_match.group(1).lower() if name_match else "my_package"
691
+ module = re.sub(r"[^a-z0-9_]", "_", raw_name)
692
+ if not re.match(r"[a-z_]", module):
693
+ module = f"pkg_{module}"
694
+ return {
695
+ "name": module,
696
+ "kind": "python",
697
+ "files": [
698
+ {
699
+ "path": f"{module}/__init__.py",
700
+ "brief": (
701
+ f"Package init for {module}: import and re-export the public "
702
+ "API from .core with an explicit __all__."
703
+ ),
704
+ },
705
+ {
706
+ "path": f"{module}/core.py",
707
+ "brief": (
708
+ "Implement the user's request as clean, documented functions/"
709
+ "classes with type hints. Standard library only."
710
+ ),
711
+ },
712
+ {
713
+ "path": f"{module}/cli.py",
714
+ "brief": (
715
+ "argparse CLI wrapping the core API: a main() function and an "
716
+ 'if __name__ == "__main__": main() guard.'
717
+ ),
718
+ },
719
+ {
720
+ "path": "README.md",
721
+ "brief": (
722
+ f"Usage documentation for the {module} package: install, import "
723
+ "example, and CLI example."
724
+ ),
725
+ },
726
+ ],
727
+ }
728
+
729
+
730
+ def infer_project_manifest(message: str) -> Optional[Dict[str, Any]]:
731
+ """Infer a multi-file project manifest from a creation request.
732
+
733
+ "todo 앱 html+css+js로 만들어줘" should yield real linked files, not one
734
+ inlined page. Deliberately narrow and deterministic (weak local models
735
+ never see this decision): requires a creation verb, a recognized project
736
+ intent (web page + css/js, React/Vite app, or Python package), and no
737
+ explicit filename. Single-type requests return ``None`` so the existing
738
+ single-file flow is completely unchanged.
739
+ """
740
+ text = (message or "").strip()
741
+ if not text or not _CREATE_VERB_RE.search(text):
742
+ return None
743
+ if _EXPLICIT_FILENAME_RE.search(text):
744
+ return None
745
+ lower = text.lower()
746
+
747
+ # Most-specific first: React (its own structure), then Python package,
748
+ # then the classic html+css/js web bundle.
749
+ if _REACT_HINT_RE.search(lower) or _VITE_HINT_RE.search(lower):
750
+ return _react_manifest(text)
751
+ if _PYTHON_HINT_RE.search(lower) and _PACKAGE_HINT_RE.search(lower):
752
+ return _python_package_manifest(text)
753
+
754
+ wants_html = bool(_HTML_HINT_RE.search(lower))
755
+ wants_css = bool(_CSS_HINT_RE.search(lower))
756
+ wants_js = bool(_JS_HINT_RE.search(lower))
757
+ if not wants_html or not (wants_css or wants_js):
758
+ return None
759
+
760
+ name_match = _PROJECT_NAME_RE.search(text)
761
+ name = f"{name_match.group(1).lower()}-app" if name_match else "web-project"
762
+
763
+ files: List[Dict[str, str]] = []
764
+ html_refs: List[str] = []
765
+ if wants_css:
766
+ html_refs.append('<link rel="stylesheet" href="style.css"> in <head>')
767
+ if wants_js:
768
+ html_refs.append('<script src="app.js"></script> just before </body>')
769
+ files.append({
770
+ "path": "index.html",
771
+ "brief": (
772
+ "The main HTML page of the project. Reference the sibling files: "
773
+ + " and ".join(html_refs)
774
+ + ". Do not inline styles or behavior scripts."
775
+ ),
776
+ })
777
+ if wants_css:
778
+ files.append({
779
+ "path": "style.css",
780
+ "brief": "All visual styles for index.html (layout, colors, typography).",
781
+ })
782
+ if wants_js:
783
+ files.append({
784
+ "path": "app.js",
785
+ "brief": (
786
+ "All page behavior for index.html as plain browser JavaScript "
787
+ "(no build step, no imports of missing files)."
788
+ ),
789
+ })
790
+ return {"name": name, "kind": "web", "files": files}
791
+
792
+
793
+ _HTML_LOCAL_REF_RE = re.compile(
794
+ r"(?:href|src)\s*=\s*[\"']([^\"'#?]+)[\"']", re.IGNORECASE
795
+ )
796
+ _EXTERNAL_REF_PREFIXES = ("http://", "https://", "//", "data:", "mailto:", "tel:", "javascript:")
797
+
798
+
799
+ def _local_bundle_refs(html: str) -> List[str]:
800
+ """File references inside an HTML document that must exist in the bundle."""
801
+ refs: List[str] = []
802
+ for ref in _HTML_LOCAL_REF_RE.findall(html or ""):
803
+ candidate = ref.strip()
804
+ if not candidate or candidate.startswith(_EXTERNAL_REF_PREFIXES):
805
+ continue
806
+ if "." not in candidate.rsplit("/", 1)[-1]:
807
+ continue # anchors / routes, not files
808
+ refs.append(candidate)
809
+ return refs
810
+
811
+
812
+ def repair_bundle_references(files: Dict[str, str]) -> Tuple[Dict[str, str], List[str]]:
813
+ """Deterministically point dangling HTML refs at real bundle files.
814
+
815
+ A weak model asked for ``style.css`` sometimes links ``styles.css``. When
816
+ a referenced file is missing but the bundle contains exactly one file of
817
+ the same extension, the reference is rewritten. Returns ``(files, fixes)``.
818
+ """
819
+ names = {p.rsplit("/", 1)[-1] for p in files}
820
+ fixes: List[str] = []
821
+ repaired = dict(files)
822
+ for path, content in files.items():
823
+ if _ext(path) not in (".html", ".htm"):
824
+ continue
825
+ updated = content
826
+ for ref in _local_bundle_refs(content):
827
+ base = ref.rsplit("/", 1)[-1]
828
+ if base in names:
829
+ continue
830
+ same_ext = [n for n in names if _ext(n) == _ext(base)]
831
+ if len(same_ext) == 1:
832
+ updated = updated.replace(ref, same_ext[0])
833
+ fixes.append(f"{path}: '{ref}' -> '{same_ext[0]}'")
834
+ if updated != content:
835
+ repaired[path] = updated
836
+ return repaired, fixes
837
+
838
+
839
+ def validate_project_bundle(files: Dict[str, str]) -> Dict[str, Any]:
840
+ """Bundle-level verification: every file valid, every HTML ref resolvable."""
841
+ issues: List[str] = []
842
+ per_file: Dict[str, Dict[str, Any]] = {}
843
+ names = {p.rsplit("/", 1)[-1] for p in files}
844
+ for path, content in files.items():
845
+ ok, reason = validate_file_content(content, path)
846
+ per_file[path] = {"valid": ok, "reason": reason}
847
+ if not ok:
848
+ issues.append(f"{path}: {reason}")
849
+ if _ext(path) in (".html", ".htm"):
850
+ for ref in _local_bundle_refs(content):
851
+ if ref.rsplit("/", 1)[-1] not in names:
852
+ issues.append(f"{path}: references missing file '{ref}'")
853
+ return {"ok": not issues, "issues": issues, "files": per_file}
854
+
855
+
460
856
  # ── orchestration ───────────────────────────────────────────────────────
461
857
 
462
858
  async def generate_file_content(
@@ -465,6 +861,7 @@ async def generate_file_content(
465
861
  target_path: str,
466
862
  user_request: str,
467
863
  max_attempts: int = 2,
864
+ bundle_files: Optional[List[str]] = None,
468
865
  ) -> Tuple[str, Dict[str, Any]]:
469
866
  """Generate validated file content with any LLM.
470
867
 
@@ -477,7 +874,9 @@ async def generate_file_content(
477
874
  feedback: Optional[str] = None
478
875
  last_candidate = ""
479
876
  for attempt in range(1, max_attempts + 1):
480
- context = build_file_generation_context(target_path, user_request, feedback=feedback)
877
+ context = build_file_generation_context(
878
+ target_path, user_request, feedback=feedback, bundle_files=bundle_files,
879
+ )
481
880
  try:
482
881
  raw = await generate(context)
483
882
  except Exception as exc: # model backend hiccup — repair still delivers
@@ -502,8 +901,11 @@ __all__ = [
502
901
  "extract_file_content",
503
902
  "generate_file_content",
504
903
  "infer_file_target",
904
+ "infer_project_manifest",
505
905
  "looks_like_refusal",
906
+ "repair_bundle_references",
506
907
  "repair_file_content",
507
908
  "sanitize_write_content",
508
909
  "validate_file_content",
910
+ "validate_project_bundle",
509
911
  ]
@@ -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.4"
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.4"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17