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/cli.py ADDED
@@ -0,0 +1,826 @@
1
+ """The `pal` command.
2
+
3
+ pal cli open a terminal session in this folder (Tab switches between the Build and Plan agents); plain `pal` does the same
4
+ pal web open the web interface in your default browser (it runs on this computer only)
5
+ pal ask "question" one answer from the Ask agent, then exit (text piped in is included; --plan or --build for the others)
6
+ pal doctor check this computer; also: pal config, pal tools, pal models (OpenRouter), pal agent, pal chat
7
+ pal --provider openrouter --model <name> use an API model (the key comes from the environment)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import os
13
+ import sys
14
+ from collections.abc import Callable, Sequence
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ from rich.text import Text
19
+
20
+ from pal import __version__
21
+ from pal.errors import PalError
22
+ from pal.llm_wrapper.modes import providers_with_keys
23
+ from pal.terminal import backend, flow_cli, theme, ui
24
+ from pal.terminal.local_models import api_only
25
+ from pal.terminal.session import AGENT_ABOUT, MODES, BudgetExceeded, PalSession, make_session
26
+ from pal.terminal.workflow import Workflow
27
+
28
+ if TYPE_CHECKING:
29
+ from pal.terminal.project import Project
30
+
31
+ NO_MODEL = ("No model to talk to yet. Either:\n"
32
+ " 1. start Ollama and pull a model: ollama pull llama3.1:8b (everything stays on this computer)\n"
33
+ " 2. or use an API model: set OPENROUTER_API_KEY (or OPENAI_API_KEY, ANTHROPIC_API_KEY ...), then\n"
34
+ " pal cli --provider openrouter --model <model name>")
35
+
36
+
37
+ def _common(p: argparse.ArgumentParser, suppress: bool = False) -> None:
38
+ """The session options, accepted before or after `cli`/`ask` (`pal --model x cli` and `pal cli --model x` both work)."""
39
+ d = (lambda v: argparse.SUPPRESS) if suppress else (lambda v: v)
40
+ p.add_argument("--provider", default=d(None), help="local (default), an API provider (openrouter, openai, anthropic, gemini, groq, deepseek, mistral, together), litellm (any LiteLLM model), custom (any OpenAI-compatible endpoint), lmstudio or vllm")
41
+ p.add_argument("--model", default=d(None), help="the model name")
42
+ p.add_argument("--mode", choices=MODES, default=d(None), help="start with the Build, Plan or Ask agent (default build)")
43
+ p.add_argument("--host", default=d(None), help="Ollama address (default http://localhost:11434)")
44
+ p.add_argument("--dir", default=d("."), help="the folder Pal works in (default: here)")
45
+ p.add_argument("--budget", type=int, default=d(0), help="stop before this session uses more than this many tokens (default: no cap)")
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ p = argparse.ArgumentParser(prog="pal", description="Pal: build and plan with fewer tokens, in your terminal (pal cli) or your browser (pal web).")
50
+ p.add_argument("--version", action="version", version=f"pal {__version__}")
51
+ _common(p)
52
+ sub = p.add_subparsers(dest="cmd")
53
+ c = sub.add_parser("cli", help="open a terminal session in this folder")
54
+ _common(c, suppress=True)
55
+ w = sub.add_parser("web", help="open the web interface in your default browser")
56
+ w.add_argument("--port", type=int, default=None, help="the port to use on this computer (default 5000)")
57
+ w.add_argument("--page", choices=["studio", "chat"], default="studio", help="which page to open: the Studio (default) or the chat")
58
+ w.add_argument("--no-open", action="store_true", help="start the server without opening a browser")
59
+ w.add_argument("--dir", default=".", help="the folder to set up for the chat (default: here)")
60
+ w.add_argument("--host", default=None, help="Ollama address (default http://localhost:11434)")
61
+ st = sub.add_parser("setup", help="look at this computer's GPU and set up the local models that suit it")
62
+ st.add_argument("--list", action="store_true", help="show every local model by the GPU memory it needs, and how each fits this computer")
63
+ st.add_argument("--yes", action="store_true", help="download without asking")
64
+ st.add_argument("--hardware", help="plan for another computer instead: cpu:16, gpu:12, gpu:24:64, mac:16")
65
+ st.add_argument("--host", default=None, help="Ollama address (default http://localhost:11434)")
66
+ a = sub.add_parser("ask", help="ask one question and print the answer")
67
+ _common(a, suppress=True)
68
+ a.add_argument("question", nargs="+")
69
+ a.add_argument("--plan", action="store_true", help="use the Plan agent: a plan instead of an answer")
70
+ a.add_argument("--build", action="store_true", help="use the Build agent: it may propose file changes (asks before writing)")
71
+ b = sub.add_parser("bench", help="run one task as a plain chat and through Pal, on the same model, and compare the real tokens")
72
+ _common(b, suppress=True)
73
+ b.add_argument("task", nargs="+", help="what to build, as you would type it")
74
+ b.add_argument("--then", action="append", default=None, metavar="MESSAGE", help="a follow-up message (repeat for more; default: two standard ones)")
75
+ b.add_argument("--no-follow-ups", action="store_true", help="only the task itself")
76
+ b.add_argument("--json", action="store_true", help="print the result as JSON")
77
+ p.epilog = ("more: pal doctor (check this computer), pal models (browse OpenRouter models), pal config, pal tools. "
78
+ "In a session, /help lists the commands.")
79
+ return p
80
+
81
+
82
+ def bench(args) -> int:
83
+ """pal bench: the same task, plain chat versus Pal, on the same model."""
84
+ import json as _json
85
+
86
+ from rich.table import Table
87
+
88
+ from pal.terminal.bench import run_bench
89
+ console = ui.make_console()
90
+ host = args.host or backend.DEFAULT_HOST
91
+ prefs = backend.load_prefs()
92
+ if args.provider and args.provider != backend.LOCAL:
93
+ provider, model = args.provider, args.model or prefs.get("model", "")
94
+ else:
95
+ info = backend.local_info(host)
96
+ provider, model = backend.pick_default(info, {**prefs, **({"provider": backend.LOCAL, "model": args.model} if args.model else {})})
97
+ model = args.model or model
98
+ if not model:
99
+ print("pal bench: choose a model, e.g. pal bench --provider openrouter --model <name> \"your task\"", file=sys.stderr)
100
+ return 2
101
+ try:
102
+ backend.make_core(provider, model, host)
103
+ except PalError as e:
104
+ print(f"pal bench: {e}", file=sys.stderr)
105
+ return 2
106
+ follow = [] if args.no_follow_ups else args.then
107
+ task = " ".join(args.task)
108
+ status = console.status(Text("Starting\u2026", style=theme.MUTED), spinner="dots", spinner_style=theme.RED)
109
+ with status:
110
+ result = run_bench(Path(args.dir), task, lambda: backend.make_core(provider, model, host), model, follow,
111
+ progress=lambda m: status.update(Text(m, style=theme.MUTED)))
112
+ if args.json:
113
+ print(_json.dumps(result.to_dict(), indent=1))
114
+ return 0
115
+ ui.say(console, Text.assemble(("pal bench ", f"bold {theme.WHITE}"), (f"{model} ({provider}) \u00B7 {ui.short_path(Path(args.dir).resolve())}", theme.MUTED)))
116
+ ui.say(console, Text(f"Task: {task}", style=theme.INK))
117
+ console.print()
118
+ grid = Table(box=None, padding=(0, 2), header_style=f"bold {theme.WHITE}")
119
+ for col, just in (("step", "left"), ("agent", "left"), ("plain chat", "right"), ("Pal", "right"), ("saved", "right")):
120
+ grid.add_column(col, justify="left" if just == "left" else "right")
121
+ for i in range(max(len(result.plain.steps), len(result.pal.steps))):
122
+ a = result.plain.steps[i] if i < len(result.plain.steps) else None
123
+ b = result.pal.steps[i] if i < len(result.pal.steps) else None
124
+ saved = (a.total - b.total) if a and b else None
125
+ first = a or b
126
+ grid.add_row(str(i + 1), first.mode if first else "", f"{a.total:,}" if a else "-", f"{b.total:,}" if b else "-",
127
+ Text(f"{saved:,}" if saved is not None else "-", style=theme.RED_HI if saved and saved > 0 else theme.MUTED))
128
+ grid.add_row("", Text("total", style=f"bold {theme.WHITE}"), f"{result.plain.total:,}", f"{result.pal.total:,}",
129
+ Text(f"{result.saved:,} ({result.saved_pct:g}%)", style=f"bold {theme.RED_HI}"))
130
+ ui.say(console, grid)
131
+ console.print()
132
+ for arm in (result.plain, result.pal):
133
+ files = sorted({f for s in arm.steps for f in s.files})
134
+ ui.say(console, Text(f"{arm.name:<6} {arm.seconds:g}s \u00B7 {arm.calls} model call{'s' if arm.calls != 1 else ''} \u00B7 wrote: {', '.join(files) or 'no files'}"
135
+ + (f" \u00B7 stopped: {arm.error}" if arm.error else ""), style=theme.MUTED))
136
+ est = result.plain.estimated_calls + result.pal.estimated_calls
137
+ ui.say(console, Text("Token counts are the model's own." if not est else f"{est} call(s) had no token count from the model, so those are estimates (characters / 4).", style=theme.MUTED))
138
+ ui.say(console, Text("Pal's local helper jobs are off in the bench, so its saving here comes from the project map, graph, documents and history alone.", style=theme.MUTED))
139
+ console.print()
140
+ return 0
141
+
142
+
143
+ def setup(args) -> int:
144
+ from pal.terminal.hardware import from_spec
145
+ from pal.terminal.setup import pull, run_setup, show_table
146
+ console = ui.make_console()
147
+ try:
148
+ hw = from_spec(args.hardware) if args.hardware else backend.hardware()
149
+ except ValueError as e:
150
+ print(f"pal setup: {e}", file=sys.stderr)
151
+ return 2
152
+ if args.list:
153
+ show_table(console, hw)
154
+ return 0
155
+ run_setup(console, hw, host=args.host or backend.DEFAULT_HOST, assume_yes=args.yes, puller=pull, dry=bool(args.hardware))
156
+ return 0
157
+
158
+
159
+ def first_run() -> None:
160
+ """The first `pal cli` or `pal web` in a terminal runs setup once."""
161
+ from pal.terminal.setup import needs_setup, run_setup
162
+ if needs_setup() and sys.stdin.isatty() and sys.stdout.isatty():
163
+ console = ui.make_console()
164
+ run_setup(console, backend.hardware())
165
+ console.print()
166
+
167
+
168
+ def prepare_folder(args, console, core_factory: Callable | None = None) -> tuple[str, Project | None]:
169
+ """Before a session: get local models ready (install and start Ollama, download the models for this GPU, asking first), then set up the
170
+ folder: documents to markdown, the project map, the code graph (in the background). Returns (a line about the local model, the Project)."""
171
+ from pal.terminal.project import Project
172
+ from pal.terminal.setup import ensure_local
173
+ local = ""
174
+ if core_factory is None:
175
+ tty = sys.stdin.isatty() and sys.stdout.isatty()
176
+ local = ensure_local(console, backend.hardware(), args.host or backend.DEFAULT_HOST, interactive=tty).detail
177
+ folder = Path(getattr(args, "dir", ".") or ".")
178
+ if os.environ.get("PAL_SKIP_PROJECT"):
179
+ return local, None
180
+ try:
181
+ with console.status(Text(f"Setting up {ui.short_path(folder.resolve())}\u2026", style=theme.MUTED), spinner="dots", spinner_style=theme.RED):
182
+ project = Project(folder)
183
+ project.prepare()
184
+ except Exception as e: # noqa: BLE001 (the session still starts without the folder's map, graph and documents)
185
+ ui.say(console, Text(f"Could not set up this folder: {type(e).__name__}: {str(e)[:200]}", style=theme.RED_HI))
186
+ return local, None
187
+ return local, project
188
+
189
+
190
+ def web(args) -> int:
191
+ """Start the web interface on this computer and open it in the default browser. It listens on 127.0.0.1 only, unless the settings say otherwise.
192
+ The helper model chosen in setup does the web interface's small jobs too."""
193
+ from pal.cli import _serve, load_dotenv
194
+ from pal.config import load_settings
195
+ from pal.errors import ConfigError
196
+ load_dotenv()
197
+ console = ui.make_console()
198
+ local, project = prepare_folder(args, console)
199
+ if local:
200
+ ui.say(console, Text(f"Local LLM: {local}", style=theme.MUTED))
201
+ if project is not None:
202
+ r = project.report
203
+ ui.say(console, Text(f"This folder: {r.code_files} files \u00B7 {len(r.converted)} documents as markdown \u00B7 code graph {r.graph}", style=theme.MUTED))
204
+ overrides: dict = {"server": {"port": args.port}} if args.port else {}
205
+ helper = backend.load_prefs().get("helper_model")
206
+ if api_only(backend.hardware()):
207
+ overrides["local_llm"] = {"enabled": False} # no GPU: API models only, no local helper jobs
208
+ elif helper:
209
+ overrides["local_llm"] = {"backend": "ollama", "model": helper}
210
+ try:
211
+ settings = load_settings(overrides=overrides or None)
212
+ except ConfigError as e:
213
+ print(f"config error: {e}", file=sys.stderr)
214
+ return 2
215
+ graph = None
216
+ if project is not None: # the web chat gets the folder too: its graph, documents and map
217
+ from pal.terminal.project import ProjectGraph
218
+ graph = ProjectGraph(project.root / "graph", settings.knowledge_graph)
219
+ return _serve(settings, open_browser=not args.no_open, page="studio" if args.page == "studio" else "", graph=graph,
220
+ documents=project.documents() if project is not None else None)
221
+
222
+
223
+ def _utf8() -> None:
224
+ for stream in (sys.stdout, sys.stderr):
225
+ try:
226
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] # old Windows consoles default to a legacy code page
227
+ except (AttributeError, ValueError):
228
+ pass
229
+
230
+
231
+ NO_GPU = ("No GPU on this computer, so Pal uses an API model. Choose one with:\n"
232
+ " pal cli --provider openrouter --model <name> (set OPENROUTER_API_KEY, or OPENAI_API_KEY, ANTHROPIC_API_KEY ...)\n"
233
+ " browse models: pal models --free")
234
+
235
+
236
+ def start(args, core_factory: Callable | None = None, project=None) -> PalSession | None:
237
+ """The session for these arguments, or None (after saying why) when there is no model to talk to.
238
+ On a computer without a GPU there is no local model: the session uses an API model and Pal's local helper jobs are off."""
239
+ console = ui.make_console()
240
+ host = args.host or backend.DEFAULT_HOST
241
+ prefs = backend.load_prefs()
242
+ no_gpu = core_factory is None and api_only(backend.hardware())
243
+ if no_gpu:
244
+ return _start_api_only(args, prefs, console, project)
245
+ want = args.provider or prefs.get("provider", "")
246
+ if want in backend.PROVIDER_KEYS and not os.environ.get(backend.PROVIDER_KEYS[want]) and core_factory is None and sys.stdin.isatty():
247
+ ask_key(console, want) # the main model is an API model: its key (never saved) for this session
248
+ info = backend.local_info(host)
249
+ if args.provider and args.provider != backend.LOCAL:
250
+ provider, model = args.provider, args.model or prefs.get("model", "")
251
+ else:
252
+ provider, model = backend.pick_default(info, {**prefs, **({"provider": backend.LOCAL, "model": args.model} if args.model else {})})
253
+ if args.model:
254
+ model = args.model
255
+ if not args.provider and not model: # nothing local: maybe a key is set, and the last API model still works
256
+ provider, model = backend.pick_default(info, prefs)
257
+ mode = args.mode or prefs.get("mode", "build")
258
+ try:
259
+ core = (core_factory or backend.make_core)(provider, model, host) if (model or core_factory) else None
260
+ except PalError as e:
261
+ console.print(Text(f"\n{e}\n", style=theme.RED_HI))
262
+ return None
263
+ if core is None:
264
+ console.print(Text("\n" + NO_MODEL + "\n", style=theme.MUTED), soft_wrap=True)
265
+ return None
266
+ helper = backend.make_helper(prefs, info, host) if core_factory is None and info.get("ok") else None
267
+ session = make_session(core, model, provider, Path(args.dir), mode, helper=helper, project=project)
268
+ session.budget = max(0, getattr(args, "budget", 0) or 0)
269
+ session.helper_model = prefs.get("helper_model", "") if helper else ""
270
+ apply_role_models(session, core_factory, host, console)
271
+ return session
272
+
273
+
274
+ def ask_key(console, provider: str) -> bool:
275
+ """Ask for a provider's key for this session only (it is never written anywhere). True when one was given."""
276
+ import getpass
277
+ var = backend.PROVIDER_KEYS[provider]
278
+ console.print(Text(f"\n Your main model uses {provider}. Paste your {provider} API key (not shown, not saved; or set {var} to skip this):",
279
+ style=theme.WHITE))
280
+ try:
281
+ key = getpass.getpass(" key: ").strip()
282
+ except (EOFError, KeyboardInterrupt):
283
+ console.print()
284
+ return False
285
+ if key:
286
+ os.environ[var] = key
287
+ return bool(key)
288
+
289
+
290
+ def _start_api_only(args, prefs: dict, console, project=None) -> PalSession | None:
291
+ from pal.terminal.setup import ask_api
292
+ keyed = lambda p: p in backend.OPEN_PROVIDERS or (p in backend.PROVIDER_KEYS and bool(os.environ.get(backend.PROVIDER_KEYS[p]))) # noqa: E731
293
+ if args.provider == backend.LOCAL:
294
+ console.print(Text("\nThis computer has no GPU, so Pal does not run local models here. Use an API model instead.\n", style=theme.RED_HI))
295
+ provider, model = (args.provider, args.model) if args.provider and args.provider != backend.LOCAL else (prefs.get("provider", ""), args.model or prefs.get("model", ""))
296
+ if provider == backend.LOCAL:
297
+ provider, model = "", ""
298
+ if not (provider and model and keyed(provider)):
299
+ if sys.stdin.isatty() and sys.stdout.isatty():
300
+ console.print(Text("\n No GPU on this computer, so Pal uses an API model.\n", style=f"bold {theme.RED_HI}"))
301
+ chosen = ask_api(console, backend.hardware().system)
302
+ if not chosen:
303
+ console.print(Text("\n" + NO_GPU + "\n", style=theme.MUTED), soft_wrap=True)
304
+ return None
305
+ provider, model = chosen
306
+ else:
307
+ console.print(Text("\n" + NO_GPU + "\n", style=theme.MUTED), soft_wrap=True)
308
+ return None
309
+ try:
310
+ core = backend.make_core(provider, model)
311
+ except PalError as e:
312
+ console.print(Text(f"\n{e}\n", style=theme.RED_HI))
313
+ return None
314
+ session = make_session(core, model, provider, Path(args.dir), args.mode or prefs.get("mode", "build"), local=False, project=project)
315
+ session.budget = max(0, getattr(args, "budget", 0) or 0)
316
+ apply_role_models(session, None, args.host or backend.DEFAULT_HOST, console)
317
+ return session
318
+
319
+
320
+ API_CHOICE = "An API model…"
321
+
322
+
323
+ def pick_model(console, session: PalSession, info: dict) -> str | None:
324
+ """/model: the models that suit this GPU (installed ones marked), or an API model. A local model that is not installed yet is downloaded
325
+ (after asking). Returns the model to switch to (a local name, or provider:model), or None."""
326
+ from pal.terminal import decide
327
+ from pal.terminal.local_models import suitable
328
+ from pal.terminal.setup import ollama_installed, pull
329
+ hw = backend.hardware()
330
+ installed = backend.chat_models(info) if info.get("ok") else []
331
+ options = [] if not session.local else suitable(hw, [m for m in installed if m != session.helper_model])
332
+ choices = [decide.Option(tag, about) for tag, about, _ in options]
333
+ for name in installed: # anything else already installed (not the helper)
334
+ if name != session.helper_model and all(o.label != name for o in choices):
335
+ choices.append(decide.Option(name, "installed"))
336
+ choices = choices[:7] + [decide.Option(API_CHOICE, "OpenAI, Anthropic, Gemini, OpenRouter, any LiteLLM model or your own endpoint")]
337
+ current = session.model if session.provider == backend.LOCAL else ""
338
+ q = decide.Question(f"Main model (now: {session.model}, {session.provider}). The best for your GPU is first; type any Ollama name to get it.",
339
+ choices, "Model", 0, other=True)
340
+ answer = decide.ask(console, q)
341
+ decide.show_answer(console, q, answer)
342
+ if not answer or answer == current:
343
+ return None
344
+ if answer == API_CHOICE:
345
+ try:
346
+ spec = console.input(Text(" provider:model (for example openai:gpt-5, anthropic:<model>, openrouter:<model>, litellm:<model>): ", style=theme.WHITE)).strip()
347
+ except (EOFError, KeyboardInterrupt):
348
+ return None
349
+ provider, _ = backend.split_spec(spec)
350
+ if provider in backend.PROVIDER_KEYS and not os.environ.get(backend.PROVIDER_KEYS[provider]) and not ask_key(console, provider):
351
+ return None
352
+ return spec or None
353
+ if not session.local:
354
+ ui.say(console, Text("This computer has no GPU, so local models are off.", style=theme.RED_HI))
355
+ return None
356
+ if answer not in installed:
357
+ if not ollama_installed():
358
+ ui.say(console, Text("Ollama is not installed: run pal setup first.", style=theme.RED_HI))
359
+ return None
360
+ if not ui.confirm(console, f"{answer} is not installed. Download it now?"):
361
+ return None
362
+ ui.say(console, Text(f"Downloading {answer} ...", style=theme.WHITE))
363
+ if not pull(answer):
364
+ ui.say(console, Text(f"The download of {answer} did not finish.", style=theme.RED_HI))
365
+ return None
366
+ return answer
367
+
368
+
369
+ def set_role_model(session: PalSession, role: str, spec: str, core_factory=None, host: str = backend.DEFAULT_HOST) -> str:
370
+ """Give an agent its own model ('default' gives it back the session's). Returns '' or what went wrong."""
371
+ if spec in ("default", "off", "none", ""):
372
+ session.role_models.pop(role, None)
373
+ setter = getattr(session.client, "set_role_core", None)
374
+ if setter:
375
+ setter(role, None)
376
+ return ""
377
+ provider, name = backend.split_spec(spec)
378
+ if provider == backend.LOCAL and not session.local:
379
+ return "this computer has no GPU, so local models are off: use /model <agent> <provider>:<model>"
380
+ try:
381
+ core = (core_factory or backend.make_core)(provider, name, host)
382
+ except PalError as e:
383
+ return str(e)
384
+ setter = getattr(session.client, "set_role_core", None)
385
+ if setter is None:
386
+ return "this session cannot give agents their own models"
387
+ setter(role, core)
388
+ session.role_models[role] = (provider, name)
389
+ return ""
390
+
391
+
392
+ def apply_role_models(session: PalSession, core_factory=None, host: str = backend.DEFAULT_HOST, console=None) -> None:
393
+ """The per-agent models saved with /model <agent> <model>, for a new session."""
394
+ prefs = backend.load_prefs()
395
+ for role in MODES:
396
+ spec = prefs.get(f"model_{role}", "")
397
+ if spec:
398
+ problem = set_role_model(session, role, spec, core_factory, host)
399
+ if problem and console is not None:
400
+ ui.say(console, Text(f"{role.capitalize()} agent keeps the session's model ({spec}: {problem})", style=theme.MUTED))
401
+
402
+
403
+ def _switch(args, session: PalSession, spec: str, core_factory, console) -> PalSession:
404
+ """/model name (local model) or /model provider:name"""
405
+ provider, name = backend.split_spec(spec)
406
+ host = args.host or backend.DEFAULT_HOST
407
+ if provider == backend.LOCAL and not session.local:
408
+ ui.say(console, Text("This computer has no GPU, so local models are off. Use /model <provider>:<name>, for example /model openrouter:<name>", style=theme.RED_HI))
409
+ return session
410
+ if provider == backend.LOCAL:
411
+ installed = backend.chat_models(backend.local_info(host))
412
+ if installed and name not in installed:
413
+ ui.say(console, Text(f"{name} is not installed. Installed: {', '.join(installed)}", style=theme.RED_HI))
414
+ return session
415
+ try:
416
+ core = (core_factory or backend.make_core)(provider, name, host)
417
+ except PalError as e:
418
+ ui.say(console, Text(str(e), style=theme.RED_HI))
419
+ return session
420
+ mode, cwd, budget, helper, helper_name = session.mode, session.cwd, session.budget, session.helper_core, session.helper_model
421
+ session.close()
422
+ backend.save_prefs(provider=provider, model=name)
423
+ ui.say(console, Text(f"Now using {name}. This starts a new conversation.", style=theme.MUTED))
424
+ console.print()
425
+ fresh = make_session(core, name, provider, cwd, mode, helper=helper, local=session.local, project=session.project)
426
+ fresh.budget, fresh.helper_model, fresh.local_status = budget, helper_name, session.local_status
427
+ apply_role_models(fresh, core_factory, host)
428
+ return fresh
429
+
430
+
431
+ def split_paths(text: str) -> list[str]:
432
+ """File paths as a terminal types them when files are dropped on it: quoted, or with escaped spaces."""
433
+ import shlex
434
+ try:
435
+ parts = shlex.split(text, posix=os.name != "nt")
436
+ except ValueError:
437
+ return []
438
+ return [x.strip("\"'") for x in parts if x.strip("\"'")]
439
+
440
+
441
+ def dropped_files(text: str) -> list[str]:
442
+ """The paths, when everything typed is one or more existing files (what dropping files on the window produces); else []."""
443
+ paths = split_paths(text)
444
+ return paths if paths and all(Path(x).expanduser().is_file() for x in paths) else []
445
+
446
+
447
+ def attach(session: PalSession, paths: list[str], console) -> None:
448
+ for raw in paths:
449
+ name, note = session.attach(raw)
450
+ if name:
451
+ ui.say(console, Text.assemble((" \u2713 attached ", theme.RED_HI), (name, f"bold {theme.WHITE}"), (f" {note}" if note else "", theme.MUTED)))
452
+ else:
453
+ ui.say(console, Text(f" \u2717 {note}", style=theme.RED_HI))
454
+ if session.pending:
455
+ ui.say(console, Text(f" {len(session.pending)} file{'s' if len(session.pending) != 1 else ''} will go with your next message.", style=theme.MUTED))
456
+
457
+
458
+ def _fresh(session: PalSession, args, core_factory) -> PalSession:
459
+ """A new session with the same model, agent, cap and helper: a clean conversation for the engine."""
460
+ mode, cwd, provider, model, budget = session.mode, session.cwd, session.provider, session.model, session.budget
461
+ helper, helper_name, local = session.helper_core, session.helper_model, session.local
462
+ session.close()
463
+ core = (core_factory or backend.make_core)(provider, model, args.host or backend.DEFAULT_HOST)
464
+ fresh = make_session(core, model, provider, cwd, mode, helper=helper, local=local, project=session.project)
465
+ fresh.budget, fresh.helper_model, fresh.local_status = budget, helper_name, session.local_status
466
+ apply_role_models(fresh, core_factory, args.host or backend.DEFAULT_HOST)
467
+ return fresh
468
+
469
+
470
+ def _bind_status(wf: Workflow) -> None:
471
+ """What the status line shows about the plan: its name, branch and pending files."""
472
+ wf.session.status_note = lambda: (f"{wf.plan.name}" + (f"/{wf.plan.branch}" if wf.plan.branch != "main" else "")
473
+ + (f" \u00B7 autonomy {wf.plan.autonomy}" if wf.plan.autonomy != "semi" else "")
474
+ + (f" \u00B7 {len(wf.plan.b.pending)} pending" if wf.plan.b.pending else ""))
475
+
476
+
477
+ def handle(line: str, args, session: PalSession, console, core_factory, box: dict | None = None) -> tuple[PalSession, bool]:
478
+ """Run a /command. Returns (the session to use next, whether to keep going)."""
479
+ cmd, _, rest = line.strip().partition(" ")
480
+ rest = rest.strip()
481
+ if cmd in ("/exit", "/quit"):
482
+ return session, False
483
+ from pal.terminal.commands import handle_extra
484
+ extra = handle_extra(cmd, rest, args, session, console, core_factory, box)
485
+ if extra is not None:
486
+ return extra
487
+ if box is not None and cmd not in ("/new", "/cd") and flow_cli.handle_flow(cmd, rest, box["wf"], console):
488
+ return session, True
489
+ if cmd == "/help":
490
+ ui.show_help(console)
491
+ elif cmd == "/mode":
492
+ if rest in MODES:
493
+ session.mode = rest
494
+ else:
495
+ session.toggle_mode()
496
+ backend.save_prefs(mode=session.mode)
497
+ ui.say(console, Text(f"Agent: {session.mode.capitalize()} ({AGENT_ABOUT[session.mode]})", style=theme.MUTED))
498
+ console.print()
499
+ elif cmd == "/model" and rest.split(" ", 1)[0] in MODES:
500
+ role, _, spec = rest.partition(" ")
501
+ if not spec.strip():
502
+ prov, name = session.model_for(role)
503
+ ui.say(console, Text(f"{role.capitalize()} agent: {name} ({prov})" + ("" if role in session.role_models else " (the session's model)")
504
+ + f" change: /model {role} <provider>:<model> back: /model {role} default", style=theme.MUTED))
505
+ else:
506
+ problem = set_role_model(session, role, spec.strip(), core_factory, args.host or backend.DEFAULT_HOST)
507
+ if problem:
508
+ ui.say(console, Text(problem, style=theme.RED_HI))
509
+ else:
510
+ backend.save_prefs(**{f"model_{role}": "" if spec.strip() in ("default", "off", "none") else spec.strip()})
511
+ prov, name = session.model_for(role)
512
+ ui.say(console, Text(f"{role.capitalize()} agent now uses {name} ({prov}). Same conversation; saved for next time.", style=theme.MUTED))
513
+ console.print()
514
+ elif cmd == "/endpoint":
515
+ if rest.startswith(("http://", "https://")):
516
+ backend.save_prefs(custom_base_url=rest.strip())
517
+ ui.say(console, Text(f"Custom endpoint: {rest.strip()} (saved; the key comes from PAL_CUSTOM_API_KEY or /key custom <key>). Then: /model custom:<model>", style=theme.MUTED))
518
+ else:
519
+ ui.say(console, Text(f"Custom endpoint: {backend.custom_base_url() or 'none'}. Set one with /endpoint https://your-server/v1 (any OpenAI-compatible API)", style=theme.MUTED))
520
+ console.print()
521
+ elif cmd == "/model":
522
+ if rest:
523
+ switched = _switch(args, session, rest, core_factory, console)
524
+ if box is not None and switched is not session:
525
+ box["wf"] = Workflow(switched, box["wf"].store, plan=box["wf"].plan.name)
526
+ _bind_status(box["wf"])
527
+ return switched, True
528
+ info = backend.local_info(args.host or backend.DEFAULT_HOST)
529
+ if sys.stdin.isatty() and sys.stdout.isatty() and core_factory is None:
530
+ chosen = pick_model(console, session, info)
531
+ if chosen:
532
+ switched = _switch(args, session, chosen, core_factory, console)
533
+ if box is not None and switched is not session:
534
+ box["wf"] = Workflow(switched, box["wf"].store, plan=box["wf"].plan.name)
535
+ _bind_status(box["wf"])
536
+ return switched, True
537
+ else:
538
+ ui.show_models(console, session, info, backend.hardware(), [p for p, ok in providers_with_keys().items() if ok])
539
+ elif cmd == "/add":
540
+ paths = split_paths(rest)
541
+ if not paths:
542
+ ui.say(console, Text("Use: /add <file> [more files]. Any file on this computer: code, text, PDF, Word, PowerPoint, Excel. Or drop files on this window.", style=theme.MUTED))
543
+ attach(session, paths, console)
544
+ console.print()
545
+ elif cmd == "/key":
546
+ provider, _, key = rest.partition(" ")
547
+ if provider == "custom" and key.strip():
548
+ os.environ["PAL_CUSTOM_API_KEY"] = key.strip()
549
+ ui.say(console, Text("Key for the custom endpoint set for this session (not saved).", style=theme.MUTED))
550
+ console.print()
551
+ return session, True
552
+ if provider not in backend.PROVIDER_KEYS or not key.strip():
553
+ ui.say(console, Text(f"Use: /key <provider> <key> providers: {', '.join(backend.PROVIDER_KEYS)}. The key is kept for this session only, never saved.", style=theme.MUTED))
554
+ else:
555
+ os.environ[backend.PROVIDER_KEYS[provider]] = key.strip()
556
+ ui.say(console, Text(f"Key for {provider} set for this session (not saved). Now pick a model: /model {provider}:<name>", style=theme.MUTED))
557
+ console.print()
558
+ elif cmd == "/files":
559
+ ui.say(console, Text("\n".join(session.listing()) or "(this folder has no files Pal can see)", style=theme.MUTED))
560
+ console.print()
561
+ elif cmd == "/security":
562
+ findings, checked = session.audit()
563
+ counts = {sev: sum(1 for f in findings if f.severity == sev) for sev in ("critical", "high", "medium", "low")}
564
+ ui.say(console, Text.assemble(("Security check ", f"bold {theme.WHITE}"),
565
+ (f"{checked} files read \u00B7 " + " \u00B7 ".join(f"{v} {k}" for k, v in counts.items()), theme.MUTED)))
566
+ if findings:
567
+ ui.show_findings(console, findings)
568
+ else:
569
+ ui.say(console, Text("Nothing found. These are pattern checks: they catch common mistakes, they cannot prove the code is safe.", style=theme.MUTED))
570
+ if rest == "ai":
571
+ console.print()
572
+ turn_ai(session, findings, console)
573
+ else:
574
+ ui.say(console, Text("/security ai asks the Plan agent for a deeper review (uses tokens; secrets are hidden first)", style=theme.MUTED))
575
+ console.print()
576
+ elif cmd == "/budget":
577
+ if rest in ("off", "0", "none"):
578
+ session.budget = 0
579
+ ui.say(console, Text("No token cap for this session.", style=theme.MUTED))
580
+ elif rest.replace(",", "").replace("_", "").isdigit():
581
+ session.budget = int(rest.replace(",", "").replace("_", ""))
582
+ ui.say(console, Text(f"Token cap: {session.budget:,} for this session ({session.pal_tokens:,} used so far). Pal stops before going over.", style=theme.MUTED))
583
+ else:
584
+ ui.say(console, Text(f"Token cap: {f'{session.budget:,}' if session.budget else 'none'}. Set one with /budget 50000, or /budget off.", style=theme.MUTED))
585
+ console.print()
586
+ elif cmd in ("/tokens", "/stats"):
587
+ ui.show_tokens(console, session, rest)
588
+ elif cmd == "/project":
589
+ if rest == "rebuild" and session.project is not None:
590
+ with console.status(Text("Setting up this folder again\u2026", style=theme.MUTED), spinner="dots", spinner_style=theme.RED):
591
+ session.project.prepare()
592
+ session.client_add_documents()
593
+ ui.show_project(console, session)
594
+ if rest != "rebuild":
595
+ ui.say(console, Text("/project rebuild reads the folder again (new or changed documents and code)", style=theme.MUTED))
596
+ console.print()
597
+ elif cmd in ("/new", "/cd"):
598
+ if box is None:
599
+ return session, True
600
+ store = box["wf"].store
601
+ try:
602
+ if cmd == "/cd" and not rest:
603
+ raise ValueError("Use: /cd <plan name or number> (/plans lists them)")
604
+ fresh = _fresh(session, args, core_factory)
605
+ wf = Workflow(fresh, store, plan=box["wf"].plan.name)
606
+ name = wf.new(rest) if cmd == "/new" else wf.switch(rest)
607
+ except ValueError as e:
608
+ ui.say(console, Text(str(e), style=theme.RED_HI))
609
+ console.print()
610
+ return session, True
611
+ session, box["wf"] = fresh, wf
612
+ _bind_status(wf)
613
+ ui.say(console, Text(f"{'New plan' if cmd == '/new' else 'Now on plan'} {name}." + (" Its conversation is picked up where it stopped." if cmd == "/cd" and wf.plan.b.convo else ""), style=theme.MUTED))
614
+ console.print()
615
+ else:
616
+ ui.say(console, Text(f"Unknown command {cmd}. Type /help.", style=theme.RED_HI))
617
+ console.print()
618
+ return session, True
619
+
620
+
621
+ def _live(session: PalSession, seconds: float) -> Text:
622
+ """The line next to the spinner: what Pal is doing, for how long, and what it has saved so far."""
623
+ doing = {"build": "building", "plan": "planning", "ask": "thinking"}.get(session.mode, "working")
624
+ out = Text(f"Pal is {doing}\u2026 {seconds:.0f}s", style=theme.MUTED)
625
+ if session.saved > 0:
626
+ out.append(" \u00B7 saved so far ", style=theme.MUTED)
627
+ out.append(f"{session.saved:,} tokens ({session.saved_pct:g}%)", style=f"bold {theme.RED_HI}")
628
+ elif session.turns:
629
+ out.append(f" \u00B7 {session.pal_tokens:,} tokens used so far", style=theme.MUTED)
630
+ return out
631
+
632
+
633
+ def turn_ai(session: PalSession, findings, console) -> None:
634
+ """/security ai: the Plan agent reviews the project, with the scanner's findings and the files they point at."""
635
+ try:
636
+ with console.status(Text("Pal is reviewing the project\u2026", style=theme.MUTED), spinner="dots", spinner_style=theme.RED):
637
+ result = session.audit_ai(findings)
638
+ except BudgetExceeded as e:
639
+ ui.say(console, Text(f"Stopped: {e}", style=theme.RED_HI))
640
+ return
641
+ except KeyboardInterrupt:
642
+ ui.say(console, Text("Stopped.", style=theme.MUTED))
643
+ return
644
+ except Exception as e: # noqa: BLE001
645
+ ui.say(console, Text(f"Something went wrong: {type(e).__name__}: {str(e)[:300]}", style=theme.RED_HI))
646
+ return
647
+ ui.show_reply(console, result, session)
648
+
649
+
650
+ AUTONOMY_CYCLE = ("none", "basic", "plus", "semi") # Shift+Tab; "full" (runs commands without asking) only with /autonomy full
651
+
652
+
653
+ def cycle_autonomy(wf: Workflow) -> str:
654
+ """Shift+Tab: the next autonomy level. Returns the line the status bar shows."""
655
+ now = wf.plan.autonomy
656
+ nxt = AUTONOMY_CYCLE[(AUTONOMY_CYCLE.index(now) + 1) % len(AUTONOMY_CYCLE)] if now in AUTONOMY_CYCLE else "none"
657
+ wf.plan.autonomy, wf.plan.custom = nxt, {}
658
+ wf.save()
659
+ about = {"none": "asks before everything", "basic": "builds, you apply", "plus": "loads files it needs, you apply",
660
+ "semi": "loads files and continues plans, asks before applying and running"}[nxt]
661
+ return f"autonomy: {nxt} ({about}) shift+tab next"
662
+
663
+
664
+ def voice_flow(console) -> str:
665
+ """Record until Enter, then turn it into text. Returns the words, or '' (after saying why)."""
666
+ import threading
667
+
668
+ from pal.terminal import voice
669
+ if not voice.can_record():
670
+ ui.say(console, Text('Talking needs a microphone library: pip install "pal-code[voice]" (Linux also: sudo apt install libportaudio2). '
671
+ "Dictation apps such as Wispr Flow work without it: they type into the box.", style=theme.MUTED))
672
+ console.print()
673
+ return ""
674
+ if not voice.engines():
675
+ ui.say(console, Text('No speech-to-text yet: pip install "pal-code[voice]" (on this computer), or set OPENAI_API_KEY or GROQ_API_KEY.', style=theme.MUTED))
676
+ console.print()
677
+ return ""
678
+ stop, out, err = threading.Event(), {}, {}
679
+
680
+ def rec() -> None:
681
+ try:
682
+ out["wav"] = voice.record(stop)
683
+ except voice.VoiceUnavailable as e:
684
+ err["e"] = str(e)
685
+ stop.set()
686
+ th = threading.Thread(target=rec, daemon=True)
687
+ th.start()
688
+ try:
689
+ console.input(Text(" ● listening… press Enter when you are done ", style=f"bold {theme.RED_HI}"))
690
+ except (EOFError, KeyboardInterrupt):
691
+ stop.set()
692
+ th.join(2)
693
+ console.print()
694
+ return ""
695
+ stop.set()
696
+ th.join(5)
697
+ if err or not out.get("wav"):
698
+ ui.say(console, Text(err.get("e", "Nothing was recorded."), style=theme.RED_HI))
699
+ return ""
700
+ try:
701
+ with console.status(Text("Turning speech into text…", style=theme.MUTED), spinner="dots", spinner_style=theme.RED):
702
+ heard = voice.transcribe(out["wav"])
703
+ except voice.VoiceUnavailable as e:
704
+ ui.say(console, Text(str(e), style=theme.RED_HI))
705
+ return ""
706
+ ui.say(console, Text(f" heard ({heard.engine}): {heard.text or '(nothing)'}", style=theme.MUTED))
707
+ return heard.text
708
+
709
+
710
+ def interactive(args, core_factory: Callable | None = None) -> int:
711
+ console = ui.make_console()
712
+ local, project = prepare_folder(args, console, core_factory)
713
+ session = start(args, core_factory, project)
714
+ if session is None:
715
+ return 1
716
+ session.local_status = local
717
+ if project is not None:
718
+ ui.show_project(console, session)
719
+ console.print()
720
+ box = {"wf": Workflow(session)}
721
+ _bind_status(box["wf"])
722
+ if box["wf"].notice:
723
+ ui.say(console, Text(box["wf"].notice, style=theme.RED_HI))
724
+ console.print()
725
+ session.notice = box["wf"].notice.split(",")[0] + ": started a new plan"
726
+ first, draft = True, ""
727
+ from pal.terminal.history import History
728
+ history = History(session.cwd)
729
+ try:
730
+ while True:
731
+ text = ui.read_input(session, console, backend.save_prefs, landing=first, version=__version__, history=history, initial=draft,
732
+ on_shift_tab=lambda: cycle_autonomy(box["wf"]))
733
+ first, session.notice, draft = False, "", ""
734
+ if text is None:
735
+ break
736
+ if text.startswith("\x00voice\x00"): # Ctrl+Space: record, then back to the box with the words added
737
+ before = text[len("\x00voice\x00"):]
738
+ heard = voice_flow(console)
739
+ draft = (before + (" " if before and heard else "") + heard) if heard else before
740
+ continue
741
+ history.add(text)
742
+ text = text.strip()
743
+ if text == "/voice":
744
+ draft = voice_flow(console)
745
+ continue
746
+ dropped = dropped_files(text)
747
+ if dropped: # files dropped on the window: attach them to the next message
748
+ attach(session, dropped, console)
749
+ console.print()
750
+ continue
751
+ try:
752
+ if text.startswith("/"):
753
+ session, go = handle(text, args, session, console, core_factory, box)
754
+ if not go:
755
+ break
756
+ elif text:
757
+ flow_cli.run_turn(box["wf"], text, console)
758
+ except KeyboardInterrupt: # Esc or Ctrl+C at an awkward moment: back to the box, never out of Pal
759
+ ui.say(console, Text("Stopped.", style=theme.MUTED))
760
+ console.print()
761
+ finally:
762
+ box["wf"].close()
763
+ session.close()
764
+ console.print(Text("Bye.", style=theme.MUTED))
765
+ return 0
766
+
767
+
768
+ def ask_once(args, core_factory: Callable | None = None) -> int:
769
+ console = ui.make_console()
770
+ args.mode = "build" if args.build else "plan" if args.plan else "ask"
771
+ session = start(args, core_factory)
772
+ if session is None:
773
+ return 1
774
+ question = " ".join(args.question)
775
+ if not sys.stdin.isatty():
776
+ try:
777
+ piped = sys.stdin.read()
778
+ except (OSError, ValueError):
779
+ piped = ""
780
+ if piped.strip():
781
+ question = f"{question}\n\n```\n{piped[:150_000]}\n```"
782
+ try:
783
+ result = session.ask(question)
784
+ except Exception as e: # noqa: BLE001
785
+ print(f"pal: {type(e).__name__}: {str(e)[:300]}", file=sys.stderr)
786
+ return 1
787
+ try:
788
+ print(result.text)
789
+ used = result.prompt_tokens + result.completion_tokens
790
+ print(f"\n[{used:,} tokens, {result.seconds:.1f}s]", file=sys.stderr)
791
+ if result.changes and sys.stdin.isatty() and sys.stdout.isatty():
792
+ ui.show_changes(console, result.changes)
793
+ if ui.confirm(console, f"Write {len(result.changes)} file(s) to {ui.short_path(session.cwd)}?"):
794
+ for path, status in session.apply(result.changes):
795
+ print(f"{status}: {path}")
796
+ finally:
797
+ session.close()
798
+ return 0
799
+
800
+
801
+ TOOLS = ("doctor", "config", "tools", "models", "agent", "chat", "serve") # the older commands (pal.cli): checks, settings, agents
802
+
803
+
804
+ def main(argv: Sequence[str] | None = None, core_factory: Callable | None = None) -> int:
805
+ _utf8()
806
+ argv = list(sys.argv[1:] if argv is None else argv)
807
+ if argv and argv[0] in TOOLS:
808
+ from pal.cli import main as tools_main
809
+ return tools_main(argv)
810
+ args = build_parser().parse_args(argv)
811
+ if args.cmd == "web":
812
+ return web(args)
813
+ if args.cmd == "setup":
814
+ return setup(args)
815
+ if args.cmd == "bench":
816
+ return bench(args)
817
+ if args.cmd == "ask":
818
+ return ask_once(args, core_factory)
819
+ if not (sys.stdin.isatty() and sys.stdout.isatty()) and core_factory is None:
820
+ print('pal cli needs a terminal. For a single answer use: pal ask "your question" For the browser: pal web', file=sys.stderr)
821
+ return 2
822
+ return interactive(args, core_factory)
823
+
824
+
825
+ if __name__ == "__main__":
826
+ sys.exit(main())