ltcai 9.9.1 → 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 (61) hide show
  1. package/README.md +15 -14
  2. package/docs/CHANGELOG.md +36 -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/runtime/multi_agent.py +1 -1
  12. package/latticeai/__init__.py +1 -1
  13. package/latticeai/api/chat.py +3 -0
  14. package/latticeai/api/chat_agent_http.py +2 -1
  15. package/latticeai/api/chat_intents.py +130 -7
  16. package/latticeai/app_factory.py +1 -0
  17. package/latticeai/core/agent.py +114 -2
  18. package/latticeai/core/file_generation.py +58 -0
  19. package/latticeai/core/legacy_compatibility.py +1 -1
  20. package/latticeai/core/marketplace.py +1 -1
  21. package/latticeai/core/workspace_os.py +1 -1
  22. package/latticeai/services/app_context.py +3 -0
  23. package/latticeai/services/architecture_readiness.py +1 -1
  24. package/latticeai/services/product_readiness.py +6 -1
  25. package/latticeai/services/tool_dispatch.py +36 -0
  26. package/package.json +1 -1
  27. package/scripts/check_current_release_docs.mjs +1 -1
  28. package/src-tauri/Cargo.lock +1 -1
  29. package/src-tauri/Cargo.toml +1 -1
  30. package/src-tauri/tauri.conf.json +1 -1
  31. package/static/app/asset-manifest.json +27 -27
  32. package/static/app/assets/{Act-CcSWTQsx.js → Act-Cx_gZNbE.js} +1 -1
  33. package/static/app/assets/{Brain-CSCDZEHd.js → Brain-BnGbytHa.js} +1 -1
  34. package/static/app/assets/BrainHome-CRUMJL93.js +2 -0
  35. package/static/app/assets/{BrainSignals-CmFD8PWk.js → BrainSignals-BP8-Qh2g.js} +1 -1
  36. package/static/app/assets/{Capture-BiuerOio.js → Capture-CAKnLcb0.js} +1 -1
  37. package/static/app/assets/{CommandPalette-BrqpT7K2.js → CommandPalette-BoUTXBfm.js} +1 -1
  38. package/static/app/assets/{Library-BcaiPbum.js → Library-PcfVUco6.js} +1 -1
  39. package/static/app/assets/{LivingBrain-6FvA0H5T.js → LivingBrain-C3VbogVo.js} +1 -1
  40. package/static/app/assets/{ProductFlow-Cr8bkQPw.js → ProductFlow-BLau7c7N.js} +1 -1
  41. package/static/app/assets/{ReviewCard-D56TMypk.js → ReviewCard-CX5RqRqx.js} +1 -1
  42. package/static/app/assets/{System-D6uJacqu.js → System-DRHc_6Su.js} +1 -1
  43. package/static/app/assets/{bot-DgC7xgUl.js → bot-DZkDdh-A.js} +1 -1
  44. package/static/app/assets/{cpu-DDGb4XHl.js → cpu-DyvsDBbj.js} +1 -1
  45. package/static/app/assets/{download-C-Ei3GFa.js → download-C7yxYHvz.js} +1 -1
  46. package/static/app/assets/{folder-open-BbVuSO9M.js → folder-open-CffoIr9t.js} +1 -1
  47. package/static/app/assets/{hard-drive-yDZR6XYz.js → hard-drive-C1oojrHb.js} +1 -1
  48. package/static/app/assets/{index-DrZSkwrE.js → index-BS_3aN1n.js} +3 -3
  49. package/static/app/assets/index-DU78Kogz.css +2 -0
  50. package/static/app/assets/{input-DPIkTs9n.js → input-B_U7zvbR.js} +1 -1
  51. package/static/app/assets/{navigation-B1mkmyUe.js → navigation-xG-gyjbe.js} +1 -1
  52. package/static/app/assets/{network-0XDI1Uf_.js → network-BF13HJJF.js} +1 -1
  53. package/static/app/assets/{primitives-sIQYKyf5.js → primitives-DsuKh3M8.js} +1 -1
  54. package/static/app/assets/{sunrise-DXMjueec.js → sunrise-CIP2ELJ1.js} +1 -1
  55. package/static/app/assets/{textarea-DvnwpnqS.js → textarea-DsDL4ET8.js} +1 -1
  56. package/static/app/assets/utils-Dh-Dwtkb.js +7 -0
  57. package/static/app/index.html +4 -4
  58. package/static/sw.js +1 -1
  59. package/static/app/assets/BrainHome-B5QpJ1Fx.js +0 -2
  60. package/static/app/assets/index-CFRhNBuH.css +0 -2
  61. package/static/app/assets/utils-BrfFHAJl.js +0 -7
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [![CI Status](https://github.com/TaeSooPark-PTS/LatticeAI/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/TaeSooPark-PTS/LatticeAI/actions/workflows/ci.yml)
12
12
  [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
13
13
 
14
- ![v9.9.1 Living Brain walkthrough](output/release/v9.9.1/gifs/v9.9.1-living-brain-walkthrough.gif)
14
+ ![v9.9.2 Living Brain walkthrough](output/release/v9.9.2/gifs/v9.9.2-living-brain-walkthrough.gif)
15
15
 
16
16
  Chat, files, folders, notes, and web pages all flow into one durable knowledge
17
17
  graph on your computer. Any model — local MLX or cloud — can speak with that
@@ -24,9 +24,9 @@ memory. Nothing leaves your machine without explicit consent.
24
24
 
25
25
  | | |
26
26
  | --- | --- |
27
- | **Chat with a Brain that remembers** — every conversation grows durable, source-linked memory ![Brain Chat](output/release/v9.9.1/screenshots/04-brain-chat-home.png) | **See how knowledge connects** — a real relationship graph, not a file list ![Memory Graph](output/release/v9.9.1/screenshots/05-memory-graph.png) |
28
- | **Capture anything** — files, whole folders, notes, screenshots, web pages ![Capture](output/release/v9.9.1/screenshots/06-capture.png) | **Automate with review** — agent changes become proposals you approve first ![Review Center](output/release/v9.9.1/screenshots/12-review-center.png) |
29
- | **Pick a model in one click** — recommended local models for your hardware ![Recommended Models](output/release/v9.9.1/screenshots/02-recommended-models.png) | **Stay in control** — audit, roles, retention in a separate admin surface ![Admin Console](output/release/v9.9.1/screenshots/10-admin-console.png) |
27
+ | **Chat with a Brain that remembers** — every conversation grows durable, source-linked memory ![Brain Chat](output/release/v9.9.2/screenshots/04-brain-chat-home.png) | **See how knowledge connects** — a real relationship graph, not a file list ![Memory Graph](output/release/v9.9.2/screenshots/05-memory-graph.png) |
28
+ | **Capture anything** — files, whole folders, notes, screenshots, web pages ![Capture](output/release/v9.9.2/screenshots/06-capture.png) | **Automate with review** — agent changes become proposals you approve first ![Review Center](output/release/v9.9.2/screenshots/12-review-center.png) |
29
+ | **Pick a model in one click** — recommended local models for your hardware ![Recommended Models](output/release/v9.9.2/screenshots/02-recommended-models.png) | **Stay in control** — audit, roles, retention in a separate admin surface ![Admin Console](output/release/v9.9.2/screenshots/10-admin-console.png) |
30
30
 
31
31
  ## Why Lattice AI
32
32
 
@@ -57,14 +57,14 @@ First-run flow — wake the Brain, pick the owner, load a recommended model:
57
57
 
58
58
  | | | |
59
59
  | --- | --- | --- |
60
- | ![Login](output/release/v9.9.1/screenshots/01-login.png) | ![Model install](output/release/v9.9.1/screenshots/03-install-load-progress.png) | ![Model library](output/release/v9.9.1/screenshots/07-model-library.png) |
60
+ | ![Login](output/release/v9.9.2/screenshots/01-login.png) | ![Model install](output/release/v9.9.2/screenshots/03-install-load-progress.png) | ![Model library](output/release/v9.9.2/screenshots/07-model-library.png) |
61
61
 
62
62
  Screenshot index and capture notes:
63
- [output/release/v9.9.1/SCREENSHOT_INDEX.md](output/release/v9.9.1/SCREENSHOT_INDEX.md)
63
+ [output/release/v9.9.2/SCREENSHOT_INDEX.md](output/release/v9.9.2/SCREENSHOT_INDEX.md)
64
64
 
65
65
  ## Current Release
66
66
 
67
- The current release is **9.9.1Clean Foundations**:
67
+ The current release is **9.9.2Artifact Trust**:
68
68
 
69
69
  - **A clean core.** The historical repo-root compatibility shims are gone
70
70
  (only `server.py` remains for `uvicorn server:app`); packaging ships exactly
@@ -93,13 +93,13 @@ The current release is **9.9.1 — Clean Foundations**:
93
93
 
94
94
  Release notes: [RELEASE.md](RELEASE.md) · Full history: [docs/CHANGELOG.md](docs/CHANGELOG.md)
95
95
 
96
- Expected artifacts for 9.9.1 release must use exact filenames:
96
+ Expected artifacts for 9.9.2 release must use exact filenames:
97
97
 
98
- - `dist/ltcai-9.9.1-py3-none-any.whl`
99
- - `dist/ltcai-9.9.1.tar.gz`
100
- - `ltcai-9.9.1.tgz`
101
- - `dist/ltcai-9.9.1.vsix`
102
- - `src-tauri/target/release/bundle/dmg/Lattice AI_9.9.1_aarch64.dmg`
98
+ - `dist/ltcai-9.9.2-py3-none-any.whl`
99
+ - `dist/ltcai-9.9.2.tar.gz`
100
+ - `ltcai-9.9.2.tgz`
101
+ - `dist/ltcai-9.9.2.vsix`
102
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_9.9.2_aarch64.dmg`
103
103
 
104
104
  Do not use wildcard artifact uploads. Package registry publishing remains owner-run.
105
105
 
@@ -119,7 +119,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details and
119
119
 
120
120
  - External package registries are owner-published and can lag behind GitHub.
121
121
  - PostgreSQL/pgvector is optional scale/migration tooling. SQLite remains the
122
- live local Brain store in 9.9.1.
122
+ live local Brain store in 9.9.2.
123
123
  - Docker, model downloads, cloud model calls, Telegram, Brain Network, and
124
124
  update checks require explicit user action.
125
125
  - Conversation does not fabricate answers when no model is loaded. Agent and
@@ -131,6 +131,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for details and
131
131
 
132
132
  | Version | Theme |
133
133
  | --- | --- |
134
+ | 9.9.2 | Artifact Trust |
134
135
  | 9.9.1 | Clean Foundations |
135
136
  | 9.9.0 | Fail-Closed Trust |
136
137
  | 9.8.0 | Honest Knowledge Pipeline |
package/docs/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ The top entry is either the current unreleased main-branch work or the current
4
4
  release line. Older entries are historical and may describe behavior as it
5
5
  existed at that release.
6
6
 
7
+ ## [9.9.2] - 2026-07-21
8
+
9
+ ### Added
10
+ - ArtifactWritePipeline: `sanitize_write_content` in
11
+ `latticeai/core/file_generation.py` — a conservative validate-first →
12
+ extract → repair gate. The agent executor applies it to every `write_file`
13
+ `args.content` before dispatch, records a `content_sanitize` verdict on the
14
+ transcript, and tags `artifact_sanitize`/`artifact_repair` in the loop trace.
15
+ - Artifact-first chat contract: direct file-creation responses and agent runs
16
+ carry an `artifacts[]` array (`kind/path/filename/bytes/previewable/valid/
17
+ repaired`) via `collect_artifacts(transcript)`.
18
+ - Direct-path overwrite protection: an existing target is auto-suffixed
19
+ (`generated_page_2.html`) and announced, instead of silently overwritten.
20
+ - Generated files are optionally indexed into the Brain through the unified
21
+ `IngestionPipeline` (`workspace://` provenance, `origin: generated_file`);
22
+ disable with `LATTICEAI_INGEST_GENERATED=0`. The response reports an honest
23
+ `brain_ingest` status; ingest failures never fail the file creation.
24
+ - Plan schema enforcement (`normalize_plan`): non-empty goal, junk-step
25
+ filtering, `estimated_steps` clamping, and a deterministic single
26
+ `write_file` step for file-intent requests whose plan came back empty.
27
+ - Memory quality filter (`filter_learnings`): trivial and duplicate agent
28
+ learnings are dropped before entering the Brain.
29
+ - FG harness `tests/unit/test_artifact_write_scenarios.py` (FG-01..FG-08):
30
+ intent gating, filename inference, dirty-output extraction, truncated-HTML
31
+ repair, agent-path sanitization, how-to non-routing, scaffold validity.
32
+ - Frontend honesty surfaces: "Auto-repaired" badge on file cards
33
+ (`generation.repaired`), and a distinct warning strip for `NEEDS_REVIEW` /
34
+ `FAILED` terminal agent states (`role="alert"`, ko/en, dark/light).
35
+
36
+ ### Changed
37
+ - HTML file validation now rejects documents wrapped in prose or Markdown
38
+ fences, so extraction gets a chance to slice out the real document instead
39
+ of the wrapper being saved as "valid".
40
+ - `product_readiness` `action-aware-chat` gate additionally proves the
41
+ ArtifactWritePipeline evidence (module, agent seam, FG harness test).
42
+
7
43
  ## [9.9.1] - 2026-07-21
8
44
 
9
45
  ### Removed
@@ -1,6 +1,6 @@
1
1
  # Community And Plugins
2
2
 
3
- Current release: **9.9.1Clean Foundations**.
3
+ Current release: **9.9.2Artifact Trust**.
4
4
 
5
5
  LatticeAI defines the path from a strong local-first framework (8.4.0
6
6
  action-aware baseline, 8.5.0 registry+DI hardening, 8.6.0 capture/navigation
@@ -3,7 +3,7 @@
3
3
  > **Status: canonical** — current contributor guidance, kept in sync with the
4
4
  > current release.
5
5
 
6
- Current release: **9.9.1Clean Foundations**.
6
+ Current release: **9.9.2Artifact Trust**.
7
7
 
8
8
  This document is for contributors working on the local-first Digital Brain
9
9
  codebase. Product positioning and quick start stay in `README.md`; release
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Onboarding
2
2
 
3
- Current release: **9.9.1Clean Foundations**.
3
+ Current release: **9.9.2Artifact Trust**.
4
4
 
5
5
  The first-run goal is a five-minute path from "I opened the app" to "my Brain
6
6
  has a source, a question, and proof." This page is the product contract behind
@@ -1,4 +1,4 @@
1
- # Lattice AI — Operations Guide (v9.9.1)
1
+ # Lattice AI — Operations Guide (v9.9.2)
2
2
 
3
3
  > **Status: canonical** — kept in sync with the current release. Storage layout
4
4
  > below reflects the SQLite live Brain store and workspace scoping, not the
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Trust Model
2
2
 
3
- Current release: **9.9.1Clean Foundations**.
3
+ Current release: **9.9.2Artifact Trust**.
4
4
 
5
5
  Lattice AI is local-first, explicit about external communication, and honest
6
6
  when a capability is unavailable.
@@ -1,6 +1,6 @@
1
1
  # Why Lattice AI Exists
2
2
 
3
- Current release: **9.9.1Clean Foundations**.
3
+ Current release: **9.9.2Artifact Trust**.
4
4
 
5
5
  **Lattice AI is a local-first Digital Brain that keeps your knowledge durable
6
6
  across any AI model.**
package/docs/kg-schema.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Knowledge Graph Schema
2
2
 
3
- Current release: **9.9.1Clean Foundations**.
3
+ Current release: **9.9.2Artifact Trust**.
4
4
 
5
5
  명세 출처: `lattice_ai_full_spec.pptx` 슬라이드 20·21·22
6
6
  구현: `lattice_brain/graph/schema.py`
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "9.9.1"
29
+ __version__ = "9.9.2"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -48,7 +48,7 @@ from .contracts import multi_agent_contract
48
48
  from ..utils import now_iso as _now
49
49
 
50
50
 
51
- MULTI_AGENT_VERSION = "9.9.1"
51
+ MULTI_AGENT_VERSION = "9.9.2"
52
52
 
53
53
  AGENT_ROLES = ("researcher", "planner", "executor", "reviewer", "release")
54
54
  CORE_PIPELINE = ("planner", "executor", "reviewer")
@@ -1,3 +1,3 @@
1
1
  """Lattice AI - modular server package."""
2
2
 
3
- __version__ = "9.9.1"
3
+ __version__ = "9.9.2"
@@ -208,6 +208,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
208
208
  execute_tool=execute_tool,
209
209
  agent_controller=agent_controller,
210
210
  agent_root=AGENT_ROOT,
211
+ ingestion_pipeline=context.ingestion_pipeline,
211
212
  )
212
213
  document_coordinator = DocumentGenerationCoordinator(
213
214
  model_router=model_router,
@@ -276,6 +277,8 @@ def create_chat_router(context: AppContext) -> APIRouter:
276
277
  direct_response = await intent_controller.direct_file_action(
277
278
  req,
278
279
  model_id=selected_model_id,
280
+ effective_email=effective_email,
281
+ workspace_id=workspace_id,
279
282
  )
280
283
  if direct_response is not None:
281
284
  return direct_response
@@ -18,7 +18,7 @@ from lattice_brain.runtime.hooks import dispatch_tool
18
18
  from latticeai.api.chat_contracts import AgentEvalRequest, AgentRequest, AgentResumeRequest
19
19
  from latticeai.api.chat_helpers import _LANG_HINT, detect_language, workspace_scope_from_request
20
20
  from latticeai.core.agent import AgentRunContext, AgentState
21
- from latticeai.services.tool_dispatch import collect_created_files
21
+ from latticeai.services.tool_dispatch import collect_artifacts, collect_created_files
22
22
 
23
23
 
24
24
  class AgentHTTPController:
@@ -307,6 +307,7 @@ class AgentHTTPController:
307
307
  "state_history": ctx.state_history,
308
308
  "final_state": ctx.state.value,
309
309
  "created_files": collect_created_files(ctx.transcript),
310
+ "artifacts": collect_artifacts(ctx.transcript),
310
311
  "loop": ctx.trace.summary(),
311
312
  }
312
313
 
@@ -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.