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/catalog.py ADDED
@@ -0,0 +1,90 @@
1
+ """What the bot can choose from: build roles, tools, and access permissions.
2
+
3
+ The bot proposes; the person decides. Every item here has a plain description so the page can show what was chosen and why.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+
10
+ from pal import tool_catalog
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Role:
15
+ id: str
16
+ name: str
17
+ description: str
18
+ prompt: str # what this role is told it is, kept short: it is sent with every request
19
+
20
+
21
+ ROLES: dict[str, Role] = {r.id: r for r in (
22
+ Role("designer", "Designer", "Plans the look and layout: pages, styling, user-interface structure.",
23
+ "You are the designer. You decide the layout and write the styles and markup. Keep it clean, readable and responsive."),
24
+ Role("coder", "Coder", "Writes the working code: the logic and the main files.",
25
+ "You are the coder. You write complete, working code. No placeholders, no TODOs, no explanations inside code."),
26
+ Role("tester", "Tester", "Writes tests and checks that the pieces fit together.",
27
+ "You are the tester. You write small, clear tests for what exists and point out what does not fit together."),
28
+ Role("docs", "Docs writer", "Writes the README and usage instructions.",
29
+ "You are the documentation writer. You write a short README: what it is, how to run it, one example."),
30
+ Role("reviewer", "Reviewer", "Reads everything that was written and fixes mistakes.",
31
+ "You are the reviewer. You read all the files so far, find mistakes and mismatches, and give corrected files."),
32
+ )}
33
+ ROLE_ORDER = tuple(ROLES)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Permission:
38
+ id: str
39
+ title: str
40
+ description: str
41
+ default: bool
42
+ risky: bool = False # only the person can switch it on: the bot may suggest it but never enables it
43
+ locked: bool = False # cannot be changed in this version
44
+ why_locked: str = ""
45
+
46
+
47
+ PERMISSIONS: tuple[Permission, ...] = (
48
+ Permission("write_project", "Save files in the project folder", "The build writes the files it creates into its own project folder. Off: a dry run that shows the files but saves nothing.", True),
49
+ Permission("modify_own_files", "Change files it already wrote", "Later steps (the reviewer, fixes) may rewrite a file an earlier step created in this build.", True),
50
+ Permission("read_references", "Read the reference files you attach", "The bot may read documents you attach as reference (a brief, a design doc). They stay on this machine unless you use an API model.", True),
51
+ Permission("network_access", "Go online", "Tools that reach the internet (Wikipedia, arXiv, web fetch, search). Off by default: it sends queries off this machine.", False, risky=True),
52
+ Permission("run_code", "Run the code it writes", "Running generated code needs a proper sandbox, which this version does not have. The build checks files for syntax mistakes instead.", False, locked=True,
53
+ why_locked="needs a sandbox (not in this version)"),
54
+ Permission("outside_project", "Touch files outside the project folder", "Never. Every path must stay inside the project folder.", False, locked=True, why_locked="never allowed"),
55
+ Permission("delete_files", "Delete files", "Never. The build only creates and rewrites files.", False, locked=True, why_locked="never allowed"),
56
+ )
57
+ PERMISSION_BY_ID = {p.id: p for p in PERMISSIONS}
58
+ DEFAULT_PERMISSIONS = {p.id: p.default for p in PERMISSIONS}
59
+
60
+ # Tools the builder's own file handling replaces, or that need a loaded project: not offered as choices.
61
+ _NOT_OFFERED = frozenset({"save_file", "read_file", "write_file", "list_files", "file_to_markdown", "build_code_graph", "query_code_graph", "read_source", "find_usages",
62
+ "search_source", "list_project_files", "system_info", "word_count"})
63
+ DOCUMENT_TOOLS = frozenset({"search_documents", "read_document", "document_outline"})
64
+ NETWORK_TOOLS = frozenset({"wikipedia", "arxiv_search", "http_get", "google_search", "wolfram_alpha", "currency_convert", "moon_phase", "words_api",
65
+ "image_caption", "visual_question_answering", "text_to_image", "text_to_audio", "speech_to_text"})
66
+ OFFERED_TOOLS = tuple(e.name for e in tool_catalog.CATALOG if e.name not in _NOT_OFFERED)
67
+
68
+
69
+ def tool_needs(name: str) -> str | None:
70
+ """The permission a tool needs: going online, or reading the files you attached."""
71
+ if name in NETWORK_TOOLS:
72
+ return "network_access"
73
+ if name in DOCUMENT_TOOLS:
74
+ return "read_references"
75
+ return None
76
+
77
+
78
+ def catalog(tools_cfg, env: Mapping[str, str]) -> dict:
79
+ """Everything the page can offer, as plain data. `ready` is false for a tool that has to be switched on in the settings first."""
80
+ tools = []
81
+ for name in OFFERED_TOOLS:
82
+ e = tool_catalog.BY_NAME[name]
83
+ st = tool_catalog.state(e, tools_cfg, env)
84
+ tools.append({"name": name, "title": e.title, "group": e.group, "summary": e.summary, "needs": tool_needs(name), "state": st,
85
+ "ready": st in ("", "ready"), "why_not": "" if st in ("", "ready") else (f"{st}: set it in the settings" if st != "off" else "off in the settings (tools.services / tools.network)")})
86
+ return {
87
+ "roles": [{"id": r.id, "name": r.name, "description": r.description} for r in ROLES.values()],
88
+ "tools": tools,
89
+ "permissions": [{"id": p.id, "title": p.title, "description": p.description, "default": p.default, "risky": p.risky, "locked": p.locked, "why_locked": p.why_locked} for p in PERMISSIONS],
90
+ }
pal/studio/checks.py ADDED
@@ -0,0 +1,129 @@
1
+ """Static checks on the files a build wrote. Nothing is run: a file is read, never executed.
2
+
3
+ These catch the mistakes small models make most (a half-closed tag, a missing brace, a page that links to a file that was never written). A problem is
4
+ reported in plain words so it can be sent back to the model once for a fix.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ import json
10
+ import re
11
+ from html.parser import HTMLParser
12
+ from pathlib import PurePosixPath
13
+
14
+ _VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"})
15
+ _TRACKED = frozenset({"html", "head", "body", "script", "style", "div", "section", "nav", "main", "header", "footer", "form", "table", "ul", "ol", "button"})
16
+ _PAIRS = {"{": "}", "(": ")", "[": "]"}
17
+
18
+
19
+ class _Html(HTMLParser):
20
+ def __init__(self):
21
+ super().__init__(convert_charrefs=True)
22
+ self.stack: list[str] = []
23
+ self.problems: list[str] = []
24
+ self.refs: list[str] = []
25
+
26
+ def handle_starttag(self, tag, attrs):
27
+ a = dict(attrs)
28
+ for key in ("src", "href"):
29
+ v = a.get(key)
30
+ if v and tag in ("script", "link", "img", "source") and not re.match(r"^([a-z][a-z0-9+.-]*:|//|#|data:)", v, re.I):
31
+ self.refs.append(v.split("#")[0].split("?")[0])
32
+ if tag in _TRACKED:
33
+ self.stack.append(tag)
34
+
35
+ def handle_startendtag(self, tag, attrs):
36
+ self.handle_starttag(tag, attrs)
37
+ if tag in _TRACKED and self.stack and self.stack[-1] == tag:
38
+ self.stack.pop()
39
+
40
+ def handle_endtag(self, tag):
41
+ if tag in _VOID or tag not in _TRACKED:
42
+ return
43
+ if tag in self.stack:
44
+ while self.stack and self.stack[-1] != tag:
45
+ self.problems.append(f"<{self.stack.pop()}> is never closed")
46
+ self.stack.pop()
47
+ else:
48
+ self.problems.append(f"</{tag}> closes something that was never opened")
49
+
50
+
51
+ def _balance(code: str, comments: bool = True) -> list[str]:
52
+ """Brackets that do not pair up, ignoring strings and comments (a lightweight scan, not a parser)."""
53
+ stack: list[str] = []
54
+ i, n = 0, len(code)
55
+ while i < n:
56
+ c = code[i]
57
+ if comments and code.startswith("//", i):
58
+ i = code.find("\n", i)
59
+ i = n if i < 0 else i
60
+ continue
61
+ if comments and code.startswith("/*", i):
62
+ j = code.find("*/", i + 2)
63
+ i = n if j < 0 else j + 2
64
+ continue
65
+ if c in "\"'`":
66
+ j = i + 1
67
+ while j < n and code[j] != c:
68
+ j += 2 if code[j] == "\\" else 1
69
+ if c != "`" and j < n and code[j] == "\n":
70
+ break
71
+ i = j + 1
72
+ continue
73
+ if c in _PAIRS:
74
+ stack.append(c)
75
+ elif c in _PAIRS.values():
76
+ if not stack or _PAIRS[stack[-1]] != c:
77
+ return [f"a '{c}' has no matching opener"]
78
+ stack.pop()
79
+ i += 1
80
+ return [f"a '{stack[-1]}' is never closed"] if stack else []
81
+
82
+
83
+ def check_file(path: str, content: str) -> list[str]:
84
+ ext = PurePosixPath(path).suffix.lower()
85
+ if ext == ".py":
86
+ try:
87
+ ast.parse(content)
88
+ except SyntaxError as e:
89
+ return [f"syntax error on line {e.lineno}: {e.msg}"]
90
+ elif ext == ".json":
91
+ try:
92
+ json.loads(content)
93
+ except ValueError as e:
94
+ return [f"not valid JSON: {e}"]
95
+ elif ext in (".html", ".htm"):
96
+ p = _Html()
97
+ p.feed(content)
98
+ p.close()
99
+ return p.problems[:3] + [f"<{t}> is never closed" for t in p.stack[:3]]
100
+ elif ext in (".js", ".mjs", ".ts", ".tsx", ".jsx", ".css"):
101
+ return _balance(content, comments=True)
102
+ return []
103
+
104
+
105
+ def check_project(files: dict[str, str], only: set[str] | None = None, links: bool = True) -> dict[str, list[str]]:
106
+ """Problems by file. `only` limits the single-file checks to files that just changed. `links` also checks that every file a page links to was
107
+ written: leave it off in the middle of a build, because a page may link to a file a later step is about to write."""
108
+ out: dict[str, list[str]] = {}
109
+ lower = {p.lower() for p in files}
110
+ for path, content in files.items():
111
+ problems = check_file(path, content) if only is None or path in only else []
112
+ if links and PurePosixPath(path).suffix.lower() in (".html", ".htm"):
113
+ p = _Html()
114
+ p.feed(content)
115
+ base = PurePosixPath(path).parent
116
+ for ref in p.refs:
117
+ target = (base / ref).as_posix() if str(base) != "." else ref
118
+ parts: list[str] = []
119
+ for seg in target.split("/"):
120
+ if seg == "..":
121
+ if parts:
122
+ parts.pop()
123
+ elif seg not in ("", "."):
124
+ parts.append(seg)
125
+ if "/".join(parts).lower() not in lower:
126
+ problems.append(f"it refers to {ref}, but no file with that name was written")
127
+ if problems:
128
+ out[path] = problems
129
+ return out
pal/studio/clients.py ADDED
@@ -0,0 +1,6 @@
1
+ """Moved to pal.terminal.clients; this name is kept so the Studio and older imports keep working."""
2
+ import sys
3
+
4
+ from pal.terminal import clients as _moved
5
+
6
+ sys.modules[__name__] = _moved
pal/studio/editor.py ADDED
@@ -0,0 +1,137 @@
1
+ """The code editor behind the Studio: open a built project, change a file by hand, or ask Pal to change it.
2
+
3
+ Every "ask" goes through the same token-saving client the builds use (PalClient), kept alive for the whole editing session, so the history is paged
4
+ and compressed and the project's other files are indexed once instead of pasted again. Nothing Pal writes is saved until the person accepts it:
5
+ `ask` only proposes (old text, new text, a diff); `save_file` is the one place a file changes, and it applies the same path and size rules as a build.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import threading
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from pal.errors import UserInputError
16
+ from pal.studio.builder import FORMAT
17
+ from pal.studio.policy import MAX_FILE_BYTES, MAX_FILES, MAX_TOTAL_BYTES, PolicyError, safe_path
18
+ from pal.terminal.files import _FENCE, parse_files
19
+ from pal.terminal.session import diff_lines # noqa: F401 (one diff, shared with the terminal)
20
+ from pal.tokens import estimate_tokens
21
+
22
+ SYSTEM = "You are a careful senior developer editing an existing project. You change only what was asked, and keep every file consistent with the others."
23
+ MAX_DIFF_LINES = 400
24
+ MAX_INSTRUCTION = 4000
25
+
26
+
27
+ def lookup(files: dict[str, str], path: str) -> str | None:
28
+ """The stored name of `path`, compared without regard to case (Windows and macOS treat Index.html and index.html as one file)."""
29
+ low = path.lower()
30
+ return next((p for p in files if p.lower() == low), None)
31
+
32
+
33
+ def save_file(state, path: str, content: str) -> dict:
34
+ """Write one file into a finished build (memory, and the project folder when the build was saved). Raises UserInputError with a plain reason."""
35
+ if state.build is None:
36
+ raise UserInputError("this build has no files yet")
37
+ try:
38
+ clean = safe_path(path)
39
+ except PolicyError as e:
40
+ raise UserInputError(str(e)) from None
41
+ if not isinstance(content, str):
42
+ raise UserInputError("the file's text must be a string")
43
+ size = len(content.encode("utf-8"))
44
+ if size > MAX_FILE_BYTES:
45
+ raise UserInputError(f"too big ({size // 1000} KB; the limit is {MAX_FILE_BYTES // 1000} KB)")
46
+ files = state.build.files
47
+ name = lookup(files, clean) or clean
48
+ if name not in files and len(files) >= MAX_FILES:
49
+ raise UserInputError(f"too many files (the limit is {MAX_FILES})")
50
+ if sum(len(c.encode("utf-8")) for p, c in files.items() if p != name) + size > MAX_TOTAL_BYTES:
51
+ raise UserInputError(f"the project would be over {MAX_TOTAL_BYTES // 1_000_000} MB")
52
+ if state.workspace is not None:
53
+ target = (state.workspace / name).resolve()
54
+ if state.workspace.resolve() not in target.parents:
55
+ raise UserInputError("files must stay inside the project folder")
56
+ target.parent.mkdir(parents=True, exist_ok=True)
57
+ target.write_text(content, encoding="utf-8", newline="")
58
+ files[name] = content
59
+ return {"path": name, "bytes": size, "saved_to_disk": state.workspace is not None}
60
+
61
+
62
+ @dataclass
63
+ class EditorSession:
64
+ """One editing conversation about one build. Holds the token-saving client and the running totals."""
65
+ client: Any
66
+ product: str
67
+ lock: threading.Lock = field(default_factory=threading.Lock)
68
+ sent: dict[str, str] = field(default_factory=dict) # path -> the text Pal's index last received
69
+ history_tokens: int = 0 # what a plain chat would carry along: every earlier request and reply
70
+ asks: int = 0
71
+ pal_tokens: int = 0
72
+ helper_tokens: int = 0
73
+ plain_tokens: int = 0
74
+
75
+ def ask(self, files: dict[str, str], path: str, instruction: str) -> dict:
76
+ if not self.lock.acquire(blocking=False):
77
+ raise UserInputError("Pal is still working on your last request: wait for it to finish")
78
+ try:
79
+ return self._ask(files, path, instruction)
80
+ finally:
81
+ self.lock.release()
82
+
83
+ def _ask(self, files: dict[str, str], path: str, instruction: str) -> dict:
84
+ name = lookup(files, path) if path else None
85
+ others = {p: c for p, c in files.items() if p != name}
86
+ changed = [{"name": p, "text": c, "kind": "document"} for p, c in others.items() if self.sent.get(p) != c]
87
+ listing = "\n".join(f"- {p} ({len(c.encode('utf-8')):,} bytes)" for p, c in sorted(files.items()))
88
+ parts = [f"Project: {self.product}. Files:\n{listing}", "The other files are available to you as reference documents; read them only if the change needs them."]
89
+ if name:
90
+ parts.append(f"The file open in the editor is {name}. Its current content:\n```\n{files[name]}\n```")
91
+ parts += [f"Change requested: {instruction}",
92
+ "Reply with the COMPLETE new content of every file you change (never a fragment, never '...'), and leave other files out. " + FORMAT
93
+ + " If the request is only a question, answer in a few sentences and write no files."]
94
+ message = "\n\n".join(parts)
95
+ # what a plain chat client would have sent for this same request: the system line, the whole project, every earlier turn, and the request
96
+ plain_prompt = estimate_tokens(SYSTEM) + sum(estimate_tokens(c) for c in files.values()) + estimate_tokens(listing) + self.history_tokens + estimate_tokens(instruction)
97
+ reply = self.client.ask(message, changed or None)
98
+ for p, c in others.items():
99
+ self.sent[p] = c
100
+ self.asks += 1
101
+ self.pal_tokens += reply.prompt_tokens + reply.completion_tokens
102
+ self.helper_tokens += reply.helper_tokens
103
+ self.plain_tokens += plain_prompt + reply.completion_tokens
104
+ self.history_tokens += estimate_tokens(instruction) + estimate_tokens(reply.text)
105
+
106
+ parsed, _unnamed = parse_files(reply.text, default_name=name or "")
107
+ proposals, refused = [], []
108
+ for pf in parsed:
109
+ try:
110
+ clean = safe_path(pf.path)
111
+ except PolicyError as e:
112
+ refused.append({"path": pf.path[:80], "reason": str(e)})
113
+ continue
114
+ if len(pf.content.encode("utf-8")) > MAX_FILE_BYTES:
115
+ refused.append({"path": clean, "reason": f"too big (the limit is {MAX_FILE_BYTES // 1000} KB)"})
116
+ continue
117
+ existing = lookup(files, clean)
118
+ old = files[existing] if existing else ""
119
+ if existing and old == pf.content:
120
+ continue
121
+ proposals.append({"path": existing or clean, "new": not existing, "old": old, "content": pf.content, "diff": diff_lines(old, pf.content)})
122
+ note = " ".join(m.group(1).strip() for m in re.finditer(r"^NOTE:\s*(.+)$", reply.text, re.M))
123
+ talk = re.sub(r"^#+\s*FILE:.*$|^NOTE:.*$", "", _FENCE.sub("", reply.text), flags=re.M | re.I).strip()
124
+ return {"files": proposals, "refused": refused, "note": note, "message": talk[:1500],
125
+ "usage": {"prompt_tokens": reply.prompt_tokens, "completion_tokens": reply.completion_tokens, "helper_tokens": reply.helper_tokens, "seconds": round(reply.seconds, 1),
126
+ "plain_estimate": plain_prompt + reply.completion_tokens},
127
+ "session": {"asks": self.asks, "pal_tokens": self.pal_tokens, "helper_tokens": self.helper_tokens, "plain_estimate": self.plain_tokens,
128
+ "saved_pct": round(100.0 * (self.plain_tokens - self.pal_tokens) / self.plain_tokens, 1) if self.plain_tokens else 0.0}}
129
+
130
+ def close(self) -> None:
131
+ close = getattr(self.client, "close", None)
132
+ if close:
133
+ close()
134
+
135
+
136
+ def new_session_dir(root: Path, run_id: str, arm: str) -> Path:
137
+ return root / run_id / f"{arm}-edit-home"
pal/studio/files.py ADDED
@@ -0,0 +1,6 @@
1
+ """Moved to pal.terminal.files; this name is kept so the Studio and older imports keep working."""
2
+ import sys
3
+
4
+ from pal.terminal import files as _moved
5
+
6
+ sys.modules[__name__] = _moved
pal/studio/models.py ADDED
@@ -0,0 +1,6 @@
1
+ """Moved to pal.terminal.models; this name is kept so the Studio and older imports keep working."""
2
+ import sys
3
+
4
+ from pal.terminal import models as _moved
5
+
6
+ sys.modules[__name__] = _moved
pal/studio/plan.py ADDED
@@ -0,0 +1,149 @@
1
+ """The plan: what the bot proposes and what the person edits. One normaliser enforces every rule, whoever changed the plan.
2
+
3
+ The rules that matter:
4
+ * a risky permission (going online) can be on only if the PERSON switched it on (`user_set`): the bot may suggest it, never enable it;
5
+ * a locked permission (running code, touching files outside the project, deleting) is always off;
6
+ * a tool that needs a permission is dropped when that permission is off, and the plan says so;
7
+ * unknown roles, tools and permissions are dropped, so a model's invention can never become an action.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Iterable, Mapping
13
+
14
+ from pal.llm_wrapper.modes import PROVIDER_KEYS, mode_for
15
+ from pal.studio.catalog import DEFAULT_PERMISSIONS, OFFERED_TOOLS, PERMISSION_BY_ID, ROLE_ORDER, ROLES, tool_needs
16
+
17
+ KINDS = ("web_app", "cli_tool", "library", "script", "document", "other")
18
+ MAX_STEPS, MAX_ROLES = 8, 5
19
+ _FOLDER = re.compile(r"[^a-z0-9]+")
20
+
21
+
22
+ _DEVICE = frozenset({"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))})
23
+
24
+
25
+ def slug(name: str) -> str:
26
+ """A safe folder name for a product. A name that is a Windows device name (con, nul, aux ...) would break there, so it gets a prefix."""
27
+ out = _FOLDER.sub("-", name.lower()).strip("-")[:40] or "project"
28
+ return f"p-{out}" if out in _DEVICE else out
29
+
30
+
31
+ def _mapping(value) -> Mapping:
32
+ return value if isinstance(value, Mapping) else {}
33
+
34
+
35
+ def _text(value, limit: int) -> str:
36
+ return " ".join(str(value or "").split())[:limit]
37
+
38
+
39
+ def default_steps(roles: list[str], summary: str | None = "") -> list[dict]:
40
+ task = {"designer": "Design the layout and write the styles and markup.", "coder": "Write the working code and the main files.",
41
+ "tester": "Write tests for what exists.", "docs": "Write the README: what it is, how to run it, one example.",
42
+ "reviewer": "Review every file written so far and give corrected versions of any with mistakes."}
43
+ return [{"role": r, "task": task[r]} for r in ROLE_ORDER if r in roles]
44
+
45
+
46
+ def normalize(raw: Mapping | None, *, installed: Iterable[str] = (), recommended: str | None = None, user_set: Iterable[str] = (),
47
+ ready_tools: Iterable[str] | None = None) -> tuple[dict, list[str]]:
48
+ """Any proposed plan -> (a valid plan, the notes about what had to change). `installed` are the local models that exist; `ready_tools` the
49
+ tools that can be used right now (None: every offered tool)."""
50
+ raw = dict(raw or {})
51
+ notes: list[str] = []
52
+ user_set = set(user_set)
53
+ installed = list(installed)
54
+ ready = set(OFFERED_TOOLS if ready_tools is None else ready_tools)
55
+
56
+ prod = _mapping(raw.get("product"))
57
+ kind = prod.get("kind") if prod.get("kind") in KINDS else "web_app"
58
+ name = _text(prod.get("name"), 60) or "My project"
59
+ product = {"name": name, "summary": _text(prod.get("summary"), 400), "kind": kind, "folder": slug(name)}
60
+
61
+ roles = []
62
+ for r in raw.get("roles") or []:
63
+ if isinstance(r, str) and r in ROLES and r not in roles:
64
+ roles.append(r)
65
+ elif isinstance(r, str) and r not in ROLES:
66
+ notes.append(f"dropped an agent that does not exist: {_text(r, 30)}")
67
+ roles = [r for r in ROLE_ORDER if r in roles][:MAX_ROLES]
68
+ if "coder" not in roles:
69
+ roles.insert(0, "coder")
70
+ notes.append("added the Coder: a build needs someone to write the code")
71
+ roles = [r for r in ROLE_ORDER if r in roles]
72
+
73
+ perms = dict(DEFAULT_PERMISSIONS)
74
+ given = _mapping(raw.get("permissions"))
75
+ for pid, want in given.items():
76
+ p = PERMISSION_BY_ID.get(pid)
77
+ if p is None:
78
+ notes.append(f"dropped a permission that does not exist: {_text(pid, 30)}")
79
+ continue
80
+ if p.locked:
81
+ if want:
82
+ notes.append(f"{p.title} stays off: {p.why_locked}")
83
+ continue
84
+ if want and p.risky and pid not in user_set:
85
+ notes.append(f"{p.title} stays off: only you can switch it on (use the switch in Access)")
86
+ continue
87
+ perms[pid] = bool(want)
88
+
89
+ tools = []
90
+ for t in raw.get("tools") or []:
91
+ if not isinstance(t, str) or t in tools:
92
+ continue
93
+ if t not in OFFERED_TOOLS:
94
+ notes.append(f"dropped a tool that is not available to builds: {_text(t, 30)}")
95
+ continue
96
+ need = tool_needs(t)
97
+ if need and not perms.get(need):
98
+ notes.append(f"left out {t}: it needs '{PERMISSION_BY_ID[need].title}', which is off")
99
+ continue
100
+ if t not in ready:
101
+ notes.append(f"left out {t}: it is not switched on in the settings")
102
+ continue
103
+ tools.append(t)
104
+
105
+ m = _mapping(raw.get("model"))
106
+ if m.get("mode") == "api" and m.get("provider") in PROVIDER_KEYS and _text(m.get("model"), 120):
107
+ model = {"mode": "api", "provider": m["provider"], "model": _text(m["model"], 120), "api_mode": mode_for(m["provider"])}
108
+ else:
109
+ pick = m.get("model") if m.get("mode") == "local" and m.get("model") in installed else (recommended or (installed[0] if installed else ""))
110
+ model = {"mode": "local", "model": pick or "", "provider": "ollama", "api_mode": ""}
111
+ if m.get("mode") == "local" and m.get("model") and m.get("model") not in installed and installed:
112
+ notes.append(f"{_text(m['model'], 40)} is not installed: using {pick}")
113
+
114
+ steps = []
115
+ for s in raw.get("steps") or []:
116
+ if isinstance(s, Mapping) and s.get("role") in roles and _text(s.get("task"), 300):
117
+ steps.append({"role": s["role"], "task": _text(s["task"], 300)})
118
+ steps = steps[:MAX_STEPS] or default_steps(roles, product["summary"])
119
+
120
+ reasons = {}
121
+ for key, why in _mapping(raw.get("reasons")).items():
122
+ kind_, _, ident = str(key).partition(":")
123
+ if (kind_ == "role" and ident in roles) or (kind_ == "tool" and ident in tools) or (kind_ == "permission" and ident in PERMISSION_BY_ID) or key in ("model", "metrics"):
124
+ reasons[str(key)] = _text(why, 200)
125
+
126
+ return {"product": product, "roles": roles, "tools": tools, "permissions": perms, "model": model, "steps": steps, "reasons": reasons,
127
+ "metrics": bool(raw.get("metrics"))}, notes
128
+
129
+
130
+ def diff(old: Mapping, new: Mapping) -> list[str]:
131
+ """What changed between two plans, in words the person can read."""
132
+ out: list[str] = []
133
+ for key, label in (("roles", "agent"), ("tools", "tool")):
134
+ a, b = set(old.get(key, [])), set(new.get(key, []))
135
+ out += [f"added {label}: {ROLES[x].name if key == 'roles' and x in ROLES else x}" for x in new.get(key, []) if x not in a]
136
+ out += [f"removed {label}: {ROLES[x].name if key == 'roles' and x in ROLES else x}" for x in old.get(key, []) if x not in b]
137
+ for pid, now in new.get("permissions", {}).items():
138
+ if old.get("permissions", {}).get(pid) != now:
139
+ out.append(f"{'turned on' if now else 'turned off'}: {PERMISSION_BY_ID[pid].title.lower()}")
140
+ if old.get("model") != new.get("model"):
141
+ nm = new["model"]
142
+ out.append(f"model: {nm['model'] or 'none'} ({'API, ' + nm['provider'] if nm['mode'] == 'api' else 'local'})")
143
+ if old.get("steps") != new.get("steps"):
144
+ out.append(f"steps changed ({len(new.get('steps', []))} now)")
145
+ if old.get("product") != new.get("product"):
146
+ out.append("product description changed")
147
+ if bool(old.get("metrics")) != bool(new.get("metrics")):
148
+ out.append("build twice to compare: " + ("on" if new.get("metrics") else "off"))
149
+ return out