pal-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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")])