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/flow_cli.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""The workflow in the terminal: one request, building a plan step by step, applying with checks, and the workflow commands."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.padding import Padding
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from pal.terminal import decide, theme, ui
|
|
14
|
+
from pal.terminal.security import worst
|
|
15
|
+
from pal.terminal.session import BudgetExceeded, Change, Turn
|
|
16
|
+
from pal.terminal.workflow import (
|
|
17
|
+
AUTONOMY,
|
|
18
|
+
DEBUG_TRIES,
|
|
19
|
+
SETTING_ABOUT,
|
|
20
|
+
SETTINGS,
|
|
21
|
+
Workflow,
|
|
22
|
+
git_commit,
|
|
23
|
+
is_git_repo,
|
|
24
|
+
run_shell,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
FLOW_COMMANDS: list[tuple[str, str]] = [
|
|
28
|
+
("/new", "start a new plan (fresh conversation)"), ("/plans", "list this folder's plans"), ("/cd", "switch plan: /cd <name or number>"),
|
|
29
|
+
("/current", "the current plan: branch, steps, context, pending"), ("/rename", "rename the current plan"), ("/delete-plan", "delete a plan"),
|
|
30
|
+
("/load", "add files, folders or globs to the context (-n \"note\" adds a note)"), ("/ls", "list the context with token counts"),
|
|
31
|
+
("/rm", "remove context by number, range, name or glob"), ("/clear-context", "remove all context (alias /unload)"),
|
|
32
|
+
("/continue", "build the next step of the plan (all: build every step)"), ("/diff", "show the pending changes"),
|
|
33
|
+
("/review", "review the pending changes in your browser"), ("/apply", "apply pending changes, run the checks, commit"),
|
|
34
|
+
("/reject", "drop pending changes: /reject <file> or /reject all"), ("/log", "every version of the sandbox"),
|
|
35
|
+
("/rewind", "go back: /rewind [n] or /rewind <version>"), ("/convo", "the plan's conversation"),
|
|
36
|
+
("/branches", "list branches"), ("/checkout", "switch to (or create) a branch"), ("/delete-branch", "delete a branch"),
|
|
37
|
+
("/autonomy", "none, basic, plus, semi or full"), ("/set", "one setting: /set auto-exec on"), ("/run", "run a command in this folder (asks first)"),
|
|
38
|
+
("/debug", "run a command and let the Build agent fix it until it passes: /debug npm test"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def spin(session, console: Console, label: str, fn: Callable):
|
|
43
|
+
"""Run fn() with a spinner whose line shows the time and the tokens saved so far."""
|
|
44
|
+
from pal.terminal.keys import EscToStop
|
|
45
|
+
with console.status(Text(label, style=theme.MUTED), spinner="dots", spinner_style=theme.RED) as status, EscToStop():
|
|
46
|
+
done = threading.Event()
|
|
47
|
+
t0 = time.monotonic()
|
|
48
|
+
|
|
49
|
+
def tick() -> None:
|
|
50
|
+
while not done.wait(0.25):
|
|
51
|
+
line = Text(f"{label} {time.monotonic() - t0:.0f}s", style=theme.MUTED)
|
|
52
|
+
if session.saved > 0:
|
|
53
|
+
line.append(" · saved so far ", style=theme.MUTED)
|
|
54
|
+
line.append(f"{session.saved:,} tokens ({session.saved_pct:g}%)", style=f"bold {theme.RED_HI}")
|
|
55
|
+
line.append(" esc to stop", style=theme.MUTED)
|
|
56
|
+
status.update(line)
|
|
57
|
+
threading.Thread(target=tick, daemon=True).start()
|
|
58
|
+
try:
|
|
59
|
+
return fn()
|
|
60
|
+
finally:
|
|
61
|
+
done.set()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def safely(console: Console, fn: Callable):
|
|
65
|
+
"""Run a model request; report a stop, a budget or an error in one line instead of a traceback. Returns the result or None."""
|
|
66
|
+
try:
|
|
67
|
+
return fn()
|
|
68
|
+
except BudgetExceeded as e:
|
|
69
|
+
ui.say(console, Text(f"Stopped: {e}", style=theme.RED_HI))
|
|
70
|
+
except KeyboardInterrupt:
|
|
71
|
+
ui.say(console, Text("Stopped.", style=theme.MUTED))
|
|
72
|
+
except Exception as e: # noqa: BLE001 (a model that cannot be reached, a missing key, a full context ...)
|
|
73
|
+
ui.say(console, Text(f"Something went wrong: {type(e).__name__}: {str(e)[:300]}", style=theme.RED_HI))
|
|
74
|
+
console.print()
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
DOING = {"build": "Pal is building…", "plan": "Pal is planning…", "ask": "Pal is thinking…"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_turn(wf: Workflow, text: str, console: Console) -> None:
|
|
82
|
+
"""One request with the current agent, then what follows from it: pending changes to review, or a plan to build."""
|
|
83
|
+
s = wf.session
|
|
84
|
+
ui.show_user(console, text)
|
|
85
|
+
result = safely(console, lambda: spin(s, console, DOING.get(s.mode, "Pal is working…"), lambda: wf.tell(text)))
|
|
86
|
+
if result is None:
|
|
87
|
+
return
|
|
88
|
+
ui.show_reply(console, result, s)
|
|
89
|
+
if s.mode == "ask" and result.questions:
|
|
90
|
+
answers = decide.ask_all(console, result.questions)
|
|
91
|
+
console.print()
|
|
92
|
+
if answers is None:
|
|
93
|
+
ui.say(console, Text("Skipped. Answer in your own words whenever you like.", style=theme.MUTED))
|
|
94
|
+
console.print()
|
|
95
|
+
else:
|
|
96
|
+
run_turn(wf, decide.answers_message(result.questions, answers), console)
|
|
97
|
+
return
|
|
98
|
+
if s.mode == "build":
|
|
99
|
+
after_build(wf, result, console)
|
|
100
|
+
elif s.mode == "plan" and wf.plan.b.steps:
|
|
101
|
+
show_steps(wf, console)
|
|
102
|
+
if wf.plan.setting("auto-continue") and ui.confirm(console, f"Build the {len(wf.plan.b.steps)} steps now, into the sandbox?"):
|
|
103
|
+
continue_flow(wf, console, every=True)
|
|
104
|
+
else:
|
|
105
|
+
ui.say(console, Text("/continue builds the next step · /continue all builds every step", style=theme.MUTED))
|
|
106
|
+
console.print()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def after_build(wf: Workflow, result: Turn, console: Console) -> None:
|
|
110
|
+
if result.changes:
|
|
111
|
+
ui.show_changes(console, result.changes)
|
|
112
|
+
pending_line(wf, console)
|
|
113
|
+
if wf.plan.b.pending and wf.plan.setting("auto-apply"):
|
|
114
|
+
apply_flow(wf, console, ask=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pending_line(wf: Workflow, console: Console) -> None:
|
|
118
|
+
n, cmds = len(wf.plan.b.pending), wf.plan.b.commands
|
|
119
|
+
if not n:
|
|
120
|
+
return
|
|
121
|
+
line = Text.assemble((f" {n} file{'s' if n != 1 else ''} pending in the sandbox", f"bold {theme.RED_HI}"), (" (your files are not changed yet)", theme.MUTED))
|
|
122
|
+
ui.say(console, line)
|
|
123
|
+
if cmds:
|
|
124
|
+
ui.say(console, Text(" checks to run after applying: " + " · ".join(cmds), style=theme.INK))
|
|
125
|
+
ui.say(console, Text(" /diff · /review · /apply · /reject <file> · /rewind", style=theme.MUTED))
|
|
126
|
+
console.print()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def show_steps(wf: Workflow, console: Console) -> None:
|
|
130
|
+
grid = Table.grid(padding=(0, 1))
|
|
131
|
+
for i, step in enumerate(wf.plan.b.steps, 1):
|
|
132
|
+
mark = "✓" if i <= wf.plan.b.done else "•"
|
|
133
|
+
grid.add_row(Text(mark, style=theme.RED_HI if i <= wf.plan.b.done else theme.MUTED), Text(f"{i}.", style=theme.MUTED), Text(step, style=theme.INK))
|
|
134
|
+
ui.say(console, Text(f"Plan saved: {len(wf.plan.b.steps)} steps ({wf.plan.b.done} built)", style=f"bold {theme.WHITE}"))
|
|
135
|
+
ui.say(console, Padding(grid, (0, 0, 0, 2)))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def continue_flow(wf: Workflow, console: Console, every: bool = False) -> None:
|
|
139
|
+
"""Build the next step, or every remaining step, into the sandbox."""
|
|
140
|
+
s = wf.session
|
|
141
|
+
if wf.next_step() is None:
|
|
142
|
+
ui.say(console, Text("Every step is built. Make a new plan with the Plan agent, or ask Build for more." if wf.plan.b.steps else
|
|
143
|
+
"There is no plan yet: switch to the Plan agent (tab) and describe the task.", style=theme.MUTED))
|
|
144
|
+
console.print()
|
|
145
|
+
return
|
|
146
|
+
while True:
|
|
147
|
+
nxt = wf.next_step()
|
|
148
|
+
if nxt is None:
|
|
149
|
+
break
|
|
150
|
+
i, step = nxt
|
|
151
|
+
ui.say(console, Text.assemble((f"Step {i} of {len(wf.plan.b.steps)} ", f"bold {theme.WHITE}"), (step, theme.INK)))
|
|
152
|
+
label = f"Pal is building step {i}…"
|
|
153
|
+
result = safely(console, lambda label=label: spin(s, console, label, wf.build_step))
|
|
154
|
+
if result is None:
|
|
155
|
+
break
|
|
156
|
+
if result.changes:
|
|
157
|
+
ui.say(console, Text(" " + " ".join(("+ " if c.is_new else "~ ") + c.path for c in result.changes), style=theme.INK))
|
|
158
|
+
for path, why in result.refused:
|
|
159
|
+
ui.say(console, Text(f" refused {path}: {why}", style=theme.RED_HI))
|
|
160
|
+
if not every:
|
|
161
|
+
break
|
|
162
|
+
console.print()
|
|
163
|
+
pending_line(wf, console)
|
|
164
|
+
if wf.plan.b.pending and wf.plan.setting("auto-apply"):
|
|
165
|
+
apply_flow(wf, console, ask=False)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def show_diff(wf: Workflow, console: Console) -> None:
|
|
169
|
+
diffs = wf.diffs()
|
|
170
|
+
if not diffs:
|
|
171
|
+
ui.say(console, Text("Nothing is pending.", style=theme.MUTED))
|
|
172
|
+
console.print()
|
|
173
|
+
return
|
|
174
|
+
changes = [Change(path, "", "", new, lines, findings) for path, new, lines, findings in diffs]
|
|
175
|
+
ui.show_changes(console, changes, limit=200)
|
|
176
|
+
console.print()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def broken_pending(wf: Workflow) -> list:
|
|
180
|
+
"""Pending files that do not parse (a fix that never came, or a file edited by hand into the sandbox)."""
|
|
181
|
+
from pal.terminal.syntax import check
|
|
182
|
+
return [p for path, new in wf.plan.b.pending.items() if (p := check(path, new))]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parses_or_confirmed(wf: Workflow, console: Console) -> bool:
|
|
186
|
+
"""True when every pending file parses, or the person accepts the ones that do not (the default is no)."""
|
|
187
|
+
bad = broken_pending(wf)
|
|
188
|
+
if not bad:
|
|
189
|
+
return True
|
|
190
|
+
for prob in bad:
|
|
191
|
+
ui.say(console, Text(f" ✗ {prob}", style=theme.RED_HI))
|
|
192
|
+
if ui.confirm(console, f"{len(bad)} pending file{'s' if len(bad) != 1 else ''} do{'es' if len(bad) == 1 else ''} not parse. Apply anyway?"):
|
|
193
|
+
return True
|
|
194
|
+
ui.say(console, Text(" Nothing applied. Fix it with the Build agent, or drop it with /reject <file>.", style=theme.MUTED))
|
|
195
|
+
console.print()
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def apply_flow(wf: Workflow, console: Console, ask: bool = True, yes: bool = False) -> None:
|
|
200
|
+
"""Write the pending changes, run the checks the person approves, roll back on failure (and auto-debug), and commit."""
|
|
201
|
+
s = wf.session
|
|
202
|
+
pending = wf.plan.b.pending
|
|
203
|
+
if not pending:
|
|
204
|
+
ui.say(console, Text("Nothing is pending.", style=theme.MUTED))
|
|
205
|
+
console.print()
|
|
206
|
+
return
|
|
207
|
+
if not parses_or_confirmed(wf, console):
|
|
208
|
+
return
|
|
209
|
+
level = worst([f for _, _, _, fs in wf.diffs() for f in fs])
|
|
210
|
+
n = len(pending)
|
|
211
|
+
if level == "critical" and not ui.confirm(console, f"A critical security problem was found (see /diff). Apply {n} file{'s' if n != 1 else ''} anyway?"):
|
|
212
|
+
ui.say(console, Text(" Nothing applied.", style=theme.MUTED))
|
|
213
|
+
return
|
|
214
|
+
if level != "critical" and ask and not yes and not wf.plan.setting("auto-apply") and not ui.confirm(console, f"Apply {n} file{'s' if n != 1 else ''} to {ui.short_path(s.cwd)}?"):
|
|
215
|
+
ui.say(console, Text(" Nothing applied. The changes stay in the sandbox.", style=theme.MUTED))
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
def run_command(cmd: str):
|
|
219
|
+
if not wf.plan.setting("auto-exec") and not ui.confirm(console, f"Run {cmd} in {ui.short_path(s.cwd)}?"):
|
|
220
|
+
return None, ""
|
|
221
|
+
ui.say(console, Text(f" $ {cmd}", style=theme.WHITE))
|
|
222
|
+
ok, out = spin(s, console, "Running…", lambda: run_shell(cmd, s.cwd))
|
|
223
|
+
tail = "\n".join(out.strip().splitlines()[-12:])
|
|
224
|
+
if tail:
|
|
225
|
+
ui.say(console, Padding(Text(ui.clean_terminal(tail), style=theme.MUTED), (0, 0, 0, 4)))
|
|
226
|
+
ui.say(console, Text(" ✓ passed" if ok else " ✗ failed", style=theme.RED_HI if not ok else f"bold {theme.WHITE}"))
|
|
227
|
+
return ok, out
|
|
228
|
+
|
|
229
|
+
def debug(cmd: str, output: str) -> bool:
|
|
230
|
+
ui.say(console, Text(" The check failed, so every file was put back. Asking the Build agent to fix it…", style=theme.INK))
|
|
231
|
+
mode = s.mode
|
|
232
|
+
s.mode = "build"
|
|
233
|
+
try:
|
|
234
|
+
result = safely(console, lambda: spin(s, console, "Pal is fixing…", lambda: wf.tell(
|
|
235
|
+
f"After applying your changes, the check `{cmd}` failed with:\n```\n{output}\n```\nFix the cause. Reply with the complete files to change.")))
|
|
236
|
+
finally:
|
|
237
|
+
s.mode = mode
|
|
238
|
+
return bool(result and result.changes)
|
|
239
|
+
|
|
240
|
+
def commit_message(files: list[str]) -> str:
|
|
241
|
+
label = next((v.label for v in reversed(wf.plan.b.versions) if not v.label.startswith(("applied", "rejected"))), "Pal changes")
|
|
242
|
+
try:
|
|
243
|
+
reply = s.client.ask(f"Write a git commit message for this change: {label}. Files: {', '.join(files)}. One line under 72 characters, "
|
|
244
|
+
"imperative mood, then optionally a blank line and up to 3 short bullet lines. Reply with the message only.")
|
|
245
|
+
text = ui.clean_terminal(reply.text).strip().strip("`").strip()
|
|
246
|
+
return text[:600] or label
|
|
247
|
+
except Exception: # noqa: BLE001
|
|
248
|
+
return label[:72]
|
|
249
|
+
|
|
250
|
+
res = wf.apply(run_command, debug if wf.plan.setting("auto-debug") else None, commit_message)
|
|
251
|
+
for path in res.written if not res.rolled_back else []:
|
|
252
|
+
ui.say(console, Text.assemble((" ✓ ", theme.RED_HI), (path, theme.WHITE)))
|
|
253
|
+
if res.rolled_back:
|
|
254
|
+
ui.say(console, Text(f" ✗ {res.failed_command} failed: every file was put back as it was. The changes are still pending.", style=theme.RED_HI))
|
|
255
|
+
ui.say(console, Text(" Fix it with the Build agent, then /apply again" + ("" if wf.plan.setting("auto-debug") else ", or turn on /set auto-debug on"), style=theme.MUTED))
|
|
256
|
+
elif res.commit:
|
|
257
|
+
ui.say(console, Text(f" committed {res.commit}", style=f"bold {theme.WHITE}"))
|
|
258
|
+
elif res.written and is_git_repo(s.cwd) and not wf.plan.setting("auto-commit"):
|
|
259
|
+
ui.say(console, Text(" Not committed (auto-commit is off: /set auto-commit on).", style=theme.MUTED))
|
|
260
|
+
console.print()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def debug_flow(wf: Workflow, cmd: str, console: Console, tries: int = DEBUG_TRIES) -> bool:
|
|
264
|
+
"""/debug <command>: run it; while it fails, show the Build agent the output, apply its fix (asking first, unless auto-apply is on) and run it
|
|
265
|
+
again, at most `tries` fixes. If it still fails, offer to put every file back as it was. True when the command passes in the end."""
|
|
266
|
+
s = wf.session
|
|
267
|
+
say = lambda text, style=theme.MUTED: ui.say(console, Text(text, style=style)) # noqa: E731
|
|
268
|
+
before: dict[str, str | None] = {} # every file a fix touched, as it was before /debug
|
|
269
|
+
written: list[str] = []
|
|
270
|
+
for attempt in range(tries + 1):
|
|
271
|
+
say(f" $ {cmd}", theme.WHITE)
|
|
272
|
+
ok, out = spin(s, console, "Running…", lambda: run_shell(cmd, s.cwd))
|
|
273
|
+
tail = "\n".join(out.strip().splitlines()[-12:])
|
|
274
|
+
if tail:
|
|
275
|
+
ui.say(console, Padding(Text(ui.clean_terminal(tail), style=theme.MUTED), (0, 0, 0, 4)))
|
|
276
|
+
if ok:
|
|
277
|
+
say(" ✓ passes" + (f" after {attempt} fix{'es' if attempt != 1 else ''}" if attempt else ""), f"bold {theme.WHITE}")
|
|
278
|
+
if written and wf.plan.setting("auto-commit") and is_git_repo(s.cwd):
|
|
279
|
+
sha = git_commit(s.cwd, sorted(set(written)), f"Fix: {cmd}"[:72])
|
|
280
|
+
if sha:
|
|
281
|
+
say(f" committed {sha}", f"bold {theme.WHITE}")
|
|
282
|
+
console.print()
|
|
283
|
+
return True
|
|
284
|
+
say(" ✗ fails", theme.RED_HI)
|
|
285
|
+
if attempt == tries:
|
|
286
|
+
break
|
|
287
|
+
mode, s.mode = s.mode, "build"
|
|
288
|
+
try:
|
|
289
|
+
ask = (f"Running `{cmd}` in the project fails with:\n```\n{ui.clean_terminal(out.strip())[-3000:]}\n```\n"
|
|
290
|
+
"Find the cause and fix it. Reply with the complete files to change.")
|
|
291
|
+
result = safely(console, lambda q=ask: spin(s, console, "Pal is debugging…", lambda: wf.tell(q)))
|
|
292
|
+
finally:
|
|
293
|
+
s.mode = mode
|
|
294
|
+
if result is None:
|
|
295
|
+
return False
|
|
296
|
+
if result.text:
|
|
297
|
+
ui.show_reply(console, result, s)
|
|
298
|
+
if not result.changes:
|
|
299
|
+
say(" The Build agent proposed no file change, so /debug stops here.")
|
|
300
|
+
console.print()
|
|
301
|
+
return False
|
|
302
|
+
if not parses_or_confirmed(wf, console):
|
|
303
|
+
return False
|
|
304
|
+
files = ", ".join(sorted(wf.plan.b.pending))
|
|
305
|
+
if not wf.plan.setting("auto-apply") and not ui.confirm(console, f"Apply {files} and run {cmd} again?"):
|
|
306
|
+
say(" Stopped. The fix stays in the sandbox: /diff, /apply.")
|
|
307
|
+
console.print()
|
|
308
|
+
return False
|
|
309
|
+
for path in wf.plan.b.pending:
|
|
310
|
+
before.setdefault(path, wf.disk(path))
|
|
311
|
+
res = wf.apply(lambda c: (None, "")) # write the fix; the command is run by this loop, not by apply
|
|
312
|
+
written += res.written
|
|
313
|
+
say(f" Still failing after {tries} fix{'es' if tries != 1 else ''}.", theme.RED_HI)
|
|
314
|
+
if before and ui.confirm(console, f"Put the {len(before)} file(s) /debug changed back as they were?"):
|
|
315
|
+
wf._roll_back(before)
|
|
316
|
+
say(" Put back.")
|
|
317
|
+
console.print()
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def handle_flow(cmd: str, rest: str, wf: Workflow, console: Console) -> bool:
|
|
322
|
+
"""The workflow commands. True when `cmd` was one of them."""
|
|
323
|
+
say = lambda text, style=theme.MUTED: ui.say(console, Text(text, style=style)) # noqa: E731
|
|
324
|
+
b = wf.plan.b
|
|
325
|
+
try:
|
|
326
|
+
if cmd == "/plans":
|
|
327
|
+
for i, name in enumerate(wf.store.names(), 1):
|
|
328
|
+
say(f"{i:>3} {name}" + (" ◀ current" if name == wf.plan.name else ""), theme.WHITE if name == wf.plan.name else theme.INK)
|
|
329
|
+
elif cmd == "/current":
|
|
330
|
+
say(f"Plan {wf.plan.name} · branch {wf.plan.branch} · autonomy {wf.plan.autonomy}", f"bold {theme.WHITE}")
|
|
331
|
+
say(f"{len(b.steps)} steps ({b.done} built) · {len(b.context)} files in context · {len(b.pending)} pending · {len(b.convo) // 2} messages")
|
|
332
|
+
if b.steps:
|
|
333
|
+
show_steps(wf, console)
|
|
334
|
+
elif cmd == "/rename":
|
|
335
|
+
wf.rename(rest)
|
|
336
|
+
say(f"Renamed to {rest}.")
|
|
337
|
+
elif cmd == "/delete-plan":
|
|
338
|
+
wf.delete(rest or wf.plan.name)
|
|
339
|
+
say(f"Deleted. Now on plan {wf.plan.name}.")
|
|
340
|
+
elif cmd == "/load":
|
|
341
|
+
note = ""
|
|
342
|
+
if rest.startswith("-n "):
|
|
343
|
+
note, rest = rest[3:].strip().strip("\"'"), ""
|
|
344
|
+
from pal.terminal.cli import split_paths
|
|
345
|
+
added, problems = wf.load(split_paths(rest), note)
|
|
346
|
+
for a in added:
|
|
347
|
+
say(f" + {a}", theme.INK)
|
|
348
|
+
for p in problems:
|
|
349
|
+
say(f" {p}", theme.RED_HI)
|
|
350
|
+
say(f"{len(b.context)} files in context. They go with every request; a file the model already has is not sent again.")
|
|
351
|
+
elif cmd == "/ls":
|
|
352
|
+
rows = wf.context_rows()
|
|
353
|
+
if not rows:
|
|
354
|
+
say("The context is empty. Add files with /load <file or folder or glob>.")
|
|
355
|
+
grid = Table.grid(padding=(0, 2))
|
|
356
|
+
for i, name, tokens, problem in rows:
|
|
357
|
+
grid.add_row(Text(f"{i}", style=theme.MUTED), Text(name, style=theme.WHITE), Text(f"{tokens:,} tok", style=theme.INK), Text(problem, style=theme.RED_HI))
|
|
358
|
+
if rows:
|
|
359
|
+
ui.say(console, grid)
|
|
360
|
+
say(f"total about {sum(r[2] for r in rows):,} tokens")
|
|
361
|
+
elif cmd == "/rm":
|
|
362
|
+
from pal.terminal.cli import split_paths
|
|
363
|
+
gone = wf.remove(split_paths(rest))
|
|
364
|
+
say("removed: " + (", ".join(gone) or "nothing matched"))
|
|
365
|
+
elif cmd in ("/clear-context", "/unload"):
|
|
366
|
+
say(f"Removed {wf.clear()} item(s) from the context.")
|
|
367
|
+
elif cmd == "/continue":
|
|
368
|
+
continue_flow(wf, console, every=rest.strip() == "all")
|
|
369
|
+
return True
|
|
370
|
+
elif cmd == "/diff":
|
|
371
|
+
show_diff(wf, console)
|
|
372
|
+
return True
|
|
373
|
+
elif cmd == "/review":
|
|
374
|
+
from pal.terminal.review import open_review
|
|
375
|
+
path = open_review(wf)
|
|
376
|
+
say(f"Opened the review in your browser ({path}).")
|
|
377
|
+
elif cmd == "/apply":
|
|
378
|
+
apply_flow(wf, console, yes=rest.strip() in ("-y", "--yes"))
|
|
379
|
+
return True
|
|
380
|
+
elif cmd == "/reject":
|
|
381
|
+
gone = wf.reject(None if rest.strip() in ("all", "--all") else rest.split())
|
|
382
|
+
say("dropped: " + (", ".join(gone) or "nothing (give a pending file, or all)") + (" (/rewind brings them back)" if gone else ""))
|
|
383
|
+
elif cmd == "/log":
|
|
384
|
+
vs = wf.log()
|
|
385
|
+
if not vs:
|
|
386
|
+
say("No versions yet: the Build agent's changes appear here.")
|
|
387
|
+
for v in reversed(vs[-30:]):
|
|
388
|
+
say(f" {v.id} {time.strftime('%H:%M', time.localtime(v.t))} {len(v.pending):>2} pending {v.label}", theme.INK)
|
|
389
|
+
elif cmd == "/rewind":
|
|
390
|
+
n = wf.rewind(int(rest) if rest.strip().isdigit() else 1, to="" if not rest.strip() or rest.strip().isdigit() else rest.strip())
|
|
391
|
+
say(f"Went back {n} version(s). {len(wf.plan.b.pending)} file(s) pending.")
|
|
392
|
+
elif cmd == "/convo":
|
|
393
|
+
for m in b.convo[-20:]:
|
|
394
|
+
ui.say(console, Text.assemble((f"{m['role']} ({m['agent']}): ", theme.RED_HI if m["role"] == "you" else theme.WHITE), (ui.clean_terminal(m["text"][:500]), theme.INK)))
|
|
395
|
+
elif cmd == "/branches":
|
|
396
|
+
for name in wf.plan.branches:
|
|
397
|
+
say(f" {name}" + (" ◀ current" if name == wf.plan.branch else ""), theme.WHITE if name == wf.plan.branch else theme.INK)
|
|
398
|
+
elif cmd == "/checkout":
|
|
399
|
+
created = wf.checkout(rest.strip())
|
|
400
|
+
say(f"{'Created and switched to' if created else 'Switched to'} branch {rest.strip()}.")
|
|
401
|
+
elif cmd == "/delete-branch":
|
|
402
|
+
wf.delete_branch(rest.strip())
|
|
403
|
+
say(f"Deleted branch {rest.strip()}.")
|
|
404
|
+
elif cmd == "/autonomy":
|
|
405
|
+
level = rest.strip().lower()
|
|
406
|
+
if level in AUTONOMY:
|
|
407
|
+
wf.plan.autonomy, wf.plan.custom = level, {}
|
|
408
|
+
wf.save()
|
|
409
|
+
say(f"Autonomy: {wf.plan.autonomy}" + ("" if level in AUTONOMY else " (choose none, basic, plus, semi or full)"), f"bold {theme.WHITE}")
|
|
410
|
+
for name in SETTINGS:
|
|
411
|
+
say(f" {'on ' if wf.plan.setting(name) else 'off'} {name:<18} {SETTING_ABOUT[name]}", theme.INK if wf.plan.setting(name) else theme.MUTED)
|
|
412
|
+
if wf.plan.autonomy == "full":
|
|
413
|
+
say("Full runs commands and applies changes without asking: use it on a clean git branch.", theme.RED_HI)
|
|
414
|
+
elif cmd == "/set":
|
|
415
|
+
name, _, value = rest.partition(" ")
|
|
416
|
+
if name not in SETTINGS or value.strip().lower() not in ("on", "off", "true", "false"):
|
|
417
|
+
say(f"Use: /set <setting> on|off settings: {', '.join(SETTINGS)}")
|
|
418
|
+
else:
|
|
419
|
+
wf.plan.custom[name] = value.strip().lower() in ("on", "true")
|
|
420
|
+
wf.save()
|
|
421
|
+
say(f"{name}: {'on' if wf.plan.setting(name) else 'off'}")
|
|
422
|
+
elif cmd == "/debug":
|
|
423
|
+
if not rest.strip():
|
|
424
|
+
say("Use: /debug <command> for example /debug npm test or /debug python -m pytest -q")
|
|
425
|
+
elif wf.plan.setting("can-exec") or ui.confirm(console, f"Commands are off for this plan (can-exec). Run {rest.strip()} anyway?"):
|
|
426
|
+
debug_flow(wf, rest.strip(), console)
|
|
427
|
+
elif cmd == "/run":
|
|
428
|
+
if not rest.strip():
|
|
429
|
+
say("Use: /run <command>")
|
|
430
|
+
elif ui.confirm(console, f"Run {rest.strip()} in {ui.short_path(wf.session.cwd)}?"):
|
|
431
|
+
ok, out = spin(wf.session, console, "Running…", lambda: run_shell(rest.strip(), wf.session.cwd))
|
|
432
|
+
ui.say(console, Padding(Text(ui.clean_terminal("\n".join(out.strip().splitlines()[-30:])), style=theme.INK), (0, 0, 0, 2)))
|
|
433
|
+
say("✓ exit 0" if ok else "✗ failed", theme.WHITE if ok else theme.RED_HI)
|
|
434
|
+
else:
|
|
435
|
+
return False
|
|
436
|
+
except ValueError as e:
|
|
437
|
+
say(str(e), theme.RED_HI)
|
|
438
|
+
console.print()
|
|
439
|
+
return True
|
pal/terminal/hardware.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""What this computer can run: its GPUs and their video memory (VRAM), Apple Silicon's shared memory, and system RAM.
|
|
2
|
+
|
|
3
|
+
Read without installing anything: `nvidia-smi` for NVIDIA cards (it ships with the driver), sysfs for AMD on Linux, the registry on Windows, `sysctl`
|
|
4
|
+
on macOS. Every probe is optional and short; a probe that fails simply finds nothing, and Pal then plans for running on the CPU.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class GPU:
|
|
20
|
+
name: str
|
|
21
|
+
vram_gb: float
|
|
22
|
+
vendor: str # "nvidia" | "amd" | "intel" | "apple" | "other"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Hardware:
|
|
27
|
+
system: str = "" # "Windows" | "Darwin" | "Linux"
|
|
28
|
+
ram_gb: float = 0.0
|
|
29
|
+
gpus: list[GPU] = field(default_factory=list)
|
|
30
|
+
apple_silicon: bool = False
|
|
31
|
+
notes: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def discrete(self) -> list[GPU]:
|
|
35
|
+
"""Cards with their own memory that Ollama can use (NVIDIA, AMD). Intel graphics share system memory and are treated as CPU."""
|
|
36
|
+
return [g for g in self.gpus if g.vendor in ("nvidia", "amd") and g.vram_gb >= 1.5]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def vram_gb(self) -> float:
|
|
40
|
+
"""Memory a model can use on the GPU: all NVIDIA (or all AMD) cards together, since Ollama splits a model across cards; Apple: shared memory."""
|
|
41
|
+
if self.apple_silicon:
|
|
42
|
+
return round(self.ram_gb * 0.70, 1) # macOS lets the GPU use about two thirds to three quarters of unified memory
|
|
43
|
+
by_vendor: dict[str, float] = {}
|
|
44
|
+
for g in self.discrete:
|
|
45
|
+
by_vendor[g.vendor] = by_vendor.get(g.vendor, 0.0) + g.vram_gb
|
|
46
|
+
return round(max(by_vendor.values(), default=0.0), 1)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def kind(self) -> str:
|
|
50
|
+
return "apple" if self.apple_silicon else ("gpu" if self.vram_gb else "cpu")
|
|
51
|
+
|
|
52
|
+
def summary(self) -> str:
|
|
53
|
+
if self.apple_silicon:
|
|
54
|
+
chip = self.gpus[0].name if self.gpus else "Apple Silicon"
|
|
55
|
+
return f"{chip} · {self.ram_gb:g} GB unified memory (about {self.vram_gb:g} GB for models)"
|
|
56
|
+
if self.discrete:
|
|
57
|
+
names = ", ".join(f"{g.name} {g.vram_gb:g} GB" for g in self.discrete)
|
|
58
|
+
return f"{names} · {self.ram_gb:g} GB RAM"
|
|
59
|
+
return f"no GPU · {self.ram_gb:g} GB RAM"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run(cmd: list[str], timeout: float = 4.0) -> str:
|
|
63
|
+
exe = shutil.which(cmd[0])
|
|
64
|
+
if not exe:
|
|
65
|
+
return ""
|
|
66
|
+
try:
|
|
67
|
+
r = subprocess.run([exe, *cmd[1:]], capture_output=True, text=True, timeout=timeout)
|
|
68
|
+
return r.stdout if r.returncode == 0 else ""
|
|
69
|
+
except (OSError, subprocess.SubprocessError):
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _nvidia() -> list[GPU]:
|
|
74
|
+
out = _run(["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"])
|
|
75
|
+
gpus = []
|
|
76
|
+
for line in out.splitlines():
|
|
77
|
+
name, _, mb = line.rpartition(",")
|
|
78
|
+
try:
|
|
79
|
+
gpus.append(GPU(name.strip() or "NVIDIA GPU", round(float(mb) / 1024, 1), "nvidia"))
|
|
80
|
+
except ValueError:
|
|
81
|
+
continue
|
|
82
|
+
return gpus
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _amd_linux(root: Path = Path("/sys/class/drm")) -> list[GPU]:
|
|
86
|
+
gpus = []
|
|
87
|
+
for card in sorted(root.glob("card[0-9]*")) if root.is_dir() else []:
|
|
88
|
+
dev = card / "device"
|
|
89
|
+
vram = dev / "mem_info_vram_total"
|
|
90
|
+
if "-" in card.name or not vram.is_file():
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
gb = round(int(vram.read_text().strip()) / 1024**3, 1)
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
continue
|
|
96
|
+
name = "AMD GPU"
|
|
97
|
+
for f in ("product_name", "model"):
|
|
98
|
+
try:
|
|
99
|
+
name = (dev / f).read_text().strip() or name
|
|
100
|
+
break
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
gpus.append(GPU(name, gb, "amd"))
|
|
104
|
+
return gpus
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _windows_gpus() -> list[GPU]:
|
|
108
|
+
"""Every display adapter from the registry, with its real memory size (qwMemorySize; WMI's AdapterRAM stops at 4 GB)."""
|
|
109
|
+
if sys.platform != "win32":
|
|
110
|
+
return []
|
|
111
|
+
import winreg
|
|
112
|
+
base = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
|
|
113
|
+
gpus = []
|
|
114
|
+
try:
|
|
115
|
+
root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base)
|
|
116
|
+
except OSError:
|
|
117
|
+
return []
|
|
118
|
+
for i in range(32):
|
|
119
|
+
try:
|
|
120
|
+
sub = winreg.OpenKey(root, f"{i:04d}")
|
|
121
|
+
except OSError:
|
|
122
|
+
break
|
|
123
|
+
try:
|
|
124
|
+
name = str(winreg.QueryValueEx(sub, "DriverDesc")[0])
|
|
125
|
+
except OSError:
|
|
126
|
+
continue
|
|
127
|
+
size = 0
|
|
128
|
+
for value in ("HardwareInformation.qwMemorySize", "HardwareInformation.MemorySize"):
|
|
129
|
+
try:
|
|
130
|
+
raw = winreg.QueryValueEx(sub, value)[0]
|
|
131
|
+
size = int.from_bytes(raw, "little") if isinstance(raw, bytes) else int(raw)
|
|
132
|
+
break
|
|
133
|
+
except (OSError, ValueError, TypeError):
|
|
134
|
+
continue
|
|
135
|
+
low = name.lower()
|
|
136
|
+
vendor = "nvidia" if "nvidia" in low else "amd" if ("amd" in low or "radeon" in low) else "intel" if "intel" in low else "other"
|
|
137
|
+
gpus.append(GPU(name, round(size / 1024**3, 1), vendor))
|
|
138
|
+
return gpus
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _ram_gb(system: str) -> float:
|
|
142
|
+
try:
|
|
143
|
+
if system == "Linux":
|
|
144
|
+
for line in Path("/proc/meminfo").read_text().splitlines():
|
|
145
|
+
if line.startswith("MemTotal:"):
|
|
146
|
+
return round(int(line.split()[1]) / 1024**2, 1)
|
|
147
|
+
if system == "Darwin":
|
|
148
|
+
out = _run(["sysctl", "-n", "hw.memsize"])
|
|
149
|
+
return round(int(out.strip()) / 1024**3, 1) if out.strip() else 0.0
|
|
150
|
+
if system == "Windows":
|
|
151
|
+
import ctypes
|
|
152
|
+
|
|
153
|
+
class MEMORYSTATUSEX(ctypes.Structure):
|
|
154
|
+
_fields_ = [("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong), ("ullTotalPhys", ctypes.c_ulonglong),
|
|
155
|
+
("ullAvailPhys", ctypes.c_ulonglong), ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
|
|
156
|
+
("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong), ("sullAvailExtendedVirtual", ctypes.c_ulonglong)]
|
|
157
|
+
stat = MEMORYSTATUSEX()
|
|
158
|
+
stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
|
|
159
|
+
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)) # type: ignore[attr-defined]
|
|
160
|
+
return round(stat.ullTotalPhys / 1024**3, 1)
|
|
161
|
+
except (OSError, ValueError, AttributeError):
|
|
162
|
+
pass
|
|
163
|
+
try:
|
|
164
|
+
return round(os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1024**3, 1)
|
|
165
|
+
except (ValueError, OSError, AttributeError):
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def detect() -> Hardware:
|
|
170
|
+
"""Look at this computer. Never raises."""
|
|
171
|
+
system = platform.system()
|
|
172
|
+
hw = Hardware(system=system, ram_gb=_ram_gb(system))
|
|
173
|
+
if system == "Darwin" and platform.machine().lower() in ("arm64", "aarch64"):
|
|
174
|
+
hw.apple_silicon = True
|
|
175
|
+
chip = _run(["sysctl", "-n", "machdep.cpu.brand_string"]).strip() or "Apple Silicon"
|
|
176
|
+
hw.gpus.append(GPU(chip, hw.ram_gb, "apple"))
|
|
177
|
+
return hw
|
|
178
|
+
hw.gpus = _nvidia()
|
|
179
|
+
if system == "Linux":
|
|
180
|
+
hw.gpus += _amd_linux()
|
|
181
|
+
if system == "Windows":
|
|
182
|
+
known = {g.name for g in hw.gpus}
|
|
183
|
+
hw.gpus += [g for g in _windows_gpus() if g.name not in known]
|
|
184
|
+
if not hw.discrete and any(g.vendor == "nvidia" for g in hw.gpus):
|
|
185
|
+
hw.notes.append("an NVIDIA card was found but not its memory: install or update the NVIDIA driver so nvidia-smi works")
|
|
186
|
+
return hw
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def from_spec(text: str) -> Hardware:
|
|
190
|
+
"""A pretend computer for trying out recommendations: "cpu:16", "gpu:12" (VRAM GB, RAM 32), "gpu:24:64", "mac:16"."""
|
|
191
|
+
m = re.fullmatch(r"\s*(cpu|gpu|mac):(\d+(?:\.\d+)?)(?::(\d+(?:\.\d+)?))?\s*", text.lower())
|
|
192
|
+
if not m:
|
|
193
|
+
raise ValueError("use cpu:<RAM GB>, gpu:<VRAM GB>[:<RAM GB>] or mac:<memory GB>")
|
|
194
|
+
kind, a, b = m.group(1), float(m.group(2)), float(m.group(3)) if m.group(3) else None
|
|
195
|
+
if kind == "cpu":
|
|
196
|
+
return Hardware("Linux", a)
|
|
197
|
+
if kind == "mac":
|
|
198
|
+
return Hardware("Darwin", a, [GPU("Apple Silicon", a, "apple")], apple_silicon=True)
|
|
199
|
+
return Hardware("Linux", b or max(16.0, a * 2), [GPU("GPU", a, "nvidia")])
|