ltcai 9.0.0 → 9.2.0
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.
- package/README.md +88 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +178 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +28 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +316 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +50 -13
- package/latticeai/core/agent_prompts.py +5 -0
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/file_generation.py +451 -0
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-C3dBrWE-.js +1 -0
- package/static/app/assets/Brain-DBYgdcjt.js +321 -0
- package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
- package/static/app/assets/Library-CFfkNn3s.js +1 -0
- package/static/app/assets/System-BOurbT-v.js +1 -0
- package/static/app/assets/index-A3M9sElj.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-DcUUmhdC.js +1 -0
- package/static/app/assets/textarea-BklR6zN4.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
|
@@ -23,6 +23,8 @@ def retrieve_context_for_generation(
|
|
|
23
23
|
*,
|
|
24
24
|
max_results: int = 10,
|
|
25
25
|
max_hops: int = 2,
|
|
26
|
+
allowed_workspaces=None,
|
|
27
|
+
include_legacy_global: bool = False,
|
|
26
28
|
) -> Dict[str, Any]:
|
|
27
29
|
"""Knowledge Graph에서 문서 생성에 필요한 컨텍스트를 검색·조합한다.
|
|
28
30
|
|
|
@@ -38,9 +40,21 @@ def retrieve_context_for_generation(
|
|
|
38
40
|
if not query or not kg_store:
|
|
39
41
|
return {"query": query, "context_markdown": "", "sources": [], "stats": {}}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
scope_kwargs = (
|
|
44
|
+
{
|
|
45
|
+
"allowed_workspaces": allowed_workspaces,
|
|
46
|
+
"include_legacy_global": include_legacy_global,
|
|
47
|
+
}
|
|
48
|
+
if allowed_workspaces is not None
|
|
49
|
+
else {}
|
|
50
|
+
)
|
|
51
|
+
results = kg_store.search_for_document_generation(query, limit=max_results, **scope_kwargs)
|
|
42
52
|
if not results:
|
|
43
|
-
fallback_ctx = kg_store.context_for_query(
|
|
53
|
+
fallback_ctx = kg_store.context_for_query(
|
|
54
|
+
query,
|
|
55
|
+
limit=max_results,
|
|
56
|
+
**scope_kwargs,
|
|
57
|
+
)
|
|
44
58
|
return {
|
|
45
59
|
"query": query,
|
|
46
60
|
"context_markdown": fallback_ctx,
|
|
@@ -49,7 +63,11 @@ def retrieve_context_for_generation(
|
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
seed_ids = [r["id"] for r in results]
|
|
52
|
-
hop_data = kg_store.multi_hop_context(
|
|
66
|
+
hop_data = kg_store.multi_hop_context(
|
|
67
|
+
seed_ids,
|
|
68
|
+
max_hops=max_hops,
|
|
69
|
+
**scope_kwargs,
|
|
70
|
+
)
|
|
53
71
|
|
|
54
72
|
extra_nodes_by_id = {}
|
|
55
73
|
for node in hop_data.get("nodes", []):
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Model-agnostic file content generation pipeline.
|
|
2
|
+
|
|
3
|
+
Small local models (gemma/qwen/llama 7B class) asked to "generate an HTML
|
|
4
|
+
file" commonly wrap the payload in chat noise: leading commentary ("Sure!
|
|
5
|
+
Here is your page:"), Markdown fences, ``<think>`` reasoning blocks,
|
|
6
|
+
trailing explanations, or an incomplete document. The previous direct-write
|
|
7
|
+
path saved that reply nearly verbatim, so weak models produced broken files.
|
|
8
|
+
|
|
9
|
+
This module makes the file-creation flow robust regardless of which LLM is
|
|
10
|
+
loaded, by treating the model as an untrusted content source:
|
|
11
|
+
|
|
12
|
+
1. Prompt — extension-aware instructions anchored with the exact first
|
|
13
|
+
line the reply must start with (small models follow examples, not rules).
|
|
14
|
+
2. Extract — strip reasoning blocks and conversational framing, pick the
|
|
15
|
+
best fenced block, slice known document boundaries.
|
|
16
|
+
3. Validate — per-extension structural checks (HTML document shape, JSON
|
|
17
|
+
parses, CSS has rule blocks, refusal/chat detection).
|
|
18
|
+
4. Retry — one corrective attempt that tells the model what was wrong.
|
|
19
|
+
5. Repair — deterministic scaffolds guarantee the user still gets a valid
|
|
20
|
+
file even when the model never produces usable output.
|
|
21
|
+
|
|
22
|
+
The pipeline is pure (no I/O, no FastAPI); the chat layer injects an async
|
|
23
|
+
``generate(context) -> str`` callable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import html as html_lib
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
# ── extraction ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
_THINK_BLOCK_RE = re.compile(
|
|
36
|
+
r"<(think|thinking|reasoning|reflection)>.*?</\1>",
|
|
37
|
+
re.DOTALL | re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
# Unclosed think block (model hit the token limit mid-reasoning).
|
|
40
|
+
_THINK_OPEN_RE = re.compile(r"<(think|thinking|reasoning)>.*\Z", re.DOTALL | re.IGNORECASE)
|
|
41
|
+
|
|
42
|
+
_FENCE_RE = re.compile(r"```([\w.+-]*)[ \t]*\n(.*?)```", re.DOTALL)
|
|
43
|
+
|
|
44
|
+
# Conversational lines that small models prepend/append around the payload.
|
|
45
|
+
_CHAT_LINE_RE = re.compile(
|
|
46
|
+
r"^\s*("
|
|
47
|
+
r"(sure|of course|certainly|okay|ok|alright|great|absolutely)\b[^\n]*"
|
|
48
|
+
r"|here('s| is| are)\b[^\n]*"
|
|
49
|
+
r"|i('ve| have) (created|written|generated|made)\b[^\n]*"
|
|
50
|
+
r"|(below|following) is\b[^\n]*"
|
|
51
|
+
r"|let me know\b[^\n]*"
|
|
52
|
+
r"|hope (this|that) helps[^\n]*"
|
|
53
|
+
r"|feel free\b[^\n]*"
|
|
54
|
+
r"|물론(입니다|이죠|이에요)?[!., ]*[^\n]*"
|
|
55
|
+
r"|네[,!. ][^\n]*"
|
|
56
|
+
r"|알겠습니다[^\n]*"
|
|
57
|
+
r"|다음은[^\n]*(입니다|합니다)[:.]?[^\n]*"
|
|
58
|
+
r"|아래는?[^\n]*(입니다|내용)[^\n]*"
|
|
59
|
+
r"|(요청하신|원하시는)[^\n]*(입니다|만들었습니다|작성했습니다)[^\n]*"
|
|
60
|
+
r"|(파일|내용|코드)[을를]?\s*(생성|작성|만들)[^\n]*"
|
|
61
|
+
r"|도움이 (필요하|되)[^\n]*"
|
|
62
|
+
r"|추가로[^\n]*(말씀|요청)[^\n]*"
|
|
63
|
+
r")\s*$",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_REFUSAL_RE = re.compile(
|
|
68
|
+
r"(i can('|no)?t|i'?m (sorry|unable)|as an ai|cannot assist"
|
|
69
|
+
r"|죄송(하지만|합니다)|할 수 없|불가능합니다|도와드릴 수 없)",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Language tags that identify a fenced block as the payload for an extension.
|
|
74
|
+
_EXT_FENCE_LANGS: Dict[str, Tuple[str, ...]] = {
|
|
75
|
+
".html": ("html", "htm", "xhtml"),
|
|
76
|
+
".htm": ("html", "htm", "xhtml"),
|
|
77
|
+
".css": ("css",),
|
|
78
|
+
".js": ("js", "javascript"),
|
|
79
|
+
".jsx": ("jsx", "javascript"),
|
|
80
|
+
".ts": ("ts", "typescript"),
|
|
81
|
+
".tsx": ("tsx", "typescript"),
|
|
82
|
+
".py": ("py", "python"),
|
|
83
|
+
".json": ("json",),
|
|
84
|
+
".yaml": ("yaml", "yml"),
|
|
85
|
+
".yml": ("yaml", "yml"),
|
|
86
|
+
".toml": ("toml",),
|
|
87
|
+
".md": ("md", "markdown"),
|
|
88
|
+
".markdown": ("md", "markdown"),
|
|
89
|
+
".sql": ("sql",),
|
|
90
|
+
".sh": ("sh", "bash", "shell", "zsh"),
|
|
91
|
+
".xml": ("xml", "svg"),
|
|
92
|
+
".csv": ("csv",),
|
|
93
|
+
".txt": ("txt", "text", "plaintext"),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ext(path: str) -> str:
|
|
98
|
+
dot = path.rfind(".")
|
|
99
|
+
return path[dot:].lower() if dot >= 0 else ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strip_chat_lines(text: str) -> str:
|
|
103
|
+
"""Drop leading/trailing conversational lines around the payload."""
|
|
104
|
+
lines = text.split("\n")
|
|
105
|
+
start, end = 0, len(lines)
|
|
106
|
+
while start < end and (not lines[start].strip() or _CHAT_LINE_RE.match(lines[start])):
|
|
107
|
+
start += 1
|
|
108
|
+
while end > start and (not lines[end - 1].strip() or _CHAT_LINE_RE.match(lines[end - 1])):
|
|
109
|
+
end -= 1
|
|
110
|
+
stripped = "\n".join(lines[start:end]).strip()
|
|
111
|
+
return stripped if stripped else text.strip()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_file_content(raw: str, target_path: str) -> str:
|
|
115
|
+
"""Recover the intended file payload from an arbitrary model reply."""
|
|
116
|
+
text = (raw or "").strip()
|
|
117
|
+
if not text:
|
|
118
|
+
return ""
|
|
119
|
+
text = _THINK_BLOCK_RE.sub("", text)
|
|
120
|
+
text = _THINK_OPEN_RE.sub("", text).strip()
|
|
121
|
+
|
|
122
|
+
ext = _ext(target_path)
|
|
123
|
+
fences = _FENCE_RE.findall(text + ("\n```" if text.count("```") % 2 else ""))
|
|
124
|
+
if fences:
|
|
125
|
+
wanted = _EXT_FENCE_LANGS.get(ext, ())
|
|
126
|
+
matching = [body for lang, body in fences if lang.lower() in wanted]
|
|
127
|
+
candidates = matching if matching else [body for _, body in fences]
|
|
128
|
+
# The payload is the largest block; short blocks are usually usage
|
|
129
|
+
# snippets ("run it with: python app.py").
|
|
130
|
+
content = max(candidates, key=len).strip()
|
|
131
|
+
else:
|
|
132
|
+
content = _strip_chat_lines(text)
|
|
133
|
+
|
|
134
|
+
if ext in (".html", ".htm"):
|
|
135
|
+
content = _slice_html_document(content)
|
|
136
|
+
elif ext == ".json":
|
|
137
|
+
sliced = _slice_json_document(content)
|
|
138
|
+
if sliced is not None:
|
|
139
|
+
content = sliced
|
|
140
|
+
return content.strip()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _slice_html_document(content: str) -> str:
|
|
144
|
+
"""Cut a complete HTML document out of surrounding prose when present."""
|
|
145
|
+
lower = content.lower()
|
|
146
|
+
start = lower.find("<!doctype")
|
|
147
|
+
if start < 0:
|
|
148
|
+
start = lower.find("<html")
|
|
149
|
+
if start > 0:
|
|
150
|
+
content = content[start:]
|
|
151
|
+
lower = lower[start:]
|
|
152
|
+
end = lower.rfind("</html>")
|
|
153
|
+
if end >= 0:
|
|
154
|
+
content = content[: end + len("</html>")]
|
|
155
|
+
return content
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _slice_json_document(content: str) -> Optional[str]:
|
|
159
|
+
"""Return the largest parseable JSON value inside ``content``, if any."""
|
|
160
|
+
candidates: List[str] = [content]
|
|
161
|
+
for opener, closer in (("{", "}"), ("[", "]")):
|
|
162
|
+
start = content.find(opener)
|
|
163
|
+
end = content.rfind(closer)
|
|
164
|
+
if start >= 0 and end > start:
|
|
165
|
+
candidates.append(content[start : end + 1])
|
|
166
|
+
best: Optional[str] = None
|
|
167
|
+
for candidate in candidates:
|
|
168
|
+
try:
|
|
169
|
+
json.loads(candidate)
|
|
170
|
+
except (ValueError, TypeError):
|
|
171
|
+
continue
|
|
172
|
+
if best is None or len(candidate) > len(best):
|
|
173
|
+
best = candidate
|
|
174
|
+
return best
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ── validation ──────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
def looks_like_refusal(content: str) -> bool:
|
|
180
|
+
head = content[:300]
|
|
181
|
+
return bool(_REFUSAL_RE.search(head)) and len(content) < 600
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def validate_file_content(content: str, target_path: str) -> Tuple[bool, str]:
|
|
185
|
+
"""Structural sanity check per file type. Returns (ok, reason)."""
|
|
186
|
+
if not content.strip():
|
|
187
|
+
return False, "empty output"
|
|
188
|
+
if looks_like_refusal(content):
|
|
189
|
+
return False, "the reply was a refusal/chat message, not file content"
|
|
190
|
+
|
|
191
|
+
ext = _ext(target_path)
|
|
192
|
+
if ext in (".html", ".htm"):
|
|
193
|
+
lower = content.lower()
|
|
194
|
+
if "<html" not in lower and "<!doctype" not in lower:
|
|
195
|
+
return False, "not a complete HTML document (missing <!DOCTYPE html>/<html>)"
|
|
196
|
+
if "</html>" not in lower:
|
|
197
|
+
return False, "HTML document is truncated (missing </html>)"
|
|
198
|
+
return True, "ok"
|
|
199
|
+
if ext == ".json":
|
|
200
|
+
try:
|
|
201
|
+
json.loads(content)
|
|
202
|
+
except (ValueError, TypeError) as exc:
|
|
203
|
+
return False, f"invalid JSON: {exc}"
|
|
204
|
+
return True, "ok"
|
|
205
|
+
if ext == ".css":
|
|
206
|
+
if "{" not in content or "}" not in content:
|
|
207
|
+
return False, "no CSS rule blocks found"
|
|
208
|
+
return True, "ok"
|
|
209
|
+
if ext in (".py", ".js", ".jsx", ".ts", ".tsx", ".sh", ".sql"):
|
|
210
|
+
if "```" in content:
|
|
211
|
+
return False, "output still contains Markdown fences"
|
|
212
|
+
return True, "ok"
|
|
213
|
+
return True, "ok"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── prompting ───────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
_FIRST_LINE_HINTS: Dict[str, str] = {
|
|
219
|
+
".html": "<!DOCTYPE html>",
|
|
220
|
+
".htm": "<!DOCTYPE html>",
|
|
221
|
+
".py": "# (python code — imports or code on the first line)",
|
|
222
|
+
".sh": "#!/bin/sh",
|
|
223
|
+
".json": "{",
|
|
224
|
+
".xml": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
_TYPE_RULES: Dict[str, str] = {
|
|
228
|
+
".html": (
|
|
229
|
+
"Produce ONE complete standalone HTML5 document: <!DOCTYPE html>, <html>, "
|
|
230
|
+
"<head> with <meta charset=\"utf-8\"> and a <title>, inline <style> for CSS, "
|
|
231
|
+
"and a closed </html> tag. Do not reference external files."
|
|
232
|
+
),
|
|
233
|
+
".htm": (
|
|
234
|
+
"Produce ONE complete standalone HTML5 document ending with </html>."
|
|
235
|
+
),
|
|
236
|
+
".json": "Produce strictly valid JSON (double quotes, no comments, no trailing commas).",
|
|
237
|
+
".css": "Produce valid CSS rules only.",
|
|
238
|
+
".md": "Produce well-structured Markdown with headings.",
|
|
239
|
+
".markdown": "Produce well-structured Markdown with headings.",
|
|
240
|
+
".csv": "Produce CSV with a header row; comma-separated, one record per line.",
|
|
241
|
+
".py": "Produce complete runnable Python source code.",
|
|
242
|
+
".js": "Produce complete valid JavaScript source code.",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def build_file_generation_context(
|
|
247
|
+
target_path: str,
|
|
248
|
+
user_request: str,
|
|
249
|
+
feedback: Optional[str] = None,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Strict, extension-aware generation instructions.
|
|
252
|
+
|
|
253
|
+
Small models ignore abstract rules but reliably imitate concrete anchors,
|
|
254
|
+
so the prompt pins the exact first line of the expected output.
|
|
255
|
+
"""
|
|
256
|
+
ext = _ext(target_path)
|
|
257
|
+
parts = [
|
|
258
|
+
"You are a file content generator. Your entire reply is saved verbatim "
|
|
259
|
+
f"as the file `{target_path}` — it is NOT shown in a chat.",
|
|
260
|
+
"Rules:",
|
|
261
|
+
"- Output ONLY the raw file content.",
|
|
262
|
+
"- No Markdown code fences (```), no explanations, no greetings, "
|
|
263
|
+
"no text before or after the content.",
|
|
264
|
+
]
|
|
265
|
+
type_rule = _TYPE_RULES.get(ext)
|
|
266
|
+
if type_rule:
|
|
267
|
+
parts.append(f"- {type_rule}")
|
|
268
|
+
first_line = _FIRST_LINE_HINTS.get(ext)
|
|
269
|
+
if first_line:
|
|
270
|
+
parts.append(f"- The very first line of your reply must be: {first_line}")
|
|
271
|
+
if feedback:
|
|
272
|
+
parts.append(
|
|
273
|
+
"Your previous attempt was rejected: "
|
|
274
|
+
f"{feedback}. Fix that and output only the corrected file content."
|
|
275
|
+
)
|
|
276
|
+
parts.append(f"\nUser request: {user_request}")
|
|
277
|
+
return "\n".join(parts)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── repair (deterministic fallback) ─────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def repair_file_content(content: str, target_path: str, user_request: str) -> str:
|
|
283
|
+
"""Turn whatever the model produced into a valid file of the target type.
|
|
284
|
+
|
|
285
|
+
This is the last resort after retries: the user asked for a file, so the
|
|
286
|
+
request must still end in a well-formed file, never an error.
|
|
287
|
+
"""
|
|
288
|
+
ext = _ext(target_path)
|
|
289
|
+
salvage = content.strip()
|
|
290
|
+
if looks_like_refusal(salvage):
|
|
291
|
+
salvage = ""
|
|
292
|
+
|
|
293
|
+
if ext in (".html", ".htm"):
|
|
294
|
+
return _repair_html(salvage, user_request)
|
|
295
|
+
if ext == ".json":
|
|
296
|
+
sliced = _slice_json_document(salvage)
|
|
297
|
+
if sliced is not None:
|
|
298
|
+
return sliced
|
|
299
|
+
return json.dumps(
|
|
300
|
+
{"request": user_request, "content": salvage},
|
|
301
|
+
ensure_ascii=False,
|
|
302
|
+
indent=2,
|
|
303
|
+
)
|
|
304
|
+
if salvage:
|
|
305
|
+
return salvage
|
|
306
|
+
# Nothing usable at all — leave an honest placeholder in the right format.
|
|
307
|
+
comment = {
|
|
308
|
+
".py": "# TODO: model produced no usable content for: ",
|
|
309
|
+
".js": "// TODO: model produced no usable content for: ",
|
|
310
|
+
".css": "/* TODO: model produced no usable content for: ",
|
|
311
|
+
".sh": "# TODO: model produced no usable content for: ",
|
|
312
|
+
".sql": "-- TODO: model produced no usable content for: ",
|
|
313
|
+
}.get(ext, "")
|
|
314
|
+
if ext == ".css":
|
|
315
|
+
return f"{comment}{user_request} */\n"
|
|
316
|
+
if comment:
|
|
317
|
+
return f"{comment}{user_request}\n"
|
|
318
|
+
return f"{user_request}\n"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _repair_html(salvage: str, user_request: str) -> str:
|
|
322
|
+
lower = salvage.lower()
|
|
323
|
+
if "<html" in lower or "<!doctype" in lower:
|
|
324
|
+
# A real document that is merely truncated — close it.
|
|
325
|
+
doc = _slice_html_document(salvage)
|
|
326
|
+
low = doc.lower()
|
|
327
|
+
if "</body>" not in low and "<body" in low:
|
|
328
|
+
doc += "\n</body>"
|
|
329
|
+
if "</html>" not in low:
|
|
330
|
+
doc += "\n</html>"
|
|
331
|
+
return doc
|
|
332
|
+
if re.search(r"<\w+[^>]*>", salvage):
|
|
333
|
+
body = salvage # an HTML fragment — embed as-is
|
|
334
|
+
elif salvage:
|
|
335
|
+
body = "\n".join(
|
|
336
|
+
f" <p>{html_lib.escape(line)}</p>"
|
|
337
|
+
for line in salvage.splitlines()
|
|
338
|
+
if line.strip()
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
body = f" <p>{html_lib.escape(user_request)}</p>"
|
|
342
|
+
title = html_lib.escape(user_request[:60] or "Generated page")
|
|
343
|
+
return (
|
|
344
|
+
"<!DOCTYPE html>\n"
|
|
345
|
+
"<html lang=\"ko\">\n"
|
|
346
|
+
"<head>\n"
|
|
347
|
+
" <meta charset=\"utf-8\">\n"
|
|
348
|
+
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
|
349
|
+
f" <title>{title}</title>\n"
|
|
350
|
+
" <style>\n"
|
|
351
|
+
" body { font-family: system-ui, sans-serif; margin: 2rem auto; "
|
|
352
|
+
"max-width: 720px; line-height: 1.6; padding: 0 1rem; }\n"
|
|
353
|
+
" </style>\n"
|
|
354
|
+
"</head>\n"
|
|
355
|
+
"<body>\n"
|
|
356
|
+
f"{body}\n"
|
|
357
|
+
"</body>\n"
|
|
358
|
+
"</html>"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── filename inference ──────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
_CREATE_VERB_RE = re.compile(
|
|
365
|
+
r"(만들|생성|작성|써\s*줘|저장|create|make|write|generate|build|save)",
|
|
366
|
+
re.IGNORECASE,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Explicit type keyword → default filename. Ordered: first match wins.
|
|
370
|
+
_TYPE_KEYWORDS: Tuple[Tuple[str, str], ...] = (
|
|
371
|
+
(r"\bhtml\b|웹\s*페이지|웹페이지|홈페이지|landing\s*page|web\s*page", "generated_page.html"),
|
|
372
|
+
(r"\bcss\b|스타일\s*시트", "styles.css"),
|
|
373
|
+
(r"\bjavascript\b|\bjs\b\s*(파일|file)|자바스크립트", "script.js"),
|
|
374
|
+
(r"\bpython\b|파이썬", "script.py"),
|
|
375
|
+
(r"\bjson\b", "data.json"),
|
|
376
|
+
(r"\bcsv\b", "data.csv"),
|
|
377
|
+
(r"\byaml\b|\byml\b", "config.yaml"),
|
|
378
|
+
(r"\bxml\b", "data.xml"),
|
|
379
|
+
(r"\bsql\b", "query.sql"),
|
|
380
|
+
(r"마크다운|\bmarkdown\b|\bmd\b\s*(파일|file)", "notes.md"),
|
|
381
|
+
(r"텍스트\s*파일|\btext\s*file\b|\btxt\b", "notes.txt"),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def infer_file_target(message: str) -> Optional[str]:
|
|
386
|
+
"""Infer a filename for creation requests that name a type but no path.
|
|
387
|
+
|
|
388
|
+
"html 파일 만들어줘" previously fell through to the agent JSON loop, which
|
|
389
|
+
small models fail at. Inference keeps such requests on the deterministic
|
|
390
|
+
direct-write path. Deliberately narrow: requires a creation verb and an
|
|
391
|
+
explicit file-type keyword — report/document prose requests keep flowing
|
|
392
|
+
to the document generator.
|
|
393
|
+
"""
|
|
394
|
+
text = (message or "").strip()
|
|
395
|
+
if not text or not _CREATE_VERB_RE.search(text):
|
|
396
|
+
return None
|
|
397
|
+
lower = text.lower()
|
|
398
|
+
for pattern, filename in _TYPE_KEYWORDS:
|
|
399
|
+
if re.search(pattern, lower):
|
|
400
|
+
return filename
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ── orchestration ───────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
async def generate_file_content(
|
|
407
|
+
generate: Callable[[str], Awaitable[Any]],
|
|
408
|
+
*,
|
|
409
|
+
target_path: str,
|
|
410
|
+
user_request: str,
|
|
411
|
+
max_attempts: int = 2,
|
|
412
|
+
) -> Tuple[str, Dict[str, Any]]:
|
|
413
|
+
"""Generate validated file content with any LLM.
|
|
414
|
+
|
|
415
|
+
``generate`` is an async callable ``context -> raw model text``. Runs up
|
|
416
|
+
to ``max_attempts`` model calls (the second with corrective feedback),
|
|
417
|
+
then falls back to deterministic repair, so the returned content is
|
|
418
|
+
always non-empty and structurally valid for the target type.
|
|
419
|
+
"""
|
|
420
|
+
attempts: List[Dict[str, Any]] = []
|
|
421
|
+
feedback: Optional[str] = None
|
|
422
|
+
last_candidate = ""
|
|
423
|
+
for attempt in range(1, max_attempts + 1):
|
|
424
|
+
context = build_file_generation_context(target_path, user_request, feedback=feedback)
|
|
425
|
+
try:
|
|
426
|
+
raw = await generate(context)
|
|
427
|
+
except Exception as exc: # model backend hiccup — repair still delivers
|
|
428
|
+
attempts.append({"attempt": attempt, "valid": False, "reason": f"generation error: {exc}"})
|
|
429
|
+
feedback = "the model call failed"
|
|
430
|
+
continue
|
|
431
|
+
candidate = extract_file_content(str(raw or ""), target_path)
|
|
432
|
+
ok, reason = validate_file_content(candidate, target_path)
|
|
433
|
+
attempts.append({"attempt": attempt, "valid": ok, "reason": reason})
|
|
434
|
+
if ok:
|
|
435
|
+
return candidate, {"attempts": attempts, "repaired": False}
|
|
436
|
+
if len(candidate) > len(last_candidate):
|
|
437
|
+
last_candidate = candidate
|
|
438
|
+
feedback = reason
|
|
439
|
+
repaired = repair_file_content(last_candidate, target_path, user_request)
|
|
440
|
+
return repaired, {"attempts": attempts, "repaired": True}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = [
|
|
444
|
+
"build_file_generation_context",
|
|
445
|
+
"extract_file_content",
|
|
446
|
+
"generate_file_content",
|
|
447
|
+
"infer_file_target",
|
|
448
|
+
"looks_like_refusal",
|
|
449
|
+
"repair_file_content",
|
|
450
|
+
"validate_file_content",
|
|
451
|
+
]
|
|
@@ -9,10 +9,7 @@ from datetime import datetime, timedelta
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Dict, List, Optional
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
def _now() -> datetime:
|
|
14
|
-
return datetime.now()
|
|
15
|
-
|
|
12
|
+
from .timeutil import local_now as _now
|
|
16
13
|
|
|
17
14
|
def _iso(dt: datetime) -> str:
|
|
18
15
|
return dt.isoformat(timespec="seconds")
|
|
@@ -13,8 +13,16 @@ from typing import Any, Dict, Optional
|
|
|
13
13
|
def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
14
14
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
15
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
16
|
-
|
|
16
|
+
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
17
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
18
|
+
handle.write(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
17
19
|
os.replace(tmp_path, path)
|
|
20
|
+
try:
|
|
21
|
+
path.chmod(0o600)
|
|
22
|
+
except OSError:
|
|
23
|
+
# Windows and unusual filesystems may not expose POSIX mode bits; the
|
|
24
|
+
# atomic write is still the safest supported fallback there.
|
|
25
|
+
pass
|
|
18
26
|
|
|
19
27
|
|
|
20
28
|
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
@@ -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.
|
|
17
|
+
LEGACY_COMPATIBILITY_VERSION = "9.2.0"
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
@dataclass(frozen=True)
|
|
@@ -89,13 +89,34 @@ LEGACY_SHIMS: List[LegacyShim] = [
|
|
|
89
89
|
reason="Deployment docs and local launch scripts historically targeted server.py.",
|
|
90
90
|
removal_phase="major-release-after-8.x",
|
|
91
91
|
),
|
|
92
|
+
LegacyShim(
|
|
93
|
+
path="tools/",
|
|
94
|
+
owner="latticeai.tools",
|
|
95
|
+
replacement="from latticeai.tools import execute_tool",
|
|
96
|
+
reason="Existing integrations and tests import the historical root tools package.",
|
|
97
|
+
removal_phase="major-release-after-9.x",
|
|
98
|
+
),
|
|
92
99
|
LegacyShim(
|
|
93
100
|
path="local_knowledge_api.py",
|
|
94
|
-
owner="latticeai.
|
|
95
|
-
replacement="from latticeai.
|
|
101
|
+
owner="latticeai.services.local_knowledge",
|
|
102
|
+
replacement="from latticeai.services.local_knowledge import create_local_knowledge_router",
|
|
96
103
|
reason="Local folder ingestion integrations used the root local knowledge API.",
|
|
97
104
|
removal_phase="requires-api-route-migration",
|
|
98
105
|
),
|
|
106
|
+
LegacyShim(
|
|
107
|
+
path="auto_setup.py",
|
|
108
|
+
owner="latticeai.setup.auto_setup",
|
|
109
|
+
replacement="from latticeai.setup.auto_setup import probe, recommend",
|
|
110
|
+
reason="Historical scripts invoke the zero-config setup module from the repo root.",
|
|
111
|
+
removal_phase="major-release-after-9.x",
|
|
112
|
+
),
|
|
113
|
+
LegacyShim(
|
|
114
|
+
path="setup_wizard.py",
|
|
115
|
+
owner="latticeai.setup.wizard",
|
|
116
|
+
replacement="from latticeai.setup.wizard import scan_environment",
|
|
117
|
+
reason="Older integrations imported the setup wizard from the repo root.",
|
|
118
|
+
removal_phase="major-release-after-9.x",
|
|
119
|
+
),
|
|
99
120
|
]
|
|
100
121
|
|
|
101
122
|
# Shim layers that have completed their compatibility window and were
|
|
@@ -403,6 +403,21 @@ class PluginRegistry:
|
|
|
403
403
|
reason=f"permission '{permission}' not granted at install time",
|
|
404
404
|
))
|
|
405
405
|
|
|
406
|
+
if capability == "tools":
|
|
407
|
+
tool_name = str(args.get("tool") or "").strip()
|
|
408
|
+
declared_tools = {
|
|
409
|
+
str(item).strip()
|
|
410
|
+
for item in (manifest.provides.get("tools") or [])
|
|
411
|
+
if str(item).strip()
|
|
412
|
+
}
|
|
413
|
+
if not tool_name or tool_name not in declared_tools:
|
|
414
|
+
return finish(PluginExecutionResult(
|
|
415
|
+
plugin_id,
|
|
416
|
+
action,
|
|
417
|
+
"blocked",
|
|
418
|
+
reason=f"tool '{tool_name or '<missing>'}' is not declared in provides.tools",
|
|
419
|
+
))
|
|
420
|
+
|
|
406
421
|
runner = runners.get(capability)
|
|
407
422
|
if runner is None:
|
|
408
423
|
return finish(PluginExecutionResult(
|
package/latticeai/core/policy.py
CHANGED
|
@@ -21,6 +21,7 @@ ROLE_CAPABILITIES: Dict[str, Set[str]] = {
|
|
|
21
21
|
"search",
|
|
22
22
|
"files",
|
|
23
23
|
"pipeline",
|
|
24
|
+
"desktop:control",
|
|
24
25
|
},
|
|
25
26
|
"member": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
|
|
26
27
|
"user": {"workspace:read", "workspace:write", "chat", "search", "files", "pipeline"},
|
|
@@ -51,4 +52,3 @@ def require_capability(role: str, capability: str) -> None:
|
|
|
51
52
|
def policy_matrix(roles: Iterable[str] | None = None) -> List[Dict[str, object]]:
|
|
52
53
|
selected = list(roles or ROLE_CAPABILITIES.keys())
|
|
53
54
|
return [{"role": normalize_role(role), "caps": capabilities_for_role(role)} for role in selected]
|
|
54
|
-
|