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