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/terminal/setup.py ADDED
@@ -0,0 +1,354 @@
1
+ """`pal setup`: look at this computer's GPU, pick the local models that suit it, download them with Ollama, and remember the choice.
2
+
3
+ Runs by itself the first time `pal cli` or `pal web` starts in a terminal. Two models are chosen:
4
+ - the main model, which builds and plans when you use a local model;
5
+ - the helper, the small model Pal runs for its own jobs on every request (routing, trimming what you type, checking answers), even when the
6
+ main model is an API model.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ from rich.console import Console
18
+ from rich.padding import Padding
19
+ from rich.table import Table
20
+ from rich.text import Text
21
+
22
+ from pal.llm_wrapper.modes import PROVIDER_KEYS
23
+ from pal.terminal import backend, theme
24
+ from pal.terminal.hardware import Hardware, detect
25
+ from pal.terminal.local_models import BY_TAG, FIT_LABEL, budget_gb, by_tier, fit, recommend, suitable, tier_name
26
+
27
+ INSTALL_OLLAMA = {"Windows": "download the installer from https://ollama.com/download",
28
+ "Darwin": "download the app from https://ollama.com/download (or: brew install ollama)",
29
+ "Linux": "run: curl -fsSL https://ollama.com/install.sh | sh"}
30
+
31
+
32
+ def _say(console: Console, renderable, indent: int = 2) -> None:
33
+ console.print(Padding(renderable, (0, 0, 0, indent)))
34
+
35
+
36
+ def ollama_installed() -> bool:
37
+ return shutil.which("ollama") is not None
38
+
39
+
40
+ def pull(tag: str) -> bool:
41
+ """`ollama pull <tag>`, showing Ollama's own progress. True when it worked."""
42
+ exe = shutil.which("ollama")
43
+ if not exe:
44
+ return False
45
+ try:
46
+ return subprocess.call([exe, "pull", tag]) == 0
47
+ except OSError:
48
+ return False
49
+
50
+
51
+ def show_table(console: Console, hw: Hardware) -> None:
52
+ """Every model in the catalogue, grouped by the GPU it needs, with how each fits this computer."""
53
+ _say(console, Text.assemble(("Local models by GPU memory", f"bold {theme.WHITE}"), (f" this computer: {tier_name(hw)}", theme.MUTED)))
54
+ console.print()
55
+ for tier, models in by_tier():
56
+ head = f"{tier} GB GPU" if tier else "CPU"
57
+ mine = max((t for t, _ in by_tier() if t <= hw.vram_gb + 0.5), default=None) if hw.vram_gb else 0
58
+ mark = " \u25C0 your computer" if tier == mine else ""
59
+ _say(console, Text.assemble((head, f"bold {theme.RED_HI}"), (mark, theme.WHITE)))
60
+ grid = Table.grid(padding=(0, 2))
61
+ for width in (22, 8, 10, 26):
62
+ grid.add_column(width=width, no_wrap=True, overflow="ellipsis")
63
+ grid.add_column(no_wrap=True, overflow="ellipsis")
64
+ for m in models:
65
+ f = fit(m.need_gb, hw)
66
+ grid.add_row(Text(m.tag, style=theme.WHITE), Text(f"{m.size_gb:g} GB", style=theme.INK), Text(m.use + (" *" if m.thinks else ""), style=theme.MUTED),
67
+ Text(FIT_LABEL[f], style=f"bold {theme.RED_HI}" if f == "gpu" else theme.INK if f == "partial" else theme.MUTED),
68
+ Text(m.note, style=theme.MUTED))
69
+ _say(console, grid, 4)
70
+ console.print()
71
+ _say(console, Text("Sizes are Ollama's 4-bit downloads (approximate). * prints its reasoning first, so Pal does not pick it by itself.", style=theme.MUTED))
72
+
73
+
74
+ def default_pick(console: Console, question: str, options: list[tuple[str, str]], recommended: int = 0, other: bool = False,
75
+ header: str = "Setup") -> str | None:
76
+ """A choice as a card in a terminal; without one (pipes, tests), the recommended choice."""
77
+ from pal.terminal import decide
78
+ if not decide._interactive():
79
+ return options[recommended][0] if options else None
80
+ q = decide.Question(question, [decide.Option(label, about) for label, about in options], header, recommended, other=other)
81
+ answer = decide.ask(console, q)
82
+ decide.show_answer(console, q, answer)
83
+ return answer
84
+
85
+
86
+ LOCAL_MAIN = "A local model on this computer"
87
+ API_MAIN = "An API model"
88
+
89
+
90
+ def run_setup(console: Console, hw: Hardware | None = None, host: str = backend.DEFAULT_HOST, assume_yes: bool = False,
91
+ ask: Callable[[str], bool] | None = None, puller: Callable[[str], bool] = pull, ollama: Callable[[], bool] = ollama_installed, dry: bool = False,
92
+ pick: Callable[..., str | None] | None = None, main_kind: str | None = None, api_chooser: Callable | None = None) -> dict:
93
+ """Scan, choose, download (if allowed), save. Returns what was saved. `dry` only shows the recommendation (for a pretend computer).
94
+
95
+ The main model (it writes and plans) can be local or an API model. With an API model only the helper is downloaded. With a local one,
96
+ the models that suit this GPU are offered (the best first) and the one chosen is downloaded with the helper."""
97
+ hw = hw or detect()
98
+ rec = recommend(hw)
99
+ pick = pick or (lambda question, options, recommended=0, other=False: default_pick(console, question, options, recommended, other))
100
+ _say(console, Text("Pal setup", style=f"bold {theme.WHITE}"))
101
+ _say(console, Text(f"This computer: {hw.summary()}", style=theme.INK))
102
+ for note in hw.notes:
103
+ _say(console, Text(note, style=theme.RED_HI))
104
+ console.print()
105
+ if rec is None:
106
+ explain_api_only(console)
107
+ if not dry:
108
+ backend.save_prefs(helper_model="", setup_done="1")
109
+ return {"provider": "", "model": "", "helper_model": "", "downloaded": False, "api_only": True}
110
+ grid = Table.grid(padding=(0, 3))
111
+ for role, m, why in (("Best main model", rec.main, "builds and plans when you use a local model"), ("Helper", rec.helper, "Pal's own small jobs on every request: routing, trimming, checking")):
112
+ grid.add_row(Text(role, style=theme.MUTED), Text(m.tag, style=f"bold {theme.WHITE}"), Text(f"{m.size_gb:g} GB", style=theme.INK), Text(why, style=theme.MUTED))
113
+ _say(console, Text("For this computer", style=f"bold {theme.RED_HI}"))
114
+ _say(console, grid, 4)
115
+ _say(console, Text(f"Why: {rec.reason}.", style=theme.MUTED), 4)
116
+ console.print()
117
+ if dry:
118
+ if rec.others:
119
+ _say(console, Text("Also fits: " + ", ".join(m.tag for m in rec.others) + " (every model: pal setup --list)", style=theme.MUTED), 4)
120
+ return {"provider": backend.LOCAL, "model": rec.main.tag, "helper_model": rec.helper.tag, "downloaded": False}
121
+
122
+ kind = main_kind or pick("Which main model should Pal use? (it writes and plans; the helper is always local)",
123
+ [(LOCAL_MAIN, f"runs on your GPU, free and private: {rec.main.tag} fits best"),
124
+ (API_MAIN, "OpenAI, Anthropic, Gemini, OpenRouter or any other: needs a key, nothing big to download")], 0)
125
+ api = kind in (API_MAIN, "api")
126
+
127
+ if not ollama():
128
+ how = INSTALL_OLLAMA.get(hw.system, INSTALL_OLLAMA["Linux"])
129
+ _say(console, Text.assemble(("Ollama runs local models (the helper too) and is not installed yet. ", theme.WHITE), (f"To install it, {how}.", theme.INK)))
130
+ _say(console, Text("Then run pal setup again. An API model works meanwhile: pal cli --provider openrouter --model <name>", style=theme.MUTED))
131
+ backend.save_prefs(helper_model=rec.helper.tag, **({} if api else {"provider": backend.LOCAL, "model": rec.main.tag}))
132
+ return {"provider": "" if api else backend.LOCAL, "model": "" if api else rec.main.tag, "helper_model": rec.helper.tag, "downloaded": False}
133
+
134
+ info = backend.local_info(host)
135
+ installed = backend.chat_models(info) if info.get("ok") else []
136
+ main_tag = ""
137
+ saved: dict = {"helper_model": rec.helper.tag}
138
+ if api:
139
+ chosen = (api_chooser or (lambda c, system: ask_api(c, system)))(console, hw.system)
140
+ if chosen:
141
+ saved.update(provider=chosen[0], model=chosen[1])
142
+ else:
143
+ _say(console, Text("No API model chosen yet: pick one in a session with /model <provider>:<model>", style=theme.MUTED))
144
+ else:
145
+ options = suitable(hw, installed)
146
+ labels = [(tag, about) for tag, about, _ in options]
147
+ main_tag = pick("Which local model should be the main one? (the best for your GPU is first; type another name to use it)", labels, 0, True) or rec.main.tag
148
+ main_tag = main_tag.strip().removeprefix("ollama pull ").strip()
149
+ saved.update(provider=backend.LOCAL, model=main_tag)
150
+
151
+ missing = [tag for tag in (main_tag, rec.helper.tag) if tag and tag not in installed]
152
+ if not missing:
153
+ _say(console, Text("Everything needed is already installed.", style=theme.INK))
154
+ backend.save_prefs(**saved, setup_done="1")
155
+ return {**{"provider": "", "model": ""}, **saved, "downloaded": True}
156
+ sizes = {m.tag: m.size_gb for m in (BY_TAG.get(x) for x in missing) if m}
157
+ total = sum(sizes.values())
158
+ question = f"Download {', '.join(missing)} now" + (f" (about {total:g} GB)" if total else "") + "?"
159
+ agreed = assume_yes or (ask or (lambda q: _confirm(console, q)))(question)
160
+ if not agreed:
161
+ _say(console, Text("Skipped. Download later with pal setup, or pull any model yourself: ollama pull <name>", style=theme.MUTED))
162
+ backend.save_prefs(**saved, setup_done="1")
163
+ return {**{"provider": "", "model": ""}, **saved, "downloaded": False}
164
+ ok = True
165
+ for tag in missing:
166
+ _say(console, Text(f"Downloading {tag} ...", style=theme.WHITE))
167
+ ok = puller(tag) and ok
168
+ _say(console, Text("Ready." if ok else "A download did not finish. Run pal setup again to retry.", style=f"bold {theme.RED_HI}" if ok else theme.RED_HI))
169
+ backend.save_prefs(**saved, setup_done="1" if ok else "")
170
+ return {**{"provider": "", "model": ""}, **saved, "downloaded": ok}
171
+
172
+
173
+ KEY_HOWTO = {"Windows": 'setx {var} "your-key" (then open a new terminal)', "Darwin": 'add export {var}="your-key" to ~/.zshrc',
174
+ "Linux": 'add export {var}="your-key" to ~/.bashrc'}
175
+
176
+
177
+ def explain_api_only(console: Console) -> None:
178
+ keyed = [p for p, var in PROVIDER_KEYS.items() if os.environ.get(var)]
179
+ _say(console, Text("No GPU on this computer, so Pal uses an API model.", style=f"bold {theme.RED_HI}"))
180
+ _say(console, Text("Local models and Pal's local helper jobs (routing, trimming what you type, checking answers) are off here. The savers that need no "
181
+ "model still work: only the relevant history, files and code are sent, and files are not sent twice.", style=theme.INK), 4)
182
+ console.print()
183
+ if keyed:
184
+ _say(console, Text(f"API key found for: {', '.join(keyed)}.", style=theme.WHITE))
185
+ _say(console, Text("Start with: pal cli --provider openrouter --model <name> browse models: pal models --free", style=theme.INK))
186
+ _say(console, Text("or in a session: /key openrouter <key> then /model openrouter:<name>", style=theme.MUTED))
187
+ _say(console, Text("Keys are never saved by Pal. To keep one, set it in your environment, for example OPENROUTER_API_KEY.", style=theme.MUTED))
188
+
189
+
190
+ def ask_api(console: Console, system: str = "", ask: Callable[[str], str] | None = None, secret: Callable[[str], str] | None = None) -> tuple[str, str] | None:
191
+ """For a computer without a GPU: ask which API provider and model to use, and the key when it is not in the environment.
192
+ The key is put in this process's environment only (never written anywhere). Returns (provider, model), or None if the person stops."""
193
+ import getpass
194
+ ask = ask or (lambda q: console.input(Text(" " + q + " ", style=theme.WHITE)))
195
+ secret = secret or (lambda q: getpass.getpass(" " + q + " "))
196
+ names = list(PROVIDER_KEYS)
197
+ keyed = [p for p in names if os.environ.get(PROVIDER_KEYS[p])]
198
+ default = keyed[0] if keyed else "openrouter"
199
+ try:
200
+ provider = (ask(f"API provider ({', '.join(names)}) [{default}]:").strip().lower() or default)
201
+ if provider not in PROVIDER_KEYS:
202
+ _say(console, Text(f"Unknown provider {provider!r}.", style=theme.RED_HI))
203
+ return None
204
+ model = ask("Model name (for example the one your provider lists; pal models --free shows OpenRouter's):").strip()
205
+ if not model:
206
+ return None
207
+ var = PROVIDER_KEYS[provider]
208
+ if not os.environ.get(var):
209
+ key = secret(f"Paste your {provider} API key (it is not shown and not saved):").strip()
210
+ if not key:
211
+ return None
212
+ os.environ[var] = key
213
+ how = KEY_HOWTO.get(system or "Linux", KEY_HOWTO["Linux"]).format(var=var)
214
+ _say(console, Text(f"Using it for this session. To keep it for next time: {how}", style=theme.MUTED))
215
+ except (EOFError, KeyboardInterrupt):
216
+ console.print()
217
+ return None
218
+ backend.save_prefs(provider=provider, model=model)
219
+ return provider, model
220
+
221
+
222
+ def _confirm(console: Console, question: str) -> bool:
223
+ try:
224
+ answer = console.input(Text.assemble((" " + question + " ", theme.WHITE), ("[Y/n] ", theme.MUTED))).strip().lower()
225
+ except (EOFError, KeyboardInterrupt):
226
+ console.print()
227
+ return False
228
+ return answer in ("", "y", "yes")
229
+
230
+
231
+ def needs_setup() -> bool:
232
+ return not backend.load_prefs().get("setup_done") and not os.environ.get("PAL_SKIP_SETUP")
233
+
234
+
235
+ def budget_note(hw: Hardware) -> str:
236
+ return f"about {budget_gb(hw):g} GB for models"
237
+
238
+
239
+ # -- getting local models ready when a session starts -------------------------------------------------------------------------------
240
+
241
+
242
+ OLLAMA_START_WAIT_S = 20
243
+
244
+
245
+ @dataclass
246
+ class LocalStatus:
247
+ state: str # "ready", "off" (no GPU), "missing" (Ollama not installed), "stopped" (could not start), "remote"
248
+ detail: str = ""
249
+ info: dict | None = None
250
+
251
+
252
+ def _is_local_host(host: str) -> bool:
253
+ return any(h in host for h in ("localhost", "127.0.0.1", "[::1]"))
254
+
255
+
256
+ def install_command(system: str) -> list[str] | None:
257
+ """The command that installs Ollama on this computer, or None when it has to be downloaded by hand."""
258
+ if system == "Linux" and shutil.which("curl") and shutil.which("sh"):
259
+ return ["sh", "-c", "curl -fsSL https://ollama.com/install.sh | sh"]
260
+ if system == "Darwin" and shutil.which("brew"):
261
+ return ["brew", "install", "ollama"]
262
+ if system == "Windows" and shutil.which("winget"):
263
+ return ["winget", "install", "-e", "--id", "Ollama.Ollama", "--accept-source-agreements", "--accept-package-agreements"]
264
+ return None
265
+
266
+
267
+ def install_ollama(console: Console, system: str, run: Callable[[list[str]], int] = subprocess.call) -> bool:
268
+ cmd = install_command(system)
269
+ if cmd is None:
270
+ _say(console, Text(f"Install Ollama by hand: {INSTALL_OLLAMA.get(system, INSTALL_OLLAMA['Linux'])}. Then run pal again.", style=theme.INK))
271
+ return False
272
+ _say(console, Text("Installing Ollama: " + " ".join(cmd), style=theme.MUTED))
273
+ try:
274
+ ok = run(cmd) == 0
275
+ except OSError:
276
+ ok = False
277
+ if system == "Windows" and not ollama_installed(): # winget puts it here; this terminal's PATH is from before
278
+ exe = Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama"
279
+ if (exe / "ollama.exe").is_file():
280
+ os.environ["PATH"] = f"{exe}{os.pathsep}{os.environ.get('PATH', '')}"
281
+ return ok and ollama_installed()
282
+
283
+
284
+ def start_ollama(host: str, wait_s: float = OLLAMA_START_WAIT_S, info: Callable[[str], dict] | None = None,
285
+ popen: Callable | None = None, sleep: Callable[[float], None] | None = None) -> bool:
286
+ """Start `ollama serve` in the background (it keeps running after Pal exits, like the Ollama app) and wait until it answers."""
287
+ import time
288
+ info = info or backend.local_info
289
+ sleep = sleep or time.sleep
290
+ exe = shutil.which("ollama")
291
+ if not exe:
292
+ return False
293
+ from pal.paths import pal_home
294
+ log = pal_home() / "ollama.log"
295
+ log.parent.mkdir(parents=True, exist_ok=True)
296
+ kwargs: dict = {"stdout": log.open("ab"), "stderr": subprocess.STDOUT, "stdin": subprocess.DEVNULL}
297
+ if os.name == "nt":
298
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
299
+ else:
300
+ kwargs["start_new_session"] = True
301
+ try:
302
+ (popen or subprocess.Popen)([exe, "serve"], **kwargs)
303
+ except OSError:
304
+ return False
305
+ waited = 0.0
306
+ while waited < wait_s:
307
+ if info(host).get("ok"):
308
+ return True
309
+ sleep(0.5)
310
+ waited += 0.5
311
+ return bool(info(host).get("ok"))
312
+
313
+
314
+ def ensure_local(console: Console, hw: Hardware, host: str = backend.DEFAULT_HOST, interactive: bool = True,
315
+ ask: Callable[[str], bool] | None = None, installer: Callable | None = None, starter: Callable | None = None,
316
+ info: Callable[[str], dict] | None = None, setup: Callable | None = None, ollama: Callable[[], bool] | None = None) -> LocalStatus:
317
+ """Make local models usable before the session starts: install Ollama (asking first), start it, and download the models chosen
318
+ for this GPU when they are missing (asking first). Never fails: the status says what is not ready and the session goes on."""
319
+ from pal.terminal.local_models import api_only
320
+ info = info or backend.local_info
321
+ ollama = ollama or ollama_installed
322
+ ask = ask or (lambda q: _confirm(console, q))
323
+ if api_only(hw):
324
+ return LocalStatus("off", "no GPU: API models only, local helper jobs off")
325
+ if not _is_local_host(host):
326
+ state = info(host)
327
+ return LocalStatus("ready" if state.get("ok") else "remote", f"Ollama at {host}" + ("" if state.get("ok") else " is not answering"), state)
328
+ if not ollama():
329
+ if interactive and ask("Local models run with Ollama, which is not installed. Install it now?"):
330
+ (installer or install_ollama)(console, hw.system)
331
+ if not ollama():
332
+ return LocalStatus("missing", f"Ollama is not installed: {INSTALL_OLLAMA.get(hw.system, INSTALL_OLLAMA['Linux'])}")
333
+ state = info(host)
334
+ if not state.get("ok"):
335
+ if not (starter or start_ollama)(host):
336
+ return LocalStatus("stopped", "Ollama did not start: see ~/.pal/ollama.log, or start it yourself with ollama serve")
337
+ state = info(host)
338
+ prefs = backend.load_prefs()
339
+ have = set(backend.chat_models(state))
340
+ rec = recommend(hw)
341
+ local_main = prefs.get("provider", backend.LOCAL) == backend.LOCAL # an API main model needs no big local download
342
+ wanted = [m for m in ((prefs.get("model") or (rec.main.tag if rec else "")) if local_main else "",
343
+ prefs.get("helper_model") or (rec.helper.tag if rec else "")) if m]
344
+ if interactive and (needs_setup() or any(m not in have for m in wanted)):
345
+ (setup or run_setup)(console, hw, host=host, ask=ask)
346
+ state = info(host)
347
+ prefs = backend.load_prefs()
348
+ installed = backend.chat_models(state)
349
+ if not installed:
350
+ return LocalStatus("ready", "Ollama running, no models yet: pal setup downloads the ones for this GPU", state)
351
+ main = prefs.get("model") if prefs.get("provider") == backend.LOCAL and prefs.get("model") in installed else ""
352
+ helper = prefs.get("helper_model") if prefs.get("helper_model") in installed else ""
353
+ detail = "Ollama running" + (f" · {main}" if main else f" · {len(installed)} model{'s' if len(installed) != 1 else ''}") + (f" · helper {helper}" if helper else "")
354
+ return LocalStatus("ready", detail, state)
pal/terminal/syntax.py ADDED
@@ -0,0 +1,98 @@
1
+ """Does a file the model wrote parse? Checked before you review a change, so a missing bracket is fixed by the Build agent, not found by you.
2
+
3
+ Python, JSON, YAML and TOML are checked with their own parsers. JavaScript, TypeScript, Go, Rust, Java, Kotlin, C, C++, C#, Ruby, PHP, Swift,
4
+ Scala, Lua and shell use tree-sitter (installed with Pal for the code graph). Other files are not checked. A check never runs the code.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ import json
10
+ from dataclasses import dataclass
11
+ from functools import lru_cache
12
+ from pathlib import PurePosixPath
13
+
14
+ # extension -> (tree-sitter module, the function that returns its language)
15
+ TREE_SITTER = {
16
+ ".js": ("tree_sitter_javascript", "language"), ".jsx": ("tree_sitter_javascript", "language"), ".mjs": ("tree_sitter_javascript", "language"),
17
+ ".cjs": ("tree_sitter_javascript", "language"), ".ts": ("tree_sitter_typescript", "language_typescript"),
18
+ ".tsx": ("tree_sitter_typescript", "language_tsx"), ".go": ("tree_sitter_go", "language"), ".rs": ("tree_sitter_rust", "language"),
19
+ ".java": ("tree_sitter_java", "language"), ".kt": ("tree_sitter_kotlin", "language"), ".kts": ("tree_sitter_kotlin", "language"),
20
+ ".c": ("tree_sitter_c", "language"), ".h": ("tree_sitter_c", "language"), ".cpp": ("tree_sitter_cpp", "language"),
21
+ ".cc": ("tree_sitter_cpp", "language"), ".hpp": ("tree_sitter_cpp", "language"), ".cs": ("tree_sitter_c_sharp", "language"),
22
+ ".rb": ("tree_sitter_ruby", "language"), ".php": ("tree_sitter_php", "language_php"), ".swift": ("tree_sitter_swift", "language"),
23
+ ".scala": ("tree_sitter_scala", "language"), ".lua": ("tree_sitter_lua", "language"), ".sh": ("tree_sitter_bash", "language"),
24
+ ".bash": ("tree_sitter_bash", "language"),
25
+ }
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SyntaxProblem:
30
+ path: str
31
+ line: int
32
+ message: str
33
+
34
+ def __str__(self) -> str:
35
+ return f"{self.path} line {self.line}: {self.message}"
36
+
37
+
38
+ @lru_cache(maxsize=32)
39
+ def _parser(ext: str):
40
+ """A tree-sitter parser for the extension, or None when the grammar is not installed."""
41
+ mod_name, fn = TREE_SITTER[ext]
42
+ try:
43
+ from tree_sitter import Language, Parser
44
+ mod = importlib.import_module(mod_name)
45
+ return Parser(Language(getattr(mod, fn)()))
46
+ except Exception: # noqa: BLE001 (grammar missing or an incompatible version: the file is just not checked)
47
+ return None
48
+
49
+
50
+ def _first_error(node):
51
+ """The innermost ERROR (or missing) node on the first broken path through the tree."""
52
+ found = None
53
+ while node is not None:
54
+ if node.type == "ERROR" or node.is_missing:
55
+ found = node
56
+ nxt = next((c for c in node.children if c.has_error or c.is_missing or c.type == "ERROR"), None)
57
+ node = nxt
58
+ return found
59
+
60
+
61
+ def check(path: str, text: str) -> SyntaxProblem | None:
62
+ """The first syntax problem in the text, or None when it parses (or the language is not checked)."""
63
+ ext = PurePosixPath(path).suffix.lower()
64
+ try:
65
+ if ext in (".py", ".pyw"):
66
+ compile(text, path, "exec", dont_inherit=True)
67
+ elif ext == ".json":
68
+ json.loads(text)
69
+ elif ext in (".yaml", ".yml"):
70
+ import yaml
71
+ list(yaml.safe_load_all(text))
72
+ elif ext == ".toml":
73
+ try:
74
+ import tomllib
75
+ except ImportError: # Python 3.10: not checked
76
+ return None
77
+ tomllib.loads(text)
78
+ elif ext in TREE_SITTER:
79
+ parser = _parser(ext)
80
+ if parser is None:
81
+ return None
82
+ tree = parser.parse(text.encode("utf-8"))
83
+ if not tree.root_node.has_error:
84
+ return None
85
+ bad = _first_error(tree.root_node) or tree.root_node
86
+ what = f"missing {bad.type}" if bad.is_missing else "unexpected code"
87
+ return SyntaxProblem(path, bad.start_point[0] + 1, what)
88
+ else:
89
+ return None
90
+ except SyntaxError as e:
91
+ return SyntaxProblem(path, e.lineno or 1, e.msg)
92
+ except json.JSONDecodeError as e:
93
+ return SyntaxProblem(path, e.lineno, e.msg)
94
+ except Exception as e: # noqa: BLE001 (yaml and toml errors carry their own types)
95
+ mark = getattr(e, "problem_mark", None)
96
+ line = (mark.line + 1) if mark is not None else getattr(e, "lineno", 1) or 1
97
+ return SyntaxProblem(path, line, str(getattr(e, "problem", None) or e).splitlines()[0][:160])
98
+ return None
pal/terminal/theme.py ADDED
@@ -0,0 +1,37 @@
1
+ """Colours and the wordmark. Black, red and white."""
2
+ from __future__ import annotations
3
+
4
+ RED = "#E50914"
5
+ RED_HI = "#FF4D57"
6
+ WHITE = "#FFFFFF"
7
+ INK = "#E8E8E8"
8
+ MUTED = "#8A8A8A"
9
+ PANEL = "#1A1A1A"
10
+ BG = "#000000"
11
+
12
+ _GLYPHS = { # chunky pixel letters, 10 rows: ascender rows 0-1, x-height rows 2-7, descender rows 8-9
13
+ "p": [" ", " ", "XXXXX ", "XXXXXX", "XX XX", "XX XX", "XXXXXX", "XXXXX ", "XX ", "XX "],
14
+ "a": [" ", " ", " XXXX ", " XX", " XXXXX", "XX XX", "XX XX", " XXXXX", " ", " "],
15
+ "l": ["XXX ", " XX ", " XX ", " XX ", " XX ", " XX ", " XX ", " XXX", " ", " "],
16
+ ".": [" ", " ", " ", " ", " ", " ", "XX", "XX", " ", " "],
17
+ }
18
+
19
+
20
+ def wordmark(text: str = "pal", dot: bool = True) -> list[list[tuple[str, str]]]:
21
+ """The wordmark as lines of (characters, colour): pixels drawn with half blocks (two pixel rows per line), two cells per pixel. The final dot is red."""
22
+ cols: list[tuple[list[str], str]] = [(_GLYPHS[ch], WHITE) for ch in text]
23
+ if dot:
24
+ cols.append((_GLYPHS["."], RED))
25
+ lines: list[list[tuple[str, str]]] = []
26
+ for r in range(0, 10, 2):
27
+ line: list[tuple[str, str]] = []
28
+ for i, (glyph, colour) in enumerate(cols):
29
+ top, bottom = glyph[r], glyph[r + 1]
30
+ chars = ""
31
+ for x in range(max(len(top), len(bottom))):
32
+ t = x < len(top) and top[x] == "X"
33
+ b = x < len(bottom) and bottom[x] == "X"
34
+ chars += ("\u2588" if t and b else "\u2580" if t else "\u2584" if b else " ") * 2 # two cells per pixel: a cell is about twice as tall as wide
35
+ line.append((chars + (" " if i < len(cols) - 1 else ""), colour))
36
+ lines.append(line)
37
+ return lines