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.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. 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