pal-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/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())
|