pal-code 0.1.0__py3-none-any.whl
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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/studio/planner.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The builder bot: it reads what the person wants and proposes a plan: which agents, which tools, what may be accessed, which model.
|
|
2
|
+
|
|
3
|
+
The bot is a LOCAL model (the strongest one Ollama has). It is asked for one JSON object; the answer is untrusted, so it goes through the plan
|
|
4
|
+
normaliser, which enforces every safety rule. If the model's answer cannot be read after one retry, simple keyword rules make the plan instead, and the
|
|
5
|
+
plan says so. The bot only ever proposes: it cannot switch on a risky permission (see plan.py).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from pal.llm_wrapper.components.base import LLMSyscall
|
|
14
|
+
from pal.llm_wrapper.modes import PROVIDER_KEYS
|
|
15
|
+
from pal.studio import plan as planlib
|
|
16
|
+
from pal.studio.catalog import OFFERED_TOOLS, PERMISSIONS, ROLES
|
|
17
|
+
|
|
18
|
+
EXAMPLE = {
|
|
19
|
+
"product": {"name": "Meeting notes to action items", "summary": "A page where I paste meeting notes and get a list of action items.", "kind": "web_app"},
|
|
20
|
+
"roles": ["designer", "coder", "docs", "reviewer"],
|
|
21
|
+
"tools": [],
|
|
22
|
+
"permissions": {"write_project": True, "modify_own_files": True, "read_references": True, "network_access": False},
|
|
23
|
+
"model": {"mode": "local", "model": "<one of the installed models>"},
|
|
24
|
+
"steps": [{"role": "designer", "task": "Design the page layout and styles."}, {"role": "coder", "task": "Write the logic that finds action items."},
|
|
25
|
+
{"role": "docs", "task": "Write the README."}, {"role": "reviewer", "task": "Review all files and fix mistakes."}],
|
|
26
|
+
"reasons": {"role:designer": "the product has a user interface", "role:reviewer": "a second look catches mistakes small models make", "model": "the strongest local model"},
|
|
27
|
+
"metrics": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def system_prompt(info: dict) -> str:
|
|
32
|
+
tools = [t for t in OFFERED_TOOLS if t in info.get("ready_tools", OFFERED_TOOLS)]
|
|
33
|
+
from pal import tool_catalog
|
|
34
|
+
return "\n".join([
|
|
35
|
+
"You are the planner of Pal Studio. A person tells you what they want built; you choose HOW it will be built.",
|
|
36
|
+
"Reply with ONE JSON object and nothing else: no prose, no code fences.",
|
|
37
|
+
"",
|
|
38
|
+
"Agents you may choose (ids): " + "; ".join(f"{r.id} = {r.description}" for r in ROLES.values()),
|
|
39
|
+
"Tools you may choose (names; most builds need none): " + "; ".join(f"{t} = {tool_catalog.BY_NAME[t].summary}" for t in tools),
|
|
40
|
+
"Permissions (true/false): " + "; ".join(f"{p.id} = {p.description}" + (" YOU MAY ONLY SUGGEST THIS; the person switches it on" if p.risky else "") + (" (locked off)" if p.locked else "") for p in PERMISSIONS),
|
|
41
|
+
"Models: local (Ollama), installed: " + (", ".join(info.get("installed", [])) or "none") + ". Use mode \"local\" unless the product clearly needs a stronger model, "
|
|
42
|
+
"then use mode \"api\" with a provider (" + ", ".join(PROVIDER_KEYS) + ") and a model name.",
|
|
43
|
+
"Only choose what the product needs: a small page needs designer, coder and reviewer; a script may need only coder and tester. Always include coder.",
|
|
44
|
+
"Each step is {\"role\", \"task\"} in build order, at most 8, ending with the reviewer when there is one. kind is one of: " + ", ".join(planlib.KINDS) + ".",
|
|
45
|
+
"Give a short reason (under 20 words) for each role, tool and the model, under \"reasons\", keyed like role:coder, tool:calculator, model.",
|
|
46
|
+
"",
|
|
47
|
+
"Example of the shape:", json.dumps(EXAMPLE),
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_json(text: str) -> dict:
|
|
52
|
+
"""The first complete JSON object in a model's reply (fences, prose before or after, and trailing commas are tolerated)."""
|
|
53
|
+
text = text.strip()
|
|
54
|
+
start = text.find("{")
|
|
55
|
+
while start >= 0:
|
|
56
|
+
depth, in_str, esc = 0, False, False
|
|
57
|
+
for i in range(start, len(text)):
|
|
58
|
+
c = text[i]
|
|
59
|
+
if in_str:
|
|
60
|
+
esc = (c == "\\" and not esc)
|
|
61
|
+
if c == '"' and not esc:
|
|
62
|
+
in_str = False
|
|
63
|
+
continue
|
|
64
|
+
if c == '"':
|
|
65
|
+
in_str = True
|
|
66
|
+
elif c == "{":
|
|
67
|
+
depth += 1
|
|
68
|
+
elif c == "}":
|
|
69
|
+
depth -= 1
|
|
70
|
+
if depth == 0:
|
|
71
|
+
blob = re.sub(r",(\s*[}\]])", r"\1", text[start:i + 1])
|
|
72
|
+
try:
|
|
73
|
+
obj = json.loads(blob)
|
|
74
|
+
if isinstance(obj, dict):
|
|
75
|
+
return obj
|
|
76
|
+
except ValueError:
|
|
77
|
+
pass
|
|
78
|
+
break
|
|
79
|
+
start = text.find("{", start + 1)
|
|
80
|
+
raise ValueError("no JSON object in the reply")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# -- the fallback: simple keyword rules ------------------------------------------------------------------------------------------------------------
|
|
84
|
+
_WEB = re.compile(r"\b(web ?site|web ?page|landing|page|web app|webapp|html|browser|dashboard|ui|interface|portfolio|calculator app|game)\b", re.I)
|
|
85
|
+
_CLI = re.compile(r"\b(cli|command[- ]line|script|automation|scraper|converter|parser|csv|tool|bot)\b", re.I)
|
|
86
|
+
_TEST = re.compile(r"\btests?\b|unit test|pytest", re.I)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def heuristic_plan(command: str, info: dict) -> dict:
|
|
90
|
+
web, cli = bool(_WEB.search(command)), bool(_CLI.search(command))
|
|
91
|
+
kind = "web_app" if web or not cli else "cli_tool"
|
|
92
|
+
roles = ["designer", "coder", "docs", "reviewer"] if kind == "web_app" else ["coder", "tester", "docs", "reviewer"]
|
|
93
|
+
if _TEST.search(command) and "tester" not in roles:
|
|
94
|
+
roles.insert(-2, "tester")
|
|
95
|
+
name = " ".join(command.split()[:6]).strip(" .,:;") or "My project"
|
|
96
|
+
return {"product": {"name": name[:60], "summary": " ".join(command.split())[:400], "kind": kind}, "roles": roles, "tools": [],
|
|
97
|
+
"model": {"mode": "local", "model": info.get("recommended") or ""},
|
|
98
|
+
"reasons": {"role:coder": "every build needs code written", "model": "the strongest local model that is installed"}}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_ROLE_WORDS = {"designer": ("designer", "design", "styling", "layout"), "coder": ("coder", "code", "developer"), "tester": ("tester", "tests", "testing", "test"),
|
|
102
|
+
"docs": ("docs", "documentation", "readme", "docs writer"), "reviewer": ("reviewer", "review")}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def heuristic_revise(plan: dict, instruction: str, info: dict) -> dict:
|
|
106
|
+
"""A few plain edits when the model's answer cannot be read: add or remove an agent or a tool, switch the comparison on or off."""
|
|
107
|
+
text = instruction.lower()
|
|
108
|
+
new = json.loads(json.dumps(plan))
|
|
109
|
+
adding = bool(re.search(r"\b(add|include|also|with|use|need)\b", text))
|
|
110
|
+
removing = bool(re.search(r"\b(remove|drop|without|no|skip|don't|do not|exclude)\b", text))
|
|
111
|
+
for role, words in _ROLE_WORDS.items():
|
|
112
|
+
if any(re.search(rf"\b{re.escape(w)}\b", text) for w in words):
|
|
113
|
+
if removing and not adding and role in new["roles"]:
|
|
114
|
+
new["roles"].remove(role)
|
|
115
|
+
elif adding and role not in new["roles"]:
|
|
116
|
+
new["roles"].append(role)
|
|
117
|
+
from pal import tool_catalog
|
|
118
|
+
for t in OFFERED_TOOLS:
|
|
119
|
+
names = {t, t.replace("_", " "), tool_catalog.BY_NAME[t].title.lower()}
|
|
120
|
+
if any(n in text for n in names):
|
|
121
|
+
if removing and not adding and t in new["tools"]:
|
|
122
|
+
new["tools"].remove(t)
|
|
123
|
+
elif t not in new["tools"] and not removing:
|
|
124
|
+
new["tools"].append(t)
|
|
125
|
+
if re.search(r"\b(compare|comparison|twice|metrics?|with and without)\b", text):
|
|
126
|
+
new["metrics"] = not removing
|
|
127
|
+
new["steps"] = [] # the steps follow the agents
|
|
128
|
+
return new
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PlanResult:
|
|
133
|
+
plan: dict
|
|
134
|
+
notes: list[str] = field(default_factory=list)
|
|
135
|
+
source: str = "model" # "model" | "fallback"
|
|
136
|
+
changes: list[str] = field(default_factory=list)
|
|
137
|
+
message: str = ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def describe(plan: dict, source: str, notes: list[str], model_note: str = "") -> str:
|
|
141
|
+
names = [ROLES[r].name for r in plan["roles"]]
|
|
142
|
+
pm = plan["model"]
|
|
143
|
+
parts = [f"Here is my plan for **{plan['product']['name']}**.",
|
|
144
|
+
f"Agents: {', '.join(names)}." + (f" Tools: {', '.join(plan['tools'])}." if plan["tools"] else " No extra tools."),
|
|
145
|
+
"It saves files only inside its own project folder, does not run the code it writes, and " + ("may go online." if plan["permissions"].get("network_access") else "does not go online.")]
|
|
146
|
+
parts.append(f"Model: {pm['model'] or 'none chosen'} ({'API, ' + pm['provider'] if pm['mode'] == 'api' else 'local, on this machine'})." + (f" {model_note}" if model_note else ""))
|
|
147
|
+
if source == "fallback":
|
|
148
|
+
parts.append("I could not read a plan from the model, so I used simple rules. Please check it.")
|
|
149
|
+
parts += notes[:4]
|
|
150
|
+
parts.append("Change anything on the right, or tell me what to change, then press Build.")
|
|
151
|
+
return " ".join(parts)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Planner:
|
|
155
|
+
def __init__(self, core, info: dict):
|
|
156
|
+
"""`core` is a wrapper to chat with; `info` has installed (model names), recommended, ready_tools."""
|
|
157
|
+
self.core, self.info = core, info
|
|
158
|
+
|
|
159
|
+
def _ask(self, system: str, user: str, history: list[dict] | None = None) -> str:
|
|
160
|
+
msgs = [{"role": "system", "content": system}, *(history or []), {"role": "user", "content": user}]
|
|
161
|
+
return self.core.chat(LLMSyscall(messages=msgs, temperature=0.0, max_tokens=1800)).text
|
|
162
|
+
|
|
163
|
+
def _normalize(self, raw: dict, user_set) -> tuple[dict, list[str]]:
|
|
164
|
+
return planlib.normalize(raw, installed=self.info.get("installed", []), recommended=self.info.get("recommended"), user_set=user_set,
|
|
165
|
+
ready_tools=self.info.get("ready_tools"))
|
|
166
|
+
|
|
167
|
+
def _from_model(self, user: str, user_set) -> tuple[dict, list[str]] | None:
|
|
168
|
+
system, history = system_prompt(self.info), [] # type: (str, list[dict])
|
|
169
|
+
for attempt in range(2):
|
|
170
|
+
try:
|
|
171
|
+
reply = self._ask(system, user, history)
|
|
172
|
+
raw = extract_json(reply)
|
|
173
|
+
if not raw.get("roles") and not raw.get("product"):
|
|
174
|
+
raise ValueError("that JSON has no product or roles")
|
|
175
|
+
return self._normalize(raw, user_set)
|
|
176
|
+
except Exception as e: # noqa: BLE001 (the model is down, or its answer cannot be read)
|
|
177
|
+
if attempt == 0 and isinstance(e, ValueError):
|
|
178
|
+
history = [{"role": "user", "content": user}, {"role": "assistant", "content": "(not valid)"}]
|
|
179
|
+
user = f"Your last reply could not be read ({e}). Reply with ONE JSON object only, in the shape of the example."
|
|
180
|
+
continue
|
|
181
|
+
return None
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def plan(self, command: str, user_set=()) -> PlanResult:
|
|
185
|
+
got = self._from_model(f"The person wants: {command}", user_set)
|
|
186
|
+
source = "model"
|
|
187
|
+
if got is None:
|
|
188
|
+
got, source = self._normalize(heuristic_plan(command, self.info), user_set), "fallback"
|
|
189
|
+
plan, notes = got
|
|
190
|
+
return PlanResult(plan, notes, source, [], describe(plan, source, notes))
|
|
191
|
+
|
|
192
|
+
def revise(self, plan: dict, instruction: str, user_set=()) -> PlanResult:
|
|
193
|
+
user = ("Here is the current plan as JSON:\n" + json.dumps(plan) + f"\n\nThe person says: {instruction}\n"
|
|
194
|
+
"Reply with the full updated plan as ONE JSON object. Change only what they asked for.")
|
|
195
|
+
got, source = self._from_model(user, user_set), "model"
|
|
196
|
+
if got is None:
|
|
197
|
+
got, source = self._normalize(heuristic_revise(plan, instruction, self.info), user_set), "fallback"
|
|
198
|
+
new, notes = got
|
|
199
|
+
changes = planlib.diff(plan, new)
|
|
200
|
+
msg = ("Done: " + "; ".join(changes) + "." if changes else "I made no change to the plan.")
|
|
201
|
+
if source == "fallback":
|
|
202
|
+
msg += " (I could not read the model's answer, so I applied simple rules: check the plan.)"
|
|
203
|
+
if not changes and source == "fallback":
|
|
204
|
+
msg = "I did not understand that. Try the controls on the right, or say for example 'add the tester' or 'build twice to compare'."
|
|
205
|
+
return PlanResult(new, notes, source, changes, " ".join([msg, *notes[:4]]))
|
pal/studio/policy.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Access policy: what a build may write, and what it may use. Enforced in code, with every refusal explained.
|
|
2
|
+
|
|
3
|
+
A model's reply is untrusted text. Before any file is written it goes through `Policy.check_write`: the path must stay inside the project folder
|
|
4
|
+
(no `..`, absolute paths, drive letters, hidden files, reserved Windows names), the kind of file must be allowed (programs and scripts are not: you add
|
|
5
|
+
those yourself), and size and count limits hold. Paths are compared without regard to case, because Windows and macOS treat `Index.html` and
|
|
6
|
+
`index.html` as one file.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from pal.studio.catalog import PERMISSION_BY_ID, tool_needs
|
|
13
|
+
from pal.terminal.policy import ( # noqa: E402,F401 (one copy of the path rules)
|
|
14
|
+
ALLOWED_EXTENSIONS,
|
|
15
|
+
ALLOWED_NAMES,
|
|
16
|
+
MAX_FILE_BYTES,
|
|
17
|
+
MAX_FILES,
|
|
18
|
+
MAX_TOTAL_BYTES,
|
|
19
|
+
PolicyError,
|
|
20
|
+
safe_path,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Decision:
|
|
26
|
+
allowed: bool
|
|
27
|
+
reason: str = ""
|
|
28
|
+
path: str = "" # the cleaned path, when there is one
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Policy:
|
|
33
|
+
permissions: dict
|
|
34
|
+
written: dict = field(default_factory=dict) # lower-cased path -> bytes written
|
|
35
|
+
blocked: list = field(default_factory=list) # (path, reason)
|
|
36
|
+
|
|
37
|
+
def _no(self, path: str, reason: str) -> Decision:
|
|
38
|
+
self.blocked.append((path, reason))
|
|
39
|
+
return Decision(False, reason, path)
|
|
40
|
+
|
|
41
|
+
def check_write(self, raw_path: str, content: str) -> Decision:
|
|
42
|
+
try:
|
|
43
|
+
path = safe_path(raw_path)
|
|
44
|
+
except PolicyError as e:
|
|
45
|
+
return self._no(str(raw_path)[:80], str(e))
|
|
46
|
+
if not self.permissions.get("write_project", False):
|
|
47
|
+
return Decision(False, "saving is switched off (dry run): the file is shown but not saved", path)
|
|
48
|
+
size = len(content.encode("utf-8"))
|
|
49
|
+
key = path.lower()
|
|
50
|
+
if size > MAX_FILE_BYTES:
|
|
51
|
+
return self._no(path, f"too big ({size // 1000} KB; the limit is {MAX_FILE_BYTES // 1000} KB)")
|
|
52
|
+
if key in self.written and not self.permissions.get("modify_own_files", False):
|
|
53
|
+
return self._no(path, "it was already written in this build, and changing it is switched off")
|
|
54
|
+
if key not in self.written and len(self.written) >= MAX_FILES:
|
|
55
|
+
return self._no(path, f"too many files (the limit is {MAX_FILES})")
|
|
56
|
+
total = sum(v for k, v in self.written.items() if k != key) + size
|
|
57
|
+
if total > MAX_TOTAL_BYTES:
|
|
58
|
+
return self._no(path, f"the project would be over {MAX_TOTAL_BYTES // 1_000_000} MB")
|
|
59
|
+
self.written[key] = size
|
|
60
|
+
return Decision(True, "", path)
|
|
61
|
+
|
|
62
|
+
def check_tool(self, name: str) -> Decision:
|
|
63
|
+
need = tool_needs(name)
|
|
64
|
+
if need and not self.permissions.get(need, False):
|
|
65
|
+
return Decision(False, f"{name} needs '{PERMISSION_BY_ID[need].title}', which is off")
|
|
66
|
+
return Decision(True)
|
|
67
|
+
|
|
68
|
+
def allowed_tools(self, names) -> frozenset:
|
|
69
|
+
return frozenset(n for n in names if self.check_tool(n).allowed)
|
pal/studio/routes.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""The web side of the Studio: a page, and the endpoints it talks to. Thin: the work is in the other modules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flask import Response, jsonify, render_template, request
|
|
10
|
+
|
|
11
|
+
from pal.errors import UserInputError
|
|
12
|
+
from pal.llm_wrapper.components.base import WrapperConfig
|
|
13
|
+
from pal.llm_wrapper.modes import PROVIDER_KEYS, default_mode, describe_modes, providers_with_keys
|
|
14
|
+
from pal.paths import data_dir
|
|
15
|
+
from pal.studio import plan as planlib
|
|
16
|
+
from pal.studio.catalog import catalog
|
|
17
|
+
from pal.studio.editor import MAX_INSTRUCTION, lookup, save_file
|
|
18
|
+
from pal.studio.planner import Planner
|
|
19
|
+
from pal.studio.policy import MAX_FILE_BYTES, MAX_FILES, PolicyError, safe_path
|
|
20
|
+
from pal.studio.runs import RunStore
|
|
21
|
+
from pal.terminal.models import discover
|
|
22
|
+
|
|
23
|
+
MAX_REFERENCES, MAX_REFERENCE_BYTES, MAX_REFERENCES_TOTAL = 8, 150_000, 400_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _NoModel:
|
|
27
|
+
"""Stands in for the planner's model when none can be reached: every call fails, so the rules-based plan is used."""
|
|
28
|
+
|
|
29
|
+
def chat(self, syscall):
|
|
30
|
+
raise ConnectionError("no local model is available")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clean_references(raw) -> list[dict]:
|
|
34
|
+
if raw in (None, []):
|
|
35
|
+
return []
|
|
36
|
+
if not isinstance(raw, list) or len(raw) > MAX_REFERENCES:
|
|
37
|
+
raise UserInputError(f"attach at most {MAX_REFERENCES} reference files")
|
|
38
|
+
out, total = [], 0
|
|
39
|
+
for r in raw:
|
|
40
|
+
if not isinstance(r, dict) or not isinstance(r.get("text"), str) or not isinstance(r.get("name"), str):
|
|
41
|
+
raise UserInputError("a reference file must have a name and text")
|
|
42
|
+
name = re.sub(r"[^A-Za-z0-9_. \-]", "_", Path(r["name"].replace("\\", "/")).name)[:80] or "reference.txt"
|
|
43
|
+
size = len(r["text"].encode("utf-8"))
|
|
44
|
+
total += size
|
|
45
|
+
if size > MAX_REFERENCE_BYTES or total > MAX_REFERENCES_TOTAL:
|
|
46
|
+
raise UserInputError(f"reference files are too big (at most {MAX_REFERENCE_BYTES // 1000} KB each and {MAX_REFERENCES_TOTAL // 1000} KB together)")
|
|
47
|
+
out.append({"name": name, "text": r["text"]})
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_studio(app, service, *, planner_core: Callable | None = None, core_factory: Callable | None = None, discover_fn: Callable | None = None,
|
|
52
|
+
hot_turns: int | None = None) -> RunStore:
|
|
53
|
+
"""Add the Studio page and endpoints to the app. The three callables are for tests: the planner's model, the builder's model, local-model discovery."""
|
|
54
|
+
cfg = service.settings
|
|
55
|
+
home = Path(cfg.storage_dir).expanduser() if cfg.storage_dir else data_dir()
|
|
56
|
+
host = lambda: (cfg.local_llm.get("host") or "http://localhost:11434") # noqa: E731
|
|
57
|
+
|
|
58
|
+
def num_ctx() -> int:
|
|
59
|
+
return cfg.studio.num_ctx
|
|
60
|
+
|
|
61
|
+
def local_core(model: str, ctx: int):
|
|
62
|
+
from pal.llm_wrapper import build_wrapper
|
|
63
|
+
return build_wrapper(WrapperConfig(backend="ollama", model=model, host=host(), extra={"timeout": 900, "litellm_params": {"num_ctx": ctx, "keep_alive": "30m"}}))
|
|
64
|
+
|
|
65
|
+
def default_core_factory(plan: dict, api_key: str | None):
|
|
66
|
+
from pal.chat.providers import build_api_core
|
|
67
|
+
m = plan["model"]
|
|
68
|
+
if m["mode"] == "api":
|
|
69
|
+
return build_api_core(m["provider"], m["model"], api_key)
|
|
70
|
+
if not m["model"]:
|
|
71
|
+
raise UserInputError("no local model is chosen: start Ollama and pull a model, or switch to an API model")
|
|
72
|
+
return local_core(m["model"], num_ctx())
|
|
73
|
+
|
|
74
|
+
runs = RunStore(home / "studio", core_factory or default_core_factory, max_runs=cfg.studio.max_runs, hot_turns=hot_turns)
|
|
75
|
+
app.extensions["studio_runs"] = runs
|
|
76
|
+
|
|
77
|
+
def local() -> dict:
|
|
78
|
+
found = (discover_fn or discover)(host())
|
|
79
|
+
if discover_fn is not None or not found.get("ok"):
|
|
80
|
+
return found
|
|
81
|
+
return fit_to_hardware(found)
|
|
82
|
+
|
|
83
|
+
def fit_to_hardware(found: dict) -> dict:
|
|
84
|
+
"""Mark each installed model with how it fits this computer's GPU, and recommend the best one that fits (not just the largest)."""
|
|
85
|
+
from pal.terminal.backend import hardware
|
|
86
|
+
from pal.terminal.local_models import FIT_LABEL, api_only, best_installed, installed_fit
|
|
87
|
+
hw = hardware()
|
|
88
|
+
if api_only(hw):
|
|
89
|
+
return {"ok": False, "url": found.get("url"), "models": [], "recommended": None, "hardware": hw.summary(),
|
|
90
|
+
"error": "No GPU on this computer, so Pal uses API models only: choose an API model (and paste a key) in the plan's Model step."}
|
|
91
|
+
fits = {r["name"]: r["fit"] for r in installed_fit(found["models"], hw)}
|
|
92
|
+
models = [{**m, "fit": fits.get(m["name"], ""), "label": m["label"] + (f" | {FIT_LABEL[fits[m['name']]]}" if m["name"] in fits else "")} for m in found["models"]]
|
|
93
|
+
best = best_installed(found["models"], hw) or found["recommended"]
|
|
94
|
+
return {**found, "models": models, "recommended": best, "hardware": hw.summary()}
|
|
95
|
+
|
|
96
|
+
def ready_tools() -> list[str]:
|
|
97
|
+
return [t["name"] for t in catalog(cfg.tools, os.environ)["tools"] if t["ready"]]
|
|
98
|
+
|
|
99
|
+
def info(model: str | None = None) -> tuple[dict, dict]:
|
|
100
|
+
loc = local()
|
|
101
|
+
installed = [m["name"] for m in loc["models"] if not m["embedding"]]
|
|
102
|
+
chosen = model if model in installed else loc["recommended"]
|
|
103
|
+
return {"installed": installed, "recommended": chosen, "ready_tools": ready_tools()}, loc
|
|
104
|
+
|
|
105
|
+
def planner_for(i: dict, loc: dict) -> Planner:
|
|
106
|
+
if not loc["ok"] or not i["recommended"]:
|
|
107
|
+
return Planner(_NoModel(), i)
|
|
108
|
+
if planner_core:
|
|
109
|
+
return Planner(planner_core(i["recommended"]), i)
|
|
110
|
+
return Planner(local_core(i["recommended"], cfg.studio.planner_num_ctx), i)
|
|
111
|
+
|
|
112
|
+
def body() -> dict:
|
|
113
|
+
data = request.get_json(silent=True)
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
raise UserInputError("send a JSON object")
|
|
116
|
+
return data
|
|
117
|
+
|
|
118
|
+
def user_set(data: dict) -> list[str]:
|
|
119
|
+
v = data.get("user_set") or []
|
|
120
|
+
return [x for x in v if isinstance(x, str)] if isinstance(v, list) else []
|
|
121
|
+
|
|
122
|
+
def normalized(data: dict, i: dict) -> tuple[dict, list[str]]:
|
|
123
|
+
if not isinstance(data.get("plan"), dict):
|
|
124
|
+
raise UserInputError("send the plan")
|
|
125
|
+
return planlib.normalize(data["plan"], installed=i["installed"], recommended=i["recommended"], user_set=user_set(data), ready_tools=i["ready_tools"])
|
|
126
|
+
|
|
127
|
+
@app.get("/studio")
|
|
128
|
+
def studio_page():
|
|
129
|
+
resp = Response(render_template("studio.html"), mimetype="text/html")
|
|
130
|
+
# The page shows text a model wrote, so it may not run inline scripts or load anything from another site.
|
|
131
|
+
resp.headers["Content-Security-Policy"] = ("default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
|
132
|
+
"base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
|
|
133
|
+
return resp
|
|
134
|
+
|
|
135
|
+
@app.get("/api/studio/catalog")
|
|
136
|
+
def studio_catalog():
|
|
137
|
+
loc = local()
|
|
138
|
+
return jsonify({**catalog(cfg.tools, os.environ), "local": loc, "llm_modes": describe_modes(), "llm_mode": default_mode(), "providers": providers_with_keys(),
|
|
139
|
+
"limits": {"num_ctx": num_ctx(), "max_files": MAX_FILES, "max_file_kb": MAX_FILE_BYTES // 1000, "max_references": MAX_REFERENCES, "max_reference_kb": MAX_REFERENCE_BYTES // 1000}})
|
|
140
|
+
|
|
141
|
+
@app.post("/api/studio/plan")
|
|
142
|
+
def studio_plan():
|
|
143
|
+
data = body()
|
|
144
|
+
command = (data.get("command") or "").strip() if isinstance(data.get("command"), str) else ""
|
|
145
|
+
if not command:
|
|
146
|
+
raise UserInputError("tell me what you want built")
|
|
147
|
+
if len(command) > 4000:
|
|
148
|
+
raise UserInputError("that is too long: describe it in a few sentences (under 4000 characters)")
|
|
149
|
+
i, loc = info(data.get("model") if isinstance(data.get("model"), str) else None)
|
|
150
|
+
r = planner_for(i, loc).plan(command, user_set(data))
|
|
151
|
+
return jsonify({"plan": r.plan, "notes": r.notes, "source": r.source, "message": r.message, "planner_model": i["recommended"], "local": {"ok": loc["ok"], "error": loc["error"]}})
|
|
152
|
+
|
|
153
|
+
@app.post("/api/studio/revise")
|
|
154
|
+
def studio_revise():
|
|
155
|
+
data = body()
|
|
156
|
+
instruction = (data.get("instruction") or "").strip() if isinstance(data.get("instruction"), str) else ""
|
|
157
|
+
if not instruction or len(instruction) > 2000:
|
|
158
|
+
raise UserInputError("say what to change (under 2000 characters)")
|
|
159
|
+
i, loc = info(data.get("model") if isinstance(data.get("model"), str) else None)
|
|
160
|
+
current, _ = normalized(data, i)
|
|
161
|
+
r = planner_for(i, loc).revise(current, instruction, user_set(data))
|
|
162
|
+
return jsonify({"plan": r.plan, "notes": r.notes, "source": r.source, "message": r.message, "changes": r.changes})
|
|
163
|
+
|
|
164
|
+
@app.post("/api/studio/validate")
|
|
165
|
+
def studio_validate():
|
|
166
|
+
data = body()
|
|
167
|
+
i, _ = info()
|
|
168
|
+
plan, notes = normalized(data, i)
|
|
169
|
+
return jsonify({"plan": plan, "notes": notes})
|
|
170
|
+
|
|
171
|
+
@app.post("/api/studio/run")
|
|
172
|
+
def studio_run():
|
|
173
|
+
data = body()
|
|
174
|
+
i, _ = info()
|
|
175
|
+
plan, notes = normalized(data, i)
|
|
176
|
+
key = data.get("api_key") if isinstance(data.get("api_key"), str) else None
|
|
177
|
+
m = plan["model"]
|
|
178
|
+
if m["mode"] == "api" and not (key or os.environ.get(PROVIDER_KEYS[m["provider"]])):
|
|
179
|
+
raise UserInputError(f"no API key for {m['provider']}: paste one, or switch to the local model")
|
|
180
|
+
run = runs.start(plan, clean_references(data.get("references")) if plan["permissions"].get("read_references") else [], key)
|
|
181
|
+
return jsonify({"run_id": run.id, "notes": notes})
|
|
182
|
+
|
|
183
|
+
@app.get("/api/studio/run/<run_id>")
|
|
184
|
+
def studio_run_status(run_id):
|
|
185
|
+
run = runs.get(run_id)
|
|
186
|
+
try:
|
|
187
|
+
since = int(request.args.get("since", "0"))
|
|
188
|
+
except ValueError:
|
|
189
|
+
raise UserInputError("since must be a number") from None
|
|
190
|
+
return jsonify({**run.summary(), "events": run.events_since(since)})
|
|
191
|
+
|
|
192
|
+
@app.post("/api/studio/run/<run_id>/cancel")
|
|
193
|
+
def studio_cancel(run_id):
|
|
194
|
+
runs.get(run_id).cancel.set()
|
|
195
|
+
return jsonify({"ok": True})
|
|
196
|
+
|
|
197
|
+
@app.get("/api/studio/run/<run_id>/files")
|
|
198
|
+
def studio_files(run_id):
|
|
199
|
+
state = runs.get(run_id).arm(request.args.get("arm", ""))
|
|
200
|
+
files = state.build.files if state.build else {}
|
|
201
|
+
return jsonify({"files": [{"path": p, "bytes": len(c.encode("utf-8"))} for p, c in sorted(files.items())]})
|
|
202
|
+
|
|
203
|
+
@app.get("/api/studio/run/<run_id>/file")
|
|
204
|
+
def studio_file(run_id):
|
|
205
|
+
state = runs.get(run_id).arm(request.args.get("arm", ""))
|
|
206
|
+
try:
|
|
207
|
+
path = safe_path(request.args.get("path", ""))
|
|
208
|
+
except PolicyError as e:
|
|
209
|
+
raise UserInputError(str(e)) from None
|
|
210
|
+
files = {p.lower(): (p, c) for p, c in (state.build.files if state.build else {}).items()}
|
|
211
|
+
if path.lower() not in files:
|
|
212
|
+
raise UserInputError("no such file in this build")
|
|
213
|
+
name, content = files[path.lower()]
|
|
214
|
+
limit = MAX_FILE_BYTES if request.args.get("full") == "1" else 60_000 # the editor needs the whole file: saving a shortened one would lose the rest
|
|
215
|
+
return jsonify({"path": name, "content": content[:limit], "truncated": len(content) > limit})
|
|
216
|
+
|
|
217
|
+
@app.put("/api/studio/run/<run_id>/file")
|
|
218
|
+
def studio_save_file(run_id):
|
|
219
|
+
data = body()
|
|
220
|
+
arm, path, content = (str(data.get(k)) if isinstance(data.get(k), str) else "" for k in ("arm", "path", "content"))
|
|
221
|
+
state = runs.editable(runs.get(run_id), arm)
|
|
222
|
+
return jsonify(save_file(state, path, content))
|
|
223
|
+
|
|
224
|
+
@app.post("/api/studio/run/<run_id>/ask")
|
|
225
|
+
def studio_ask(run_id):
|
|
226
|
+
"""Ask Pal to change the open file (or answer a question about the project). Proposes only: nothing is saved until the person accepts."""
|
|
227
|
+
data = body()
|
|
228
|
+
instruction = (data.get("instruction") or "").strip() if isinstance(data.get("instruction"), str) else ""
|
|
229
|
+
if not instruction or len(instruction) > MAX_INSTRUCTION:
|
|
230
|
+
raise UserInputError(f"say what to change (under {MAX_INSTRUCTION} characters)")
|
|
231
|
+
run = runs.get(run_id)
|
|
232
|
+
arm = str(data.get("arm")) if isinstance(data.get("arm"), str) else ""
|
|
233
|
+
state = runs.editable(run, arm)
|
|
234
|
+
path = str(data.get("path")) if isinstance(data.get("path"), str) else ""
|
|
235
|
+
build = state.build
|
|
236
|
+
assert build is not None # editable() checked it # noqa: S101
|
|
237
|
+
if path and lookup(build.files, path) is None:
|
|
238
|
+
raise UserInputError("that file is not in this build")
|
|
239
|
+
key = data.get("api_key") if isinstance(data.get("api_key"), str) else None
|
|
240
|
+
m = run.plan["model"]
|
|
241
|
+
if m["mode"] == "api" and not (key or os.environ.get(PROVIDER_KEYS[m["provider"]])):
|
|
242
|
+
raise UserInputError(f"no API key for {m['provider']}: paste one, or switch to the local model")
|
|
243
|
+
return jsonify(runs.editor(run, arm, key).ask(build.files, path, instruction))
|
|
244
|
+
|
|
245
|
+
@app.get("/api/studio/run/<run_id>/download")
|
|
246
|
+
def studio_download(run_id):
|
|
247
|
+
run = runs.get(run_id)
|
|
248
|
+
arm = request.args.get("arm", "")
|
|
249
|
+
data = runs.zip_bytes(run, arm)
|
|
250
|
+
workspace = run.arm(arm).workspace
|
|
251
|
+
folder = workspace.name if workspace else "project"
|
|
252
|
+
return Response(data, mimetype="application/zip", headers={"Content-Disposition": f'attachment; filename="{folder}-{arm}.zip"'})
|
|
253
|
+
|
|
254
|
+
return runs
|