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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Safe local tools for Lattice AI agent mode.
|
|
2
|
+
|
|
3
|
+
All filesystem operations are confined to LATTICEAI_AGENT_ROOT, defaulting to
|
|
4
|
+
./agent_workspace. Command execution runs without a shell and from inside that
|
|
5
|
+
workspace.
|
|
6
|
+
|
|
7
|
+
The physical implementation belongs to ``latticeai.tools``. The historical
|
|
8
|
+
root ``tools`` package aliases this exact module object, preserving existing
|
|
9
|
+
imports and ``tools.AGENT_ROOT`` monkeypatch/state identity.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import platform
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
from html.parser import HTMLParser
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from latticeai.core.tool_registry import ToolRegistry
|
|
26
|
+
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
27
|
+
|
|
28
|
+
_PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ── base: agent-root sandbox, shared constants, path helpers ──────────────────
|
|
32
|
+
AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
|
|
33
|
+
MAX_FILE_BYTES = 512_000
|
|
34
|
+
MAX_COMMAND_SECONDS = 30
|
|
35
|
+
MAX_BUILD_SECONDS = 180
|
|
36
|
+
MAX_DEPLOY_SECONDS = 300
|
|
37
|
+
MAX_COMMAND_OUTPUT = 12_000
|
|
38
|
+
|
|
39
|
+
BLOCKED_COMMANDS = {
|
|
40
|
+
"rm",
|
|
41
|
+
"rmdir",
|
|
42
|
+
"sudo",
|
|
43
|
+
"su",
|
|
44
|
+
"chmod",
|
|
45
|
+
"chown",
|
|
46
|
+
"curl",
|
|
47
|
+
"wget",
|
|
48
|
+
"ssh",
|
|
49
|
+
"scp",
|
|
50
|
+
"rsync",
|
|
51
|
+
"dd",
|
|
52
|
+
"mkfs",
|
|
53
|
+
"diskutil",
|
|
54
|
+
"launchctl",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ALLOWED_COMMANDS = {
|
|
58
|
+
"pwd",
|
|
59
|
+
"ls",
|
|
60
|
+
"find",
|
|
61
|
+
"cat",
|
|
62
|
+
"head",
|
|
63
|
+
"tail",
|
|
64
|
+
"wc",
|
|
65
|
+
"rg",
|
|
66
|
+
"git",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
|
|
70
|
+
DEPLOY_SCRIPT_NAMES = {
|
|
71
|
+
"deploy",
|
|
72
|
+
"preview",
|
|
73
|
+
"release",
|
|
74
|
+
"package",
|
|
75
|
+
"dist",
|
|
76
|
+
"make",
|
|
77
|
+
"build:installer",
|
|
78
|
+
"build:pkg",
|
|
79
|
+
"build:exe",
|
|
80
|
+
"package:mac",
|
|
81
|
+
"package:win",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
|
|
85
|
+
|
|
86
|
+
TEXT_EXTENSIONS = {
|
|
87
|
+
".css",
|
|
88
|
+
".csv",
|
|
89
|
+
".html",
|
|
90
|
+
".js",
|
|
91
|
+
".json",
|
|
92
|
+
".jsx",
|
|
93
|
+
".md",
|
|
94
|
+
".py",
|
|
95
|
+
".ts",
|
|
96
|
+
".tsx",
|
|
97
|
+
".txt",
|
|
98
|
+
".xml",
|
|
99
|
+
".yaml",
|
|
100
|
+
".yml",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
DOCUMENT_OUTPUT_DIR = "generated_documents"
|
|
104
|
+
PRESENTATION_OUTPUT_DIR = "generated_presentations"
|
|
105
|
+
SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ToolError(ValueError):
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def ensure_agent_root() -> Path:
|
|
113
|
+
AGENT_ROOT.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
return AGENT_ROOT
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_path(path: str = "") -> Path:
|
|
118
|
+
ensure_agent_root()
|
|
119
|
+
if not path:
|
|
120
|
+
return AGENT_ROOT
|
|
121
|
+
candidate = (AGENT_ROOT / path).resolve()
|
|
122
|
+
if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
|
|
123
|
+
raise ToolError("Path escapes the agent workspace.")
|
|
124
|
+
return candidate
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _relative(path: Path) -> str:
|
|
128
|
+
return str(path.relative_to(AGENT_ROOT))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ── document / local / read constants (shared by submodules) ──────────────────
|
|
132
|
+
PDF_OUTPUT_DIR = "generated_pdfs"
|
|
133
|
+
LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# CJK-capable fonts (Korean + Chinese + Japanese)
|
|
137
|
+
_CJK_FONT_CANDIDATES = [
|
|
138
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
|
|
139
|
+
"/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
|
|
140
|
+
"/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
|
|
141
|
+
"/Library/Fonts/NanumGothic.ttf", # Korean
|
|
142
|
+
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
_SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
|
|
146
|
+
DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── focused tool submodules (re-exported flat for import compatibility) ───────
|
|
150
|
+
from latticeai.tools.computer import * # noqa: E402,F401,F403
|
|
151
|
+
from latticeai.tools.filesystem import * # noqa: E402,F401,F403
|
|
152
|
+
from latticeai.tools.documents import * # noqa: E402,F401,F403
|
|
153
|
+
from latticeai.tools.local_files import * # noqa: E402,F401,F403
|
|
154
|
+
from latticeai.tools.knowledge import * # noqa: E402,F401,F403
|
|
155
|
+
from latticeai.tools.network import * # noqa: E402,F401,F403
|
|
156
|
+
from latticeai.tools.commands import * # noqa: E402,F401,F403
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── tool registry: the single name → invocation source of truth ───────────────
|
|
160
|
+
def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
161
|
+
rows = args.get("rows", [])
|
|
162
|
+
if isinstance(rows, str):
|
|
163
|
+
rows = json.loads(rows)
|
|
164
|
+
return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
|
|
168
|
+
slides = args.get("slides", [])
|
|
169
|
+
if isinstance(slides, str):
|
|
170
|
+
slides = json.loads(slides)
|
|
171
|
+
return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _knowledge_scope(args: Dict[str, Any]) -> Dict[str, str]:
|
|
175
|
+
"""Return the authenticated scope injected by server/runtime adapters."""
|
|
176
|
+
workspace_id = str(args.get("workspace_id") or "").strip()
|
|
177
|
+
user_email = str(args.get("user_email") or "").strip().lower()
|
|
178
|
+
if not workspace_id or not user_email:
|
|
179
|
+
raise ToolError(
|
|
180
|
+
"Knowledge tool execution requires an authenticated workspace and user scope."
|
|
181
|
+
)
|
|
182
|
+
return {"workspace_id": workspace_id, "user_email": user_email}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── Tool registry: the single source of truth for name → invocation ───────────
|
|
186
|
+
# Each entry binds the args dict to a tool function. ``execute_tool`` is a
|
|
187
|
+
# lookup over this table — adding a tool means adding one entry here, not
|
|
188
|
+
# editing an if/elif chain. server.py's governance map and catalog brief are
|
|
189
|
+
# checked against ``registered_tools()`` so the three never silently drift.
|
|
190
|
+
TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
|
191
|
+
# filesystem
|
|
192
|
+
"list_dir": lambda a: list_dir(a.get("path", ".")),
|
|
193
|
+
"workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
|
|
194
|
+
"read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
|
|
195
|
+
"write_file": lambda a: write_file(a["path"], a.get("content", "")),
|
|
196
|
+
"edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
|
|
197
|
+
"grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
|
|
198
|
+
"search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
|
|
199
|
+
"inspect_html": lambda a: inspect_html(a["path"]),
|
|
200
|
+
"preview_url": lambda a: preview_url(a.get("path", "index.html")),
|
|
201
|
+
# planning
|
|
202
|
+
"todo_read": lambda a: todo_read(),
|
|
203
|
+
"todo_write": lambda a: todo_write(a.get("todos") or []),
|
|
204
|
+
# documents
|
|
205
|
+
"create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
|
|
206
|
+
"create_xlsx": _h_create_xlsx,
|
|
207
|
+
"create_pptx": _h_create_pptx,
|
|
208
|
+
"create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
|
|
209
|
+
"create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
|
|
210
|
+
# local filesystem
|
|
211
|
+
"local_list": lambda a: local_list(a["path"]),
|
|
212
|
+
"local_read": lambda a: local_read(a["path"]),
|
|
213
|
+
"local_write": lambda a: local_write(a["path"], a.get("content", "")),
|
|
214
|
+
"read_document": lambda a: read_document(a["path"]),
|
|
215
|
+
"network_status": lambda a: network_status(),
|
|
216
|
+
# computer use
|
|
217
|
+
"computer_screenshot": lambda a: computer_screenshot(),
|
|
218
|
+
"computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
|
|
219
|
+
"computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
|
|
220
|
+
"computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
|
|
221
|
+
"computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
|
|
222
|
+
"computer_key": lambda a: computer_key(a["key"]),
|
|
223
|
+
"computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
|
|
224
|
+
"computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
|
|
225
|
+
"computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
|
|
226
|
+
"computer_status": lambda a: computer_status(),
|
|
227
|
+
"chrome_status": lambda a: desktop_bridge_status(),
|
|
228
|
+
"computer_use_status": lambda a: desktop_bridge_status(),
|
|
229
|
+
"vision_analyze": lambda a: vision_analyze(a.get("image_b64", ""), a.get("prompt", "Describe this image in detail. Be concise.")),
|
|
230
|
+
# knowledge / obsidian
|
|
231
|
+
"knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title"), **_knowledge_scope(a)),
|
|
232
|
+
"knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5), **_knowledge_scope(a)),
|
|
233
|
+
"knowledge_tree": lambda a: knowledge_tree(**_knowledge_scope(a)),
|
|
234
|
+
"obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title"), **_knowledge_scope(a)),
|
|
235
|
+
"obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5), **_knowledge_scope(a)),
|
|
236
|
+
"obsidian_tree": lambda a: obsidian_tree(**_knowledge_scope(a)),
|
|
237
|
+
# git (read-only)
|
|
238
|
+
"git_status": lambda a: git_status(a.get("cwd")),
|
|
239
|
+
"git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
|
|
240
|
+
"git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
|
|
241
|
+
"git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
|
|
242
|
+
# exec
|
|
243
|
+
"run_command": lambda a: run_command(a["command"], a.get("cwd")),
|
|
244
|
+
"build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
|
|
245
|
+
"deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
DEFAULT_TOOL_REGISTRY = ToolRegistry(TOOL_HANDLERS)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def registered_tools() -> frozenset:
|
|
253
|
+
"""Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
|
|
254
|
+
return DEFAULT_TOOL_REGISTRY.registered_tools()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
258
|
+
return DEFAULT_TOOL_REGISTRY.execute(action, args, error_cls=ToolError)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
__all__ = [
|
|
262
|
+
"AGENT_ROOT", "ToolError", "ensure_agent_root",
|
|
263
|
+
"list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
|
|
264
|
+
"search_files", "inspect_html", "preview_url", "create_web_project",
|
|
265
|
+
"todo_read", "todo_write",
|
|
266
|
+
"create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
|
|
267
|
+
"local_list", "local_read", "local_write", "desktop_bridge_status",
|
|
268
|
+
"knowledge_save", "knowledge_search", "knowledge_tree", "knowledge_scope_root",
|
|
269
|
+
"obsidian_save", "obsidian_search", "obsidian_tree",
|
|
270
|
+
"network_status",
|
|
271
|
+
"computer_screenshot", "computer_open_app", "computer_open_url",
|
|
272
|
+
"computer_click", "computer_type", "computer_key", "computer_scroll",
|
|
273
|
+
"computer_move", "computer_drag", "computer_status", "vision_analyze",
|
|
274
|
+
"run_command", "build_project", "deploy_project",
|
|
275
|
+
"git_status", "git_diff", "git_log", "git_show",
|
|
276
|
+
"TOOL_HANDLERS", "DEFAULT_TOOL_REGISTRY", "registered_tools", "execute_tool",
|
|
277
|
+
"BRAIN_DIR", "STRUCTURE",
|
|
278
|
+
]
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import shlex
|
|
7
|
+
import shutil
|
|
6
8
|
import subprocess
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from typing import Any, Dict, List, Optional
|
|
9
11
|
|
|
10
|
-
import tools
|
|
11
|
-
from tools import (
|
|
12
|
+
import latticeai.tools as tools
|
|
13
|
+
from latticeai.tools import (
|
|
12
14
|
ToolError,
|
|
13
15
|
ensure_agent_root,
|
|
14
16
|
_resolve_path,
|
|
@@ -25,7 +27,46 @@ from tools import (
|
|
|
25
27
|
)
|
|
26
28
|
|
|
27
29
|
# find(1) flags that execute or delete; checked in run_command.
|
|
28
|
-
_BLOCKED_FIND_FLAGS = {
|
|
30
|
+
_BLOCKED_FIND_FLAGS = {
|
|
31
|
+
"-delete",
|
|
32
|
+
"-exec",
|
|
33
|
+
"-execdir",
|
|
34
|
+
"-fls",
|
|
35
|
+
"-fprint",
|
|
36
|
+
"-fprintf",
|
|
37
|
+
"-ok",
|
|
38
|
+
"-okdir",
|
|
39
|
+
}
|
|
40
|
+
_BLOCKED_RG_FLAGS = {"--pre", "--pre-glob"}
|
|
41
|
+
_SAFE_EXECUTABLE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _argument_value(argument: str) -> str:
|
|
45
|
+
if argument.startswith("-") and "=" in argument:
|
|
46
|
+
return argument.split("=", 1)[1]
|
|
47
|
+
return argument
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_command_paths(parts: List[str], workdir: Path) -> None:
|
|
51
|
+
"""Reject traversal and symlink escapes hidden in command arguments."""
|
|
52
|
+
for argument in parts[1:]:
|
|
53
|
+
value = _argument_value(argument)
|
|
54
|
+
if not value or value == "/dev/null" or (argument.startswith("-") and "=" not in argument):
|
|
55
|
+
continue
|
|
56
|
+
candidate_path = Path(value).expanduser()
|
|
57
|
+
if candidate_path.is_absolute():
|
|
58
|
+
raise ToolError(f"Absolute paths in command arguments are not allowed: {value}")
|
|
59
|
+
if ".." in candidate_path.parts:
|
|
60
|
+
raise ToolError(f"Path traversal in command arguments is not allowed: {value}")
|
|
61
|
+
|
|
62
|
+
candidate = workdir / candidate_path
|
|
63
|
+
# Existing arguments (including symlinks) and explicit path-shaped
|
|
64
|
+
# values must resolve inside the workspace. Other values can be search
|
|
65
|
+
# patterns, counts, or expressions and are not interpreted as paths.
|
|
66
|
+
if candidate.exists() or candidate.is_symlink() or "/" in value or "\\" in value:
|
|
67
|
+
resolved = candidate.resolve(strict=False)
|
|
68
|
+
if resolved != tools.AGENT_ROOT and tools.AGENT_ROOT not in resolved.parents:
|
|
69
|
+
raise ToolError(f"Path escapes the agent workspace: {value}")
|
|
29
70
|
|
|
30
71
|
|
|
31
72
|
def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
@@ -35,6 +76,8 @@ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
|
35
76
|
raise ToolError("Command is empty.")
|
|
36
77
|
|
|
37
78
|
executable = Path(parts[0]).name
|
|
79
|
+
if parts[0] != executable:
|
|
80
|
+
raise ToolError("Executable paths are not allowed.")
|
|
38
81
|
if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
|
|
39
82
|
raise ToolError(f"Command is not allowed: {executable}")
|
|
40
83
|
if executable == "git":
|
|
@@ -45,20 +88,37 @@ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
|
45
88
|
blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
|
|
46
89
|
if blocked:
|
|
47
90
|
raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
91
|
+
if executable == "rg":
|
|
92
|
+
blocked = [
|
|
93
|
+
flag
|
|
94
|
+
for flag in parts[1:]
|
|
95
|
+
if any(flag == denied or flag.startswith(f"{denied}=") for denied in _BLOCKED_RG_FLAGS)
|
|
96
|
+
]
|
|
97
|
+
if blocked:
|
|
98
|
+
raise ToolError(f"rg flags are not allowed: {', '.join(blocked)}")
|
|
51
99
|
|
|
52
100
|
workdir = _resolve_path(cwd or ".")
|
|
53
101
|
if not workdir.exists() or not workdir.is_dir():
|
|
54
102
|
raise ToolError("Working directory does not exist.")
|
|
103
|
+
_validate_command_paths(parts, workdir)
|
|
104
|
+
|
|
105
|
+
executable_path = shutil.which(executable, path=_SAFE_EXECUTABLE_PATH)
|
|
106
|
+
if not executable_path:
|
|
107
|
+
raise ToolError(f"Allowed command is not installed: {executable}")
|
|
108
|
+
environment = {
|
|
109
|
+
"HOME": str(tools.AGENT_ROOT),
|
|
110
|
+
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
|
111
|
+
"LC_ALL": os.environ.get("LC_ALL", "C.UTF-8"),
|
|
112
|
+
"PATH": _SAFE_EXECUTABLE_PATH,
|
|
113
|
+
}
|
|
55
114
|
|
|
56
115
|
try:
|
|
57
116
|
completed = subprocess.run(
|
|
58
|
-
parts,
|
|
117
|
+
[executable_path, *parts[1:]],
|
|
59
118
|
cwd=workdir,
|
|
60
119
|
capture_output=True,
|
|
61
120
|
text=True,
|
|
121
|
+
env=environment,
|
|
62
122
|
timeout=MAX_COMMAND_SECONDS,
|
|
63
123
|
check=False,
|
|
64
124
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Filesystem tools confined to the agent workspace.
|
|
2
2
|
|
|
3
3
|
read/write/edit/grep/search, todo list, HTML inspection and web scaffolding.
|
|
4
|
-
Path resolution reads ``tools.AGENT_ROOT`` so tests can redirect the sandbox.
|
|
4
|
+
Path resolution reads ``latticeai.tools.AGENT_ROOT`` so tests can redirect the sandbox.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -12,8 +12,8 @@ from html.parser import HTMLParser
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import Any, Dict, List, Optional
|
|
14
14
|
|
|
15
|
-
import tools
|
|
16
|
-
from tools import (
|
|
15
|
+
import latticeai.tools as tools
|
|
16
|
+
from latticeai.tools import (
|
|
17
17
|
ToolError,
|
|
18
18
|
ensure_agent_root,
|
|
19
19
|
_resolve_path,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Knowledge-base / Obsidian vault tools over the local brain directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from p_reinforce import BRAIN_DIR, STRUCTURE
|
|
10
|
+
from latticeai.tools import MAX_FILE_BYTES, ToolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_brain_folder(folder: str) -> str:
|
|
14
|
+
if folder not in STRUCTURE:
|
|
15
|
+
raise ToolError(f"Unknown knowledge folder: {folder}")
|
|
16
|
+
return folder
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _scope_digest(kind: str, value: str) -> str:
|
|
20
|
+
digest = hashlib.sha256(f"{kind}\0{value}".encode("utf-8")).hexdigest()[:24]
|
|
21
|
+
return f"{kind}-{digest}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def knowledge_scope_root(
|
|
25
|
+
*,
|
|
26
|
+
workspace_id: Optional[str] = None,
|
|
27
|
+
user_email: Optional[str] = None,
|
|
28
|
+
) -> Path:
|
|
29
|
+
"""Resolve a private vault partition for an authenticated workspace user.
|
|
30
|
+
|
|
31
|
+
Calls without either value preserve the historical single-user/local vault
|
|
32
|
+
API. Authenticated tool surfaces always provide both values. A half-scoped
|
|
33
|
+
request fails closed so a missing identity can never fall back to the
|
|
34
|
+
shared legacy vault.
|
|
35
|
+
"""
|
|
36
|
+
workspace = str(workspace_id or "").strip()
|
|
37
|
+
user = str(user_email or "").strip().lower()
|
|
38
|
+
if not workspace and not user:
|
|
39
|
+
return BRAIN_DIR
|
|
40
|
+
if not workspace or not user:
|
|
41
|
+
raise ToolError("Knowledge tools require both workspace_id and user_email.")
|
|
42
|
+
return (
|
|
43
|
+
BRAIN_DIR
|
|
44
|
+
/ ".lattice-scopes"
|
|
45
|
+
/ _scope_digest("workspace", workspace)
|
|
46
|
+
/ _scope_digest("user", user)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def knowledge_save(
|
|
51
|
+
content: str,
|
|
52
|
+
folder: str = "00_Raw",
|
|
53
|
+
title: Optional[str] = None,
|
|
54
|
+
*,
|
|
55
|
+
workspace_id: Optional[str] = None,
|
|
56
|
+
user_email: Optional[str] = None,
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
folder = _safe_brain_folder(folder)
|
|
59
|
+
if not content:
|
|
60
|
+
raise ToolError("Knowledge content is required.")
|
|
61
|
+
if len(content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
62
|
+
raise ToolError("Knowledge content is too large.")
|
|
63
|
+
|
|
64
|
+
root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
|
|
65
|
+
target_dir = root / folder
|
|
66
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
safe_title = title or content.strip().splitlines()[0][:60] or "note"
|
|
68
|
+
safe_title = "".join(ch if ch.isalnum() or ch in (" ", "-", "_") else "" for ch in safe_title).strip()
|
|
69
|
+
safe_title = "_".join(safe_title.split()) or "note"
|
|
70
|
+
filename = f"{safe_title}.md"
|
|
71
|
+
target = target_dir / filename
|
|
72
|
+
counter = 2
|
|
73
|
+
while target.exists():
|
|
74
|
+
target = target_dir / f"{safe_title}_{counter}.md"
|
|
75
|
+
counter += 1
|
|
76
|
+
target.write_text(content, encoding="utf-8")
|
|
77
|
+
return {"folder": folder, "filename": target.name, "path": str(target)}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def knowledge_search(
|
|
81
|
+
query: str,
|
|
82
|
+
max_results: int = 5,
|
|
83
|
+
*,
|
|
84
|
+
workspace_id: Optional[str] = None,
|
|
85
|
+
user_email: Optional[str] = None,
|
|
86
|
+
) -> Dict[str, Any]:
|
|
87
|
+
if not query:
|
|
88
|
+
raise ToolError("Query is required.")
|
|
89
|
+
max_results = max(1, min(int(max_results), 20))
|
|
90
|
+
query_lower = query.lower()
|
|
91
|
+
results: List[Dict[str, Any]] = []
|
|
92
|
+
root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
|
|
93
|
+
|
|
94
|
+
for file_path in root.rglob("*.md"):
|
|
95
|
+
if len(results) >= max_results:
|
|
96
|
+
break
|
|
97
|
+
try:
|
|
98
|
+
content = file_path.read_text(encoding="utf-8")
|
|
99
|
+
except UnicodeDecodeError:
|
|
100
|
+
continue
|
|
101
|
+
if query_lower in content.lower() or query_lower in file_path.name.lower():
|
|
102
|
+
results.append(
|
|
103
|
+
{
|
|
104
|
+
"path": str(file_path),
|
|
105
|
+
"relative_path": str(file_path.relative_to(root)),
|
|
106
|
+
"preview": content[:500],
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return {"query": query, "results": results}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def knowledge_tree(
|
|
114
|
+
*,
|
|
115
|
+
workspace_id: Optional[str] = None,
|
|
116
|
+
user_email: Optional[str] = None,
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
entries: List[Dict[str, Any]] = []
|
|
119
|
+
scope_root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
|
|
120
|
+
for folder in STRUCTURE:
|
|
121
|
+
root = scope_root / folder
|
|
122
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
for file_path in sorted(root.rglob("*.md")):
|
|
124
|
+
entries.append(
|
|
125
|
+
{
|
|
126
|
+
"folder": folder,
|
|
127
|
+
"relative_path": str(file_path.relative_to(scope_root)),
|
|
128
|
+
"size": file_path.stat().st_size,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
return {"root": str(scope_root), "entries": entries}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def obsidian_save(
|
|
135
|
+
content: str,
|
|
136
|
+
folder: str = "00_Raw",
|
|
137
|
+
title: Optional[str] = None,
|
|
138
|
+
*,
|
|
139
|
+
workspace_id: Optional[str] = None,
|
|
140
|
+
user_email: Optional[str] = None,
|
|
141
|
+
) -> Dict[str, Any]:
|
|
142
|
+
root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
|
|
143
|
+
result = knowledge_save(
|
|
144
|
+
content,
|
|
145
|
+
folder,
|
|
146
|
+
title,
|
|
147
|
+
workspace_id=workspace_id,
|
|
148
|
+
user_email=user_email,
|
|
149
|
+
)
|
|
150
|
+
result["vault_root"] = str(root)
|
|
151
|
+
result["obsidian_uri_hint"] = f"obsidian://open?path={result['path']}"
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def obsidian_search(
|
|
156
|
+
query: str,
|
|
157
|
+
max_results: int = 5,
|
|
158
|
+
*,
|
|
159
|
+
workspace_id: Optional[str] = None,
|
|
160
|
+
user_email: Optional[str] = None,
|
|
161
|
+
) -> Dict[str, Any]:
|
|
162
|
+
root = knowledge_scope_root(workspace_id=workspace_id, user_email=user_email)
|
|
163
|
+
result = knowledge_search(
|
|
164
|
+
query,
|
|
165
|
+
max_results,
|
|
166
|
+
workspace_id=workspace_id,
|
|
167
|
+
user_email=user_email,
|
|
168
|
+
)
|
|
169
|
+
result["vault_root"] = str(root)
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def obsidian_tree(
|
|
174
|
+
*,
|
|
175
|
+
workspace_id: Optional[str] = None,
|
|
176
|
+
user_email: Optional[str] = None,
|
|
177
|
+
) -> Dict[str, Any]:
|
|
178
|
+
return knowledge_tree(workspace_id=workspace_id, user_email=user_email)
|
|
@@ -6,7 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
from typing import Any, Dict
|
|
7
7
|
|
|
8
8
|
from latticeai.core.tool_registry import LOCAL_WRITE_BLOCKED_PREFIXES
|
|
9
|
-
from tools import ToolError, LOCAL_MAX_FILE_BYTES
|
|
9
|
+
from latticeai.tools import ToolError, LOCAL_MAX_FILE_BYTES
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def local_list(path: str) -> Dict[str, Any]:
|