ltcai 9.9.0 → 9.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +41 -35
  2. package/bin/ltcai.js +1 -1
  3. package/desktop/electron/main.cjs +1 -1
  4. package/docs/CHANGELOG.md +83 -0
  5. package/docs/CI_AND_RELEASE_GATES.md +1 -1
  6. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  7. package/docs/DEVELOPMENT.md +1 -1
  8. package/docs/LEGACY_COMPATIBILITY.md +48 -49
  9. package/docs/ONBOARDING.md +1 -1
  10. package/docs/OPERATIONS.md +1 -1
  11. package/docs/TRUST_MODEL.md +1 -1
  12. package/docs/WHY_LATTICE.md +1 -1
  13. package/docs/kg-schema.md +6 -6
  14. package/lattice_brain/__init__.py +1 -1
  15. package/lattice_brain/graph/schema.py +1 -1
  16. package/lattice_brain/runtime/multi_agent.py +1 -1
  17. package/latticeai/__init__.py +1 -1
  18. package/latticeai/api/chat.py +3 -0
  19. package/latticeai/api/chat_agent_http.py +2 -1
  20. package/latticeai/api/chat_intents.py +130 -7
  21. package/latticeai/app_factory.py +1 -0
  22. package/latticeai/core/agent.py +114 -2
  23. package/latticeai/core/file_generation.py +58 -0
  24. package/latticeai/core/legacy_compatibility.py +71 -47
  25. package/latticeai/core/marketplace.py +1 -1
  26. package/latticeai/core/workspace_os.py +1 -1
  27. package/latticeai/services/app_context.py +3 -0
  28. package/latticeai/services/architecture_readiness.py +1 -1
  29. package/latticeai/services/product_readiness.py +7 -2
  30. package/latticeai/services/tool_dispatch.py +36 -0
  31. package/latticeai/tools/__init__.py +1 -1
  32. package/latticeai/tools/knowledge.py +1 -1
  33. package/package.json +6 -16
  34. package/scripts/check_current_release_docs.mjs +20 -1
  35. package/scripts/check_legacy_debt.mjs +91 -0
  36. package/scripts/prune_release_evidence.mjs +38 -0
  37. package/scripts/wheel_smoke.py +22 -6
  38. package/src-tauri/Cargo.lock +1 -1
  39. package/src-tauri/Cargo.toml +1 -1
  40. package/src-tauri/src/main.rs +12 -12
  41. package/src-tauri/tauri.conf.json +1 -13
  42. package/static/app/asset-manifest.json +27 -27
  43. package/static/app/assets/Act-Cx_gZNbE.js +1 -0
  44. package/static/app/assets/{Brain-Cgkh0Hpn.js → Brain-BnGbytHa.js} +2 -2
  45. package/static/app/assets/BrainHome-CRUMJL93.js +2 -0
  46. package/static/app/assets/BrainSignals-BP8-Qh2g.js +1 -0
  47. package/static/app/assets/Capture-CAKnLcb0.js +1 -0
  48. package/static/app/assets/CommandPalette-BoUTXBfm.js +1 -0
  49. package/static/app/assets/Library-PcfVUco6.js +1 -0
  50. package/static/app/assets/LivingBrain-C3VbogVo.js +1 -0
  51. package/static/app/assets/ProductFlow-BLau7c7N.js +1 -0
  52. package/static/app/assets/ReviewCard-CX5RqRqx.js +1 -0
  53. package/static/app/assets/System-DRHc_6Su.js +1 -0
  54. package/static/app/assets/bot-DZkDdh-A.js +1 -0
  55. package/static/app/assets/cpu-DyvsDBbj.js +1 -0
  56. package/static/app/assets/download-C7yxYHvz.js +1 -0
  57. package/static/app/assets/folder-open-CffoIr9t.js +1 -0
  58. package/static/app/assets/hard-drive-C1oojrHb.js +1 -0
  59. package/static/app/assets/{index-BjaXCeOZ.js → index-BS_3aN1n.js} +3 -3
  60. package/static/app/assets/index-DU78Kogz.css +2 -0
  61. package/static/app/assets/input-B_U7zvbR.js +1 -0
  62. package/static/app/assets/navigation-xG-gyjbe.js +1 -0
  63. package/static/app/assets/network-BF13HJJF.js +1 -0
  64. package/static/app/assets/primitives-DsuKh3M8.js +1 -0
  65. package/static/app/assets/sunrise-CIP2ELJ1.js +1 -0
  66. package/static/app/assets/textarea-DsDL4ET8.js +1 -0
  67. package/static/app/assets/utils-Dh-Dwtkb.js +7 -0
  68. package/static/app/index.html +4 -4
  69. package/static/sw.js +1 -1
  70. package/auto_setup.py +0 -21
  71. package/kg_schema.py +0 -13
  72. package/knowledge_graph.py +0 -51
  73. package/knowledge_graph_api.py +0 -24
  74. package/llm_router.py +0 -20
  75. package/local_knowledge_api.py +0 -16
  76. package/ltcai_cli.py +0 -21
  77. package/mcp_registry.py +0 -20
  78. package/p_reinforce.py +0 -16
  79. package/setup_wizard.py +0 -16
  80. package/static/app/assets/Act-CfOPUKPs.js +0 -2
  81. package/static/app/assets/BrainHome-D8e3wQiW.js +0 -3
  82. package/static/app/assets/BrainSignals-BjRBA18L.js +0 -1
  83. package/static/app/assets/Capture-CQQYQ1Ga.js +0 -1
  84. package/static/app/assets/CommandPalette-pss56Mg4.js +0 -1
  85. package/static/app/assets/Library-CoI3xYJk.js +0 -1
  86. package/static/app/assets/LivingBrain-CaU_hCxQ.js +0 -1
  87. package/static/app/assets/ProductFlow-Db-1O71-.js +0 -1
  88. package/static/app/assets/System-Dfocn0zN.js +0 -1
  89. package/static/app/assets/bot-Bp2x1i6F.js +0 -1
  90. package/static/app/assets/circle-check-CyGal42W.js +0 -1
  91. package/static/app/assets/cpu-DQFk82hG.js +0 -1
  92. package/static/app/assets/download-C70gKQVi.js +0 -1
  93. package/static/app/assets/folder-open-DNGytsVw.js +0 -1
  94. package/static/app/assets/hard-drive-CYo_bEl4.js +0 -1
  95. package/static/app/assets/i18n-BBMJshCW.js +0 -7
  96. package/static/app/assets/index-BqRcLZR3.css +0 -2
  97. package/static/app/assets/input-ChWD-Fsh.js +0 -1
  98. package/static/app/assets/navigation-D9D9_FPC.js +0 -1
  99. package/static/app/assets/network-DzLDGmtV.js +0 -1
  100. package/static/app/assets/primitives-CmaSAyGG.js +0 -1
  101. package/static/app/assets/sparkles-DXiaM1NS.js +0 -1
  102. package/static/app/assets/textarea-CV43-o79.js +0 -1
  103. package/telegram_bot.py +0 -16
  104. package/tools/__init__.py +0 -29
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import os
7
+ import re
6
8
  from pathlib import Path
7
- from typing import Any, Dict, Optional
9
+ from typing import Any, Dict, List, Optional
8
10
 
9
11
  from fastapi import HTTPException, Request
10
12
  from fastapi.responses import JSONResponse, StreamingResponse
@@ -17,7 +19,43 @@ from latticeai.api.chat_helpers import (
17
19
  )
18
20
  from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
19
21
  from latticeai.core.agent import AgentState
20
- from latticeai.core.file_generation import generate_file_content, infer_file_target
22
+ from latticeai.core.file_generation import (
23
+ PREVIEWABLE_EXTENSIONS,
24
+ generate_file_content,
25
+ infer_file_target,
26
+ )
27
+
28
+
29
+ def next_available_path(root: Path, target: str) -> str:
30
+ """Return ``target`` unchanged, or a ``name_2.ext``-style variant when the
31
+ file already exists in the workspace.
32
+
33
+ The direct chat write path must never silently overwrite an existing file:
34
+ overwrites belong to the reviewable-proposal flow. Deterministic suffixing
35
+ keeps "make me an html page" repeatable without a 409 dead end.
36
+ """
37
+ candidate = (root / target)
38
+ if not candidate.exists():
39
+ return target
40
+ stem, suffix = candidate.stem, candidate.suffix
41
+ # An earlier auto-suffix run may have produced name_2 already; restart the
42
+ # numbering from the base name so we fill the first free slot.
43
+ base = re.sub(r"_\d+$", "", stem) or stem
44
+ for index in range(2, 100):
45
+ variant = candidate.with_name(f"{base}_{index}{suffix}")
46
+ if not variant.exists():
47
+ return str(Path(target).with_name(variant.name))
48
+ raise HTTPException(
49
+ status_code=409,
50
+ detail=f"'{target}' 이름의 파일이 너무 많습니다. 다른 이름을 지정해 주세요.",
51
+ )
52
+
53
+
54
+ def ingest_generated_enabled() -> bool:
55
+ """Feature toggle for auto-indexing generated files into the Brain."""
56
+ return os.environ.get("LATTICEAI_INGEST_GENERATED", "1").strip().lower() not in {
57
+ "0", "false", "off", "no",
58
+ }
21
59
 
22
60
 
23
61
  class ChatIntentController:
@@ -41,6 +79,7 @@ class ChatIntentController:
41
79
  execute_tool: Any,
42
80
  agent_controller: Any,
43
81
  agent_root: Path,
82
+ ingestion_pipeline: Any = None,
44
83
  ) -> None:
45
84
  self.router = model_router
46
85
  self.config = config
@@ -59,6 +98,7 @@ class ChatIntentController:
59
98
  self.execute_tool = execute_tool
60
99
  self.agent_controller = agent_controller
61
100
  self.agent_root = Path(agent_root)
101
+ self.ingestion_pipeline = ingestion_pipeline
62
102
 
63
103
  def no_model_response(self) -> JSONResponse:
64
104
  detail = "No model loaded. Call /models/load first."
@@ -184,13 +224,25 @@ class ChatIntentController:
184
224
  )
185
225
  return single_answer_response(req, answer, model="client_url")
186
226
 
187
- async def direct_file_action(self, req: Any, *, model_id: Optional[str]):
227
+ async def direct_file_action(
228
+ self,
229
+ req: Any,
230
+ *,
231
+ model_id: Optional[str],
232
+ effective_email: Optional[str] = None,
233
+ workspace_id: Optional[str] = None,
234
+ ):
188
235
  # An explicit path ("report.txt") wins; otherwise infer one from an
189
236
  # explicit type keyword ("html 파일 만들어줘") so weak models never
190
237
  # have to drive the JSON tool loop just to create a file.
191
238
  target_path = file_action_target(req.message) or infer_file_target(req.message)
192
239
  if not target_path:
193
240
  return None
241
+ # Never silently overwrite: an existing target gets a _2/_3 suffix so
242
+ # regeneration is safe and repeatable without the proposal flow.
243
+ deduped_path = next_available_path(self.agent_root, target_path)
244
+ renamed = deduped_path != target_path
245
+ target_path = deduped_path
194
246
  content = inline_file_action_content(req.message)
195
247
  generation_meta: Optional[Dict[str, Any]] = None
196
248
  if content is None and not model_id:
@@ -227,19 +279,43 @@ class ChatIntentController:
227
279
  )
228
280
  except self.tool_error as exc:
229
281
  raise HTTPException(status_code=400, detail=str(exc)) from exc
230
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
231
- if generation_meta and generation_meta.get("repaired"):
282
+ final_path = result.get("path") or target_path
283
+ answer = f"{final_path} 파일을 만들었습니다."
284
+ if renamed:
285
+ answer += " (같은 이름의 파일이 있어 새 이름으로 저장했습니다.)"
286
+ repaired = bool(generation_meta and generation_meta.get("repaired"))
287
+ if repaired:
232
288
  answer += (
233
289
  " (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
234
290
  )
235
291
  created_files = [
236
292
  {
237
- "path": result.get("path") or target_path,
238
- "filename": Path(result.get("path") or target_path).name,
293
+ "path": final_path,
294
+ "filename": Path(final_path).name,
239
295
  "bytes": result.get("bytes", 0),
240
296
  "action": "write_file",
241
297
  }
242
298
  ]
299
+ # Artifact-first contract: every file the request produced, with the
300
+ # honesty flags the UI needs (valid vs repaired scaffold, previewable).
301
+ artifacts: List[Dict[str, Any]] = [
302
+ {
303
+ "kind": "file",
304
+ "path": final_path,
305
+ "filename": Path(final_path).name,
306
+ "bytes": result.get("bytes", 0),
307
+ "previewable": Path(final_path).suffix.lower() in PREVIEWABLE_EXTENSIONS,
308
+ "valid": True,
309
+ "repaired": repaired,
310
+ }
311
+ ]
312
+ brain_ingest = self._ingest_generated_file(
313
+ final_path,
314
+ content,
315
+ effective_email=effective_email,
316
+ workspace_id=workspace_id,
317
+ conversation_id=getattr(req, "conversation_id", None),
318
+ )
243
319
  self.notify("user", req.message, req.source)
244
320
  self.notify("assistant", answer, req.source)
245
321
  payload = {
@@ -257,11 +333,14 @@ class ChatIntentController:
257
333
  "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
258
334
  "final_state": AgentState.DONE.value,
259
335
  "created_files": created_files,
336
+ "artifacts": artifacts,
260
337
  "routed_to_agent": True,
261
338
  "action_route": "direct_write_file",
262
339
  }
263
340
  if generation_meta is not None:
264
341
  payload["generation"] = generation_meta
342
+ if brain_ingest is not None:
343
+ payload["brain_ingest"] = brain_ingest
265
344
  if req.stream:
266
345
  return StreamingResponse(
267
346
  agent_payload_stream(
@@ -275,6 +354,50 @@ class ChatIntentController:
275
354
  )
276
355
  return JSONResponse(content=payload)
277
356
 
357
+ def _ingest_generated_file(
358
+ self,
359
+ rel_path: str,
360
+ content: Optional[str],
361
+ *,
362
+ effective_email: Optional[str],
363
+ workspace_id: Optional[str],
364
+ conversation_id: Optional[str],
365
+ ) -> Optional[Dict[str, Any]]:
366
+ """Optionally index a just-generated file into the Brain.
367
+
368
+ The knowledge promise is "what Lattice makes, Lattice remembers":
369
+ without this, generated files are invisible to recall until the user
370
+ re-uploads them. Best-effort and additive — ingestion problems never
371
+ fail the file creation, and LATTICEAI_INGEST_GENERATED=0 disables it.
372
+ """
373
+ if self.ingestion_pipeline is None or not ingest_generated_enabled():
374
+ return None
375
+ try:
376
+ from lattice_brain.ingestion import IngestionItem
377
+
378
+ item = IngestionItem(
379
+ source_type="file",
380
+ title=Path(rel_path).name,
381
+ text=content,
382
+ path=str(self.agent_root / rel_path),
383
+ source_uri=f"workspace://{rel_path}",
384
+ owner=effective_email,
385
+ workspace_id=workspace_id,
386
+ conversation_id=conversation_id,
387
+ metadata={"origin": "generated_file", "route": "direct_write_file"},
388
+ )
389
+ result = self.ingestion_pipeline.ingest(item, user_email=effective_email)
390
+ payload = result.as_dict() if hasattr(result, "as_dict") else dict(result or {})
391
+ return {
392
+ "status": payload.get("status"),
393
+ "node_id": payload.get("node_id"),
394
+ "chunk_count": payload.get("chunk_count", 0),
395
+ "duplicate": payload.get("duplicate", False),
396
+ }
397
+ except Exception as exc: # noqa: BLE001 — indexing must never break creation
398
+ logging.warning("generated-file ingest failed: %s", exc)
399
+ return {"status": "failed", "detail": str(exc)[:200]}
400
+
278
401
  async def route_file_to_agent(
279
402
  self,
280
403
  req: Any,
@@ -911,6 +911,7 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
911
911
  chat_service=CHAT_SERVICE,
912
912
  context_assembler=CONTEXT_ASSEMBLER,
913
913
  brain_memory=BRAIN_MEMORY,
914
+ ingestion_pipeline=INGESTION_PIPELINE if ENABLE_GRAPH else None,
914
915
  chat_agent_runtime=CHAT_AGENT_RUNTIME,
915
916
  gardener=gardener,
916
917
  hooks=HOOKS_REGISTRY,
@@ -32,6 +32,7 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tu
32
32
  from lattice_brain.runtime.hooks import dispatch_tool
33
33
  from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
34
34
  from latticeai.core.agent_trace import LoopTrace
35
+ from latticeai.core.file_generation import infer_file_target, sanitize_write_content
35
36
  from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
36
37
  from latticeai.tools import ToolError
37
38
 
@@ -141,6 +142,91 @@ def extract_action(raw: str) -> Dict:
141
142
  return action
142
143
 
143
144
 
145
+ def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
146
+ """Enforce the minimal plan schema so execution never starts adrift.
147
+
148
+ A weak planner that returns junk steps / a missing goal previously flowed
149
+ straight into the executor, which then had to reconstruct intent from the
150
+ raw request. Normalization keeps the loop honest: ``goal`` is always a
151
+ non-empty string, ``steps`` only contains dicts with an ``action``, and an
152
+ empty plan for an obvious file-creation request gets a deterministic
153
+ single ``write_file`` step instead of leaving the executor to improvise.
154
+
155
+ Returns ``(plan, fixes)`` where ``fixes`` names every applied repair —
156
+ the loop trace records them so plan quality is observable per model.
157
+ """
158
+ fixes: List[str] = []
159
+ if not isinstance(plan, dict):
160
+ plan = {}
161
+ fixes.append("plan_not_object")
162
+ plan = dict(plan)
163
+
164
+ goal = str(plan.get("goal") or "").strip()
165
+ if not goal:
166
+ plan["goal"] = user_message
167
+ fixes.append("goal_defaulted")
168
+
169
+ raw_steps = plan.get("steps")
170
+ steps = [
171
+ s for s in (raw_steps if isinstance(raw_steps, list) else [])
172
+ if isinstance(s, dict) and s.get("action")
173
+ ]
174
+ if raw_steps and steps != raw_steps:
175
+ fixes.append("steps_filtered")
176
+ if not steps:
177
+ inferred = infer_file_target(user_message)
178
+ if inferred:
179
+ steps = [{
180
+ "action": "write_file",
181
+ "args": {"path": inferred},
182
+ "description": f"Create {inferred} for: {user_message[:120]}",
183
+ }]
184
+ fixes.append("heuristic_file_step")
185
+ plan["steps"] = steps
186
+
187
+ try:
188
+ estimated = int(plan.get("estimated_steps") or 0)
189
+ except (TypeError, ValueError):
190
+ estimated = 0
191
+ fixes.append("estimated_steps_invalid")
192
+ plan["estimated_steps"] = max(1, estimated, len(steps))
193
+ plan["requires_approval"] = bool(plan.get("requires_approval", False))
194
+ if not isinstance(plan.get("rollback_strategy"), str):
195
+ plan["rollback_strategy"] = "none"
196
+ return plan, fixes
197
+
198
+
199
+ _TRIVIAL_LEARNING_RE = re.compile(
200
+ r"^(파일(을|이)?\s*(만들|생성|작성|저장)|작업(을|이)?\s*(완료|성공)|성공적으로"
201
+ r"|task\s+(was\s+)?complet|file\s+(was\s+)?(creat|written|saved)"
202
+ r"|(successfully\s+)?(created|completed|finished|done)\b)",
203
+ re.IGNORECASE,
204
+ )
205
+
206
+
207
+ def filter_learnings(learnings: List[Any]) -> List[str]:
208
+ """Drop trivial/duplicate learnings before they enter the brain.
209
+
210
+ "파일을 만들었다"-class statements restate what the transcript already
211
+ records and pollute recall. A learning survives when it is long enough to
212
+ carry information and is not a bare completion announcement.
213
+ """
214
+ kept: List[str] = []
215
+ seen: set = set()
216
+ for raw in learnings or []:
217
+ text = str(raw or "").strip()
218
+ if len(text) < 12:
219
+ continue
220
+ if _TRIVIAL_LEARNING_RE.match(text) and len(text) < 48:
221
+ continue
222
+ key = text.lower()
223
+ if key in seen:
224
+ continue
225
+ seen.add(key)
226
+ kept.append(text)
227
+ return kept
228
+
229
+
144
230
  @dataclass
145
231
  class AgentDeps:
146
232
  """The ports a :class:`SingleAgentRuntime` needs from the outside world.
@@ -258,6 +344,9 @@ class SingleAgentRuntime:
258
344
  "goal": req.message, "steps": [],
259
345
  "requires_approval": False, "rollback_strategy": "none", "estimated_steps": 1,
260
346
  }
347
+ plan, plan_fixes = normalize_plan(plan, req.message)
348
+ if plan_fixes:
349
+ ctx.trace.repair("plan", repairs=plan_fixes)
261
350
  ctx.plan = plan
262
351
  ctx.transcript.append({
263
352
  "state": AgentState.PLANNING.value,
@@ -266,6 +355,7 @@ class SingleAgentRuntime:
266
355
  "requires_approval": plan.get("requires_approval", False),
267
356
  "rollback_strategy": plan.get("rollback_strategy", "none"),
268
357
  "estimated_steps": plan.get("estimated_steps", 1),
358
+ **({"plan_fixes": plan_fixes} if plan_fixes else {}),
269
359
  })
270
360
  ctx.state = AgentState.WAITING_APPROVAL
271
361
 
@@ -557,6 +647,26 @@ class SingleAgentRuntime:
557
647
  ) -> None:
558
648
  """Role check + shared tool lifecycle, recorded on the transcript either way."""
559
649
  d = self.deps
650
+ sanitize_meta: Optional[Dict[str, Any]] = None
651
+ if name == "write_file" and isinstance(args.get("content"), str):
652
+ # ArtifactWritePipeline: the executor's args.content is untrusted
653
+ # model output. The same extract→validate→repair guarantee as the
654
+ # direct chat path applies here, so a weak model driving the JSON
655
+ # loop can never persist fenced/chatty/truncated payloads.
656
+ cleaned, meta = sanitize_write_content(
657
+ str(args.get("path") or ""), args["content"],
658
+ user_request=str(ctx.plan.get("goal") or thoughts or name),
659
+ )
660
+ if meta.get("sanitized"):
661
+ args = dict(args)
662
+ args["content"] = cleaned
663
+ sanitize_meta = meta
664
+ ctx.trace.repair(
665
+ "execute",
666
+ repairs=[
667
+ "artifact_repair" if meta.get("repaired") else "artifact_sanitize"
668
+ ],
669
+ )
560
670
  try:
561
671
  d.check_role(name, current_user)
562
672
  # Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
@@ -570,6 +680,7 @@ class SingleAgentRuntime:
570
680
  "state": AgentState.EXECUTING.value, "action": name,
571
681
  "thoughts": thoughts, "args": args,
572
682
  "risk": risk, "governance": dict(policy), "result": result,
683
+ **({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
573
684
  })
574
685
  except (ToolError, KeyError, TypeError, PermissionError) as exc:
575
686
  ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
@@ -791,8 +902,9 @@ class SingleAgentRuntime:
791
902
  context=context, max_tokens=256, temperature=0.1,
792
903
  )
793
904
  mem = extract_action(str(raw))
794
- if mem.get("save_to_knowledge") and mem.get("learnings"):
795
- learnings = "\n".join(mem["learnings"])
905
+ kept_learnings = filter_learnings(mem.get("learnings") or [])
906
+ if mem.get("save_to_knowledge") and kept_learnings:
907
+ learnings = "\n".join(kept_learnings)
796
908
  if d.brain_memory is not None:
797
909
  # This runtime is LLM-driven — its learnings are real
798
910
  # experiences and enter the brain with provenance.
@@ -195,6 +195,14 @@ def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
195
195
  return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
196
196
  if "</html>" not in lower:
197
197
  return False, "HTML document is truncated (missing </html>)"
198
+ # A document that merely *contains* html somewhere is not a valid
199
+ # file payload: fenced/chat-wrapped replies must fail here so the
200
+ # extraction pass gets a chance to slice out the real document.
201
+ stripped = content.lstrip()
202
+ if not (stripped.lower().startswith("<!doctype") or stripped.lower().startswith("<html")):
203
+ return False, "HTML document is wrapped in prose or fences"
204
+ if "```" in content:
205
+ return False, "output still contains Markdown fences"
198
206
  return True, "ok"
199
207
  if ext == ".json":
200
208
  try:
@@ -359,6 +367,54 @@ def _repair_html(salvage: str, user_request: str) -> str:
359
367
  )
360
368
 
361
369
 
370
+ # Extensions the Brain UI can render inline (preview) after creation.
371
+ PREVIEWABLE_EXTENSIONS = frozenset({
372
+ ".html", ".htm", ".md", ".markdown", ".txt", ".json", ".css", ".js",
373
+ ".csv", ".py", ".yaml", ".yml", ".xml", ".sql", ".sh",
374
+ })
375
+
376
+
377
+ # ── write-side sanitize (ArtifactWritePipeline) ─────────────────────────
378
+
379
+ def sanitize_write_content(
380
+ target_path: str,
381
+ content: Any,
382
+ user_request: str = "",
383
+ ) -> Tuple[str, Dict[str, Any]]:
384
+ """Single write-side guarantee for model-produced file content.
385
+
386
+ The direct chat path already runs the full generate→validate→repair
387
+ pipeline, but the agent JSON loop historically wrote ``args.content``
388
+ verbatim — weak models routinely put fenced/chatty payloads there. This
389
+ conservative sanitizer closes that gap for *any* write entry point:
390
+
391
+ 1. content that already validates is returned byte-for-byte unchanged
392
+ (trusted/user-authored content is never mangled);
393
+ 2. otherwise the extraction pass strips fences/think-blocks/chat noise
394
+ and is used only when the extracted payload validates;
395
+ 3. otherwise deterministic repair guarantees a structurally valid file.
396
+
397
+ Empty content is left untouched (creating an empty file is a legitimate,
398
+ intentional action — e.g. ``__init__.py``). Returns ``(content, meta)``
399
+ where meta is ``{"sanitized": bool, "repaired": bool, "reason": str}``.
400
+ """
401
+ raw = str(content or "")
402
+ if not raw.strip():
403
+ return raw, {"sanitized": False, "repaired": False, "reason": "empty"}
404
+ ok, reason = validate_file_content(raw, target_path)
405
+ if ok:
406
+ return raw, {"sanitized": False, "repaired": False, "reason": "ok"}
407
+ extracted = extract_file_content(raw, target_path)
408
+ if extracted:
409
+ extracted_ok, _ = validate_file_content(extracted, target_path)
410
+ if extracted_ok:
411
+ return extracted, {"sanitized": True, "repaired": False, "reason": reason}
412
+ repaired = repair_file_content(
413
+ extracted or raw, target_path, user_request or f"content for {target_path}"
414
+ )
415
+ return repaired, {"sanitized": True, "repaired": True, "reason": reason}
416
+
417
+
362
418
  # ── filename inference ──────────────────────────────────────────────────
363
419
 
364
420
  _CREATE_VERB_RE = re.compile(
@@ -441,11 +497,13 @@ async def generate_file_content(
441
497
 
442
498
 
443
499
  __all__ = [
500
+ "PREVIEWABLE_EXTENSIONS",
444
501
  "build_file_generation_context",
445
502
  "extract_file_content",
446
503
  "generate_file_content",
447
504
  "infer_file_target",
448
505
  "looks_like_refusal",
449
506
  "repair_file_content",
507
+ "sanitize_write_content",
450
508
  "validate_file_content",
451
509
  ]
@@ -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.0"
17
+ LEGACY_COMPATIBILITY_VERSION = "9.9.2"
18
18
 
19
19
 
20
20
  @dataclass(frozen=True)
@@ -40,106 +40,130 @@ class LegacyShim:
40
40
 
41
41
 
42
42
  LEGACY_SHIMS: List[LegacyShim] = [
43
+ LegacyShim(
44
+ path="server.py",
45
+ owner="latticeai.server_app",
46
+ replacement="from latticeai.server_app import app, main",
47
+ reason="Deployment docs, Docker CMD, start scripts, and uvicorn targets still use server:app.",
48
+ removal_phase="major-release-after-9.x",
49
+ ),
50
+ ]
51
+
52
+ # Shim layers that have completed their compatibility window and were
53
+ # physically deleted. Kept in the report so consumers (docs, tests, release
54
+ # notes) can tell "removed on purpose" apart from "missing by accident".
55
+ # Removing the internal-only layers is the first step of extracting
56
+ # ``lattice_brain`` as a standalone Brain Core package: the package now has
57
+ # exactly one import surface (``lattice_brain.*`` physical paths).
58
+ REMOVED_SHIMS: List[LegacyShim] = [
43
59
  LegacyShim(
44
60
  path="knowledge_graph.py",
45
- owner="lattice_brain.graph",
61
+ owner="lattice_brain.graph.store",
46
62
  replacement="from lattice_brain.graph.store import KnowledgeGraphStore",
47
- reason="Historical scripts imported the graph store from the repo root.",
48
- removal_phase="major-release-after-8.x",
63
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
64
+ removal_phase="removed-9.9.1",
65
+ layer="root",
66
+ status="removed",
49
67
  ),
50
68
  LegacyShim(
51
69
  path="knowledge_graph_api.py",
52
70
  owner="latticeai.api.knowledge_graph",
53
71
  replacement="from latticeai.api.knowledge_graph import create_knowledge_graph_router",
54
- reason="Older API composition roots imported the knowledge graph router from the repo root.",
55
- removal_phase="major-release-after-8.x",
72
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
73
+ removal_phase="removed-9.9.1",
74
+ layer="root",
75
+ status="removed",
56
76
  ),
57
77
  LegacyShim(
58
78
  path="kg_schema.py",
59
79
  owner="lattice_brain.graph.schema",
60
80
  replacement="from lattice_brain.graph.schema import ...",
61
- reason="Historical graph schema tests and tools referenced root schema symbols.",
62
- removal_phase="major-release-after-8.x",
81
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
82
+ removal_phase="removed-9.9.1",
83
+ layer="root",
84
+ status="removed",
63
85
  ),
64
86
  LegacyShim(
65
87
  path="ltcai_cli.py",
66
88
  owner="latticeai.cli.entrypoint",
67
89
  replacement="from latticeai.cli.entrypoint import main",
68
- reason="Console entry points and older package installs imported the CLI root module.",
69
- removal_phase="major-release-after-8.x",
90
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
91
+ removal_phase="removed-9.9.1",
92
+ layer="root",
93
+ status="removed",
70
94
  ),
71
95
  LegacyShim(
72
96
  path="telegram_bot.py",
73
97
  owner="latticeai.integrations.telegram_bot",
74
98
  replacement="from latticeai.integrations.telegram_bot import run_bot",
75
- reason="Existing Telegram automation setups still import the legacy root module.",
76
- removal_phase="major-release-after-8.x",
99
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
100
+ removal_phase="removed-9.9.1",
101
+ layer="root",
102
+ status="removed",
77
103
  ),
78
104
  LegacyShim(
79
105
  path="p_reinforce.py",
80
106
  owner="latticeai.services.p_reinforce",
81
107
  replacement="from latticeai.services.p_reinforce import PReinforceGardener",
82
- reason="Old gardener scripts referenced the root reinforcement helper.",
83
- removal_phase="major-release-after-8.x",
108
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
109
+ removal_phase="removed-9.9.1",
110
+ layer="root",
111
+ status="removed",
84
112
  ),
85
113
  LegacyShim(
86
114
  path="mcp_registry.py",
87
115
  owner="latticeai.core.mcp_registry",
88
116
  replacement="import latticeai.core.mcp_registry",
89
- reason="Older integrations imported the MCP registry from the repo root.",
90
- removal_phase="major-release-after-9.x",
117
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
118
+ removal_phase="removed-9.9.1",
119
+ layer="root",
120
+ status="removed",
91
121
  ),
92
122
  LegacyShim(
93
123
  path="llm_router.py",
94
124
  owner="latticeai.models.router",
95
125
  replacement="import latticeai.models.router",
96
- reason="Historical scripts and tests imported the LLM router from the repo root.",
97
- removal_phase="major-release-after-9.x",
98
- ),
99
- LegacyShim(
100
- path="server.py",
101
- owner="latticeai.server_app",
102
- replacement="from latticeai.server_app import app, main",
103
- reason="Deployment docs and local launch scripts historically targeted server.py.",
104
- removal_phase="major-release-after-8.x",
105
- ),
106
- LegacyShim(
107
- path="tools/",
108
- owner="latticeai.tools",
109
- replacement="from latticeai.tools import execute_tool",
110
- reason="Existing integrations and tests import the historical root tools package.",
111
- removal_phase="major-release-after-9.x",
126
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
127
+ removal_phase="removed-9.9.1",
128
+ layer="root",
129
+ status="removed",
112
130
  ),
113
131
  LegacyShim(
114
132
  path="local_knowledge_api.py",
115
133
  owner="latticeai.services.local_knowledge",
116
134
  replacement="from latticeai.services.local_knowledge import create_local_knowledge_router",
117
- reason="Local folder ingestion integrations used the root local knowledge API.",
118
- removal_phase="requires-api-route-migration",
135
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
136
+ removal_phase="removed-9.9.1",
137
+ layer="root",
138
+ status="removed",
119
139
  ),
120
140
  LegacyShim(
121
141
  path="auto_setup.py",
122
142
  owner="latticeai.setup.auto_setup",
123
143
  replacement="from latticeai.setup.auto_setup import probe, recommend",
124
- reason="Historical scripts invoke the zero-config setup module from the repo root.",
125
- removal_phase="major-release-after-9.x",
144
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
145
+ removal_phase="removed-9.9.1",
146
+ layer="root",
147
+ status="removed",
126
148
  ),
127
149
  LegacyShim(
128
150
  path="setup_wizard.py",
129
151
  owner="latticeai.setup.wizard",
130
152
  replacement="from latticeai.setup.wizard import scan_environment",
131
- reason="Older integrations imported the setup wizard from the repo root.",
132
- removal_phase="major-release-after-9.x",
153
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
154
+ removal_phase="removed-9.9.1",
155
+ layer="root",
156
+ status="removed",
157
+ ),
158
+ LegacyShim(
159
+ path="tools/",
160
+ owner="latticeai.tools",
161
+ replacement="from latticeai.tools import execute_tool",
162
+ reason="Root compatibility shims completed their window; removed in 9.9.1.",
163
+ removal_phase="removed-9.9.1",
164
+ layer="root",
165
+ status="removed",
133
166
  ),
134
- ]
135
-
136
- # Shim layers that have completed their compatibility window and were
137
- # physically deleted. Kept in the report so consumers (docs, tests, release
138
- # notes) can tell "removed on purpose" apart from "missing by accident".
139
- # Removing the internal-only layers is the first step of extracting
140
- # ``lattice_brain`` as a standalone Brain Core package: the package now has
141
- # exactly one import surface (``lattice_brain.*`` physical paths).
142
- REMOVED_SHIMS: List[LegacyShim] = [
143
167
  LegacyShim(
144
168
  path="lattice_brain/store.py",
145
169
  owner="lattice_brain.graph.store",
@@ -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.0"
14
+ MARKETPLACE_VERSION = "9.9.2"
15
15
  TEMPLATE_KINDS = ("plugin", "workflow", "agent", "ingestion_bridge")
16
16
 
17
17