pal-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/cli.py ADDED
@@ -0,0 +1,502 @@
1
+ """Command line: `pal serve`, `pal doctor`, `pal config` (also `python -m pal ...`).
2
+
3
+ Works the same on Linux, macOS and Windows: pure Python, no shell scripts.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import importlib.util
9
+ import os
10
+ import shutil
11
+ import sys
12
+ from collections.abc import Callable, Sequence
13
+ from dataclasses import dataclass
14
+
15
+ import yaml
16
+
17
+ from pal import __version__
18
+ from pal.agents import AGENTS
19
+ from pal.config import Settings, load_settings
20
+ from pal.errors import ConfigError, PalError
21
+ from pal.logging_setup import configure_logging
22
+ from pal.paths import pal_home
23
+ from pal.sdk.agent import agent_key
24
+
25
+ _LOOPBACK = ("127.0.0.1", "localhost", "::1")
26
+
27
+
28
+ @dataclass
29
+ class Check:
30
+ name: str
31
+ status: str # "ok" | "warn" | "fail" | "info"
32
+ detail: str = ""
33
+
34
+
35
+ def _services_check(settings: Settings, env) -> Check:
36
+ """Which third-party tools are switched on, and which have their key."""
37
+ from pal.chat.services import selected
38
+ try:
39
+ specs = selected(settings.tools.services)
40
+ except ConfigError as e:
41
+ return Check("service tools", "fail", str(e))
42
+ if not specs:
43
+ return Check("service tools", "info", "off (name them in tools.services, or all: google_search, wolfram_alpha, currency_convert, "
44
+ "words_api, moon_phase, and the image and audio tools)")
45
+ missing: dict[str, list[str]] = {}
46
+ for s in specs:
47
+ if s.key_env and not env.get(s.key_env):
48
+ missing.setdefault(s.key_env, []).append(s.name)
49
+ ready = [s.name for s in specs if not s.key_env or env.get(s.key_env)]
50
+ detail = f"{len(ready)} of {len(specs)} ready" + (f" ({', '.join(ready)})" if ready else "")
51
+ if missing:
52
+ detail += "; missing: " + "; ".join(f"{k} for {', '.join(v)}" for k, v in missing.items())
53
+ return Check("service tools", "warn" if missing else "ok", detail)
54
+
55
+
56
+ def _installed_version(name: str) -> str | None:
57
+ try:
58
+ from importlib.metadata import PackageNotFoundError, version
59
+ return version(name)
60
+ except PackageNotFoundError:
61
+ return None
62
+
63
+
64
+ def _has_module(name: str) -> bool:
65
+ try:
66
+ return importlib.util.find_spec(name) is not None
67
+ except (ImportError, ValueError):
68
+ return False
69
+
70
+
71
+ def run_doctor(settings: Settings | None, env=None, which: Callable[[str], str | None] = shutil.which,
72
+ has_module: Callable[[str], bool] = _has_module, http_get=None,
73
+ version_of: Callable[[str], str | None] = _installed_version) -> list[Check]:
74
+ """Environment checks. Everything the OS or network is asked is injectable, so this is testable."""
75
+ env = os.environ if env is None else env
76
+ out: list[Check] = []
77
+ v = sys.version_info
78
+ out.append(Check("python", "ok" if v >= (3, 10) else "fail", f"{v.major}.{v.minor}.{v.micro} (needs 3.10+)"))
79
+ out.append(Check("platform", "info", f"{sys.platform}, pal-code {__version__}"))
80
+ if settings is None:
81
+ return out
82
+
83
+ home = pal_home()
84
+ try:
85
+ home.mkdir(parents=True, exist_ok=True)
86
+ probe = home / ".write-test"
87
+ probe.write_text("x", encoding="utf-8")
88
+ probe.unlink()
89
+ out.append(Check("data folder", "ok", f"{home} is writable"))
90
+ except OSError as e:
91
+ out.append(Check("data folder", "fail", f"{home}: {e}"))
92
+
93
+ out.append(Check("pandoc", "ok" if which("pandoc") else "warn",
94
+ "found" if which("pandoc") else "not found: HTML/DOCX/ODT/RTF/EPUB/LaTeX attachments will be "
95
+ "rejected (PDF, PPTX, XLSX and text still work). Install: https://pandoc.org/installing.html"))
96
+ for label, module, why in (("pypdf", "pypdf", "PDF attachments"), ("python-pptx", "pptx", "PowerPoint attachments"),
97
+ ("openpyxl", "openpyxl", "Excel attachments"), ("graphify", "graphify", "code-project graphs"),
98
+ ("waitress", "waitress", "production server (Flask dev server is the fallback)")):
99
+ ok = has_module(module)
100
+ out.append(Check(label, "ok" if ok else "warn", "installed" if ok else f"missing: no {why}. pip install pal-code[full]"))
101
+ for label, module, why in (("chromadb", "chromadb", "documents are kept in memory instead of a disk vector store"),
102
+ ("sentence-transformers", "sentence_transformers", "keyword matching is used instead of embeddings")):
103
+ out.append(Check(label, "ok" if has_module(module) else "info", "installed" if has_module(module) else f"optional: {why}"))
104
+
105
+ llm = settings.local_llm
106
+ if llm.get("backend") == "ollama":
107
+ host = (llm.get("host") or "http://localhost:11434").rstrip("/")
108
+ try:
109
+ get = http_get or _http_get_json
110
+ names = [m["name"] for m in get(host + "/api/tags").get("models", [])]
111
+ have = any(n == llm["model"] or n.startswith(llm["model"] + ":") for n in names)
112
+ out.append(Check("local LLM", "ok" if have else "warn",
113
+ f"{llm['model']} ready" if have else f"Ollama is up but {llm['model']} is not pulled: ollama pull {llm['model']}"))
114
+ except Exception as e: # noqa: BLE001 (any failure = unreachable)
115
+ out.append(Check("local LLM", "warn", f"Ollama not reachable at {host} ({e.__class__.__name__}): "
116
+ "routing and input savers need it; every request will fail until it runs"))
117
+ else:
118
+ out.append(Check("local LLM", "info", f"{llm.get('backend')} (not checked)"))
119
+
120
+ from pal.chat.providers import API_PROVIDERS
121
+ have_keys = [p for p, var in API_PROVIDERS.items() if env.get(var)]
122
+ out.append(Check("API keys", "ok" if have_keys else "info",
123
+ ", ".join(have_keys) if have_keys else "none in the environment (you can paste one in the UI)"))
124
+
125
+ from pal.llm_wrapper.modes import describe_modes
126
+ for mode in describe_modes(env):
127
+ with_keys = ", ".join(p["name"] for p in mode["providers"] if p["key_set"])
128
+ want = " or ".join(p["key_env"] for p in mode["providers"][:2]) + (" ..." if len(mode["providers"]) > 2 else "")
129
+ out.append(Check(f"mode: {mode['title'].lower()}", "ok" if mode["ready"] else "info",
130
+ f"ready ({with_keys})" if mode["ready"] else f"not set up: set {want}"))
131
+
132
+ from pal.llm_wrapper.components.litellm_wrapper import PINNED
133
+ ll_version = version_of("litellm")
134
+ if ll_version is None:
135
+ out.append(Check("litellm", "warn", f"not installed: cloud APIs need it. Run python run.py, or pip install --require-hashes "
136
+ f"-r requirements/litellm.lock (pinned to {PINNED})"))
137
+ elif ll_version != PINNED:
138
+ out.append(Check("litellm", "warn", f"version {ll_version} is installed but this release is pinned to {PINNED}. Reinstall "
139
+ "from requirements/litellm.lock (the pin was reviewed; other versions were not)"))
140
+ else:
141
+ out.append(Check("litellm", "ok", f"{ll_version} (pinned; the SDK only, never the proxy server)"))
142
+ fb = settings.fallback
143
+ if not fb.enabled:
144
+ out.append(Check("fallback", "info", "off (set fallback.enabled and fallback.model to answer with a second model when "
145
+ "your own provider is rate-limited or down)"))
146
+ elif not fb.model:
147
+ out.append(Check("fallback", "warn", "enabled but fallback.model is empty, so it does nothing"))
148
+ else:
149
+ var = API_PROVIDERS.get(fb.provider)
150
+ if var and not env.get(var):
151
+ out.append(Check("fallback", "warn", f"{fb.provider}/{fb.model} is set but {var} is not in the environment"))
152
+ else:
153
+ out.append(Check("fallback", "ok", f"{fb.provider}/{fb.model} answers when your own provider is rate-limited or down"))
154
+
155
+ out.append(_services_check(settings, env))
156
+
157
+ for cls in AGENTS.values():
158
+ spec = cls.spec
159
+ if spec is None:
160
+ continue
161
+ ok = bool(agent_key(spec, env))
162
+ out.append(Check(f"agent: {spec.id}", "ok" if ok else "info",
163
+ "key found" if ok else f"set {spec.key_env} in .env to use the {spec.name}"))
164
+
165
+ from pal.os_kernel import system_info
166
+ info = system_info()
167
+ mem = (info.get("memory") or {}).get("total_mb")
168
+ out.append(Check("OS kernel", "info", f"{info['distro']}, kernel {info['kernel']}, {info['cpu_count']} CPUs"
169
+ + (f", {mem} MB RAM" if mem else "") + " (non-LLM queries go here)"))
170
+
171
+ from pal.orchestrator.components.cgroup_manager import CgroupManager
172
+ enforced = CgroupManager.supported()
173
+ out.append(Check("agent resource limits", "info", "cgroup v2 available" if enforced else
174
+ "not enforced on this OS (only matters for the multi-agent orchestrator, not the chat)"))
175
+ return out
176
+
177
+
178
+ def _http_get_json(url: str) -> dict:
179
+ import requests
180
+ return requests.get(url, timeout=3).json()
181
+
182
+
183
+ def _print_checks(checks: list[Check], stream=None) -> None:
184
+ stream = stream or sys.stdout
185
+ mark = {"ok": "ok ", "warn": "warn", "fail": "FAIL", "info": "info"}
186
+ for c in checks:
187
+ print(f"[{mark[c.status]}] {c.name}: {c.detail}", file=stream)
188
+
189
+
190
+ def load_dotenv(path: str | os.PathLike | None = None, env=None) -> list[str]:
191
+ """Read KEY=VALUE lines from a .env file into the environment. Never overrides a variable that is already
192
+ set. Returns the names it set (values are never printed or logged)."""
193
+ from pathlib import Path
194
+ env = os.environ if env is None else env
195
+ p = Path(path) if path else Path.cwd() / ".env"
196
+ if not p.is_file():
197
+ return []
198
+ loaded = []
199
+ for raw in p.read_text(encoding="utf-8-sig").splitlines():
200
+ line = raw.strip()
201
+ if not line or line.startswith("#") or "=" not in line:
202
+ continue
203
+ key, _, value = line.removeprefix("export ").partition("=")
204
+ key, value = key.strip(), value.strip()
205
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
206
+ value = value[1:-1]
207
+ elif " #" in value:
208
+ value = value.split(" #", 1)[0].rstrip()
209
+ if key and value and key not in env:
210
+ env[key] = value
211
+ loaded.append(key)
212
+ return loaded
213
+
214
+
215
+ def _serve(settings: Settings, open_browser: bool = False, page: str = "", graph=None, documents: list | None = None) -> int:
216
+ from pal.chat import ChatService
217
+ from pal.web import create_app
218
+
219
+ s = settings.server
220
+ configure_logging(s.log_level)
221
+ service = ChatService(settings, graph=graph) if graph is not None else ChatService(settings)
222
+ if documents:
223
+ service.preload_docs = list(documents)
224
+ app = create_app(service)
225
+ if s.host not in _LOOPBACK:
226
+ print(f"WARNING: listening on {s.host}. Pal has no login: anyone who can reach this port can use "
227
+ "your API keys' quota and read stored memory. Put it behind an authenticating reverse proxy.",
228
+ file=sys.stderr)
229
+ shown = "localhost" if s.host in ("0.0.0.0", "::") else s.host # noqa: S104
230
+ url = f"http://{shown}:{s.port}" + ("/" + page.strip("/") if page else "")
231
+ print(f"Pal {__version__} on {url} (Ctrl+C to stop)")
232
+ from pal.chat.providers import API_PROVIDERS
233
+ keys = [p for p, var in API_PROVIDERS.items() if os.environ.get(var)]
234
+ print("API key found for: " + ", ".join(keys) if keys else
235
+ "No API key in the environment: paste one in the page (API key menu), or set e.g. GEMINI_API_KEY.")
236
+ status = service.local_status()
237
+ if status["ok"] is False:
238
+ print(f"WARNING: local model: {status['detail']}. Input reduction and local answers are off until it runs; "
239
+ "the app still works and sends everything to the API model.", file=sys.stderr)
240
+ else:
241
+ print(f"Local model: {status['detail']}")
242
+ if open_browser:
243
+ import threading
244
+ import webbrowser
245
+ threading.Timer(1.2, lambda: webbrowser.open(url)).start()
246
+ try:
247
+ try:
248
+ from waitress import serve
249
+ except ImportError:
250
+ print("waitress is not installed: using Flask's development server (pip install waitress)",
251
+ file=sys.stderr)
252
+ app.run(host=s.host, port=s.port, threaded=True)
253
+ else:
254
+ serve(app, host=s.host, port=s.port, threads=s.threads, ident="pal")
255
+ finally:
256
+ service.close()
257
+ return 0
258
+
259
+
260
+ class _UploadFile:
261
+ """Just enough of an uploaded file for the attachment reader."""
262
+
263
+ def __init__(self, path):
264
+ from pathlib import Path
265
+ self._path = Path(path)
266
+ self.filename = self._path.name
267
+
268
+ def read(self) -> bytes:
269
+ try:
270
+ return self._path.read_bytes()
271
+ except OSError as e:
272
+ raise ConfigError(f"cannot read {self._path}: {e.strerror or e}") from e
273
+
274
+
275
+ def _agent(settings: Settings, name: str, message: str, files: list[str]) -> int:
276
+ """One agent turn from the terminal. The same pipeline, token savers included."""
277
+ from pal.chat import ChatService
278
+ from pal.chat.uploads import read_upload
279
+
280
+ service = ChatService(settings)
281
+ try:
282
+ attachments = [read_upload(service, _UploadFile(p), "cli") for p in files]
283
+ res = service.send("cli", "", "", None, message, attachments, agent=name)
284
+ except PalError as e:
285
+ print(f"error: {e}", file=sys.stderr)
286
+ return 1
287
+ finally:
288
+ service.close()
289
+ print(res.reply)
290
+ print(f"\n[{res.input_tokens} in / {res.output_tokens} out tokens, {res.latency.get('total', 0)} s]", file=sys.stderr)
291
+ return 0
292
+
293
+
294
+ _CHAT_HELP = ("/file PATH attach a file or source zip to your next message\n/agent NAME talk to an agent (coding, docs); "
295
+ "/agent none for plain chat\n/reset start a new conversation\n/stats what has been saved so far\n"
296
+ "/help this list\n/quit leave")
297
+
298
+
299
+ def render_models(models: list, free_only: bool = False) -> str:
300
+ """OpenRouter's models as a plain table: the name to pass as --model, the context size, the price, and whether it is free."""
301
+ if not models:
302
+ return "No models match.\n"
303
+ width = max(len(m.id) for m in models)
304
+ rows = [f"{'MODEL (pass as --model)':<{width}} {'CONTEXT':>8} PRICE (USD per million tokens in / out)"]
305
+ for m in models:
306
+ ctx = f"{m.context_length:,}" if m.context_length else "?"
307
+ price = "free" if m.free else (f"{m.prompt_per_million:g} / {m.completion_per_million:g}" if m.prompt_per_million is not None and m.completion_per_million is not None else "?")
308
+ rows.append(f"{m.id:<{width}} {ctx:>8} {price}")
309
+ rows.append(f"\n{len(models)} model(s). Use one with: pal chat --mode explore --model <MODEL>")
310
+ return "\n".join(rows) + "\n"
311
+
312
+
313
+ def _models(args, fetch=None) -> int:
314
+ """pal models [--free] [--search WORDS] [--limit N] [--json]: browse OpenRouter's models (needs no key, needs the network)."""
315
+ from pal.llm_wrapper.components.openrouter_wrapper import fetch_models, search_models
316
+ try:
317
+ found = search_models(fetch_models(fetch), args.search or "", args.free, args.limit)
318
+ except Exception as e: # noqa: BLE001
319
+ print(f"could not read OpenRouter's model list ({type(e).__name__}: {e}). Check your network, or type a model name yourself.", file=sys.stderr)
320
+ return 1
321
+ if args.json:
322
+ import json
323
+ from dataclasses import asdict
324
+ print(json.dumps([asdict(m) for m in found], indent=2))
325
+ else:
326
+ print(render_models(found, args.free), end="")
327
+ return 0
328
+
329
+
330
+ def _pick_provider(settings: Settings, provider: str | None, model: str | None, env, mode: str | None = None) -> tuple[str, str]:
331
+ """Which API provider and model plain chat uses: what you asked for, else the first provider that has a key. `mode` (explore or own_key)
332
+ limits the choice to that mode's providers."""
333
+ from pal.chat.providers import API_PROVIDERS
334
+ from pal.llm_wrapper.modes import MODES, mode_for
335
+ if mode and provider and mode_for(provider) != mode:
336
+ raise ConfigError(f"{provider} is not an {MODES[mode].title.lower()} provider (mode {mode}: {', '.join(MODES[mode].providers)})")
337
+ if not provider:
338
+ allowed = MODES[mode].providers if mode else tuple(API_PROVIDERS)
339
+ provider = next((p for p in allowed if env.get(API_PROVIDERS[p])), "")
340
+ if not provider and mode == "explore":
341
+ raise ConfigError("Explore mode needs an OpenRouter key: set OPENROUTER_API_KEY (pal models lists what it can reach)")
342
+ if not provider and mode:
343
+ raise ConfigError("own-key mode needs a key for one provider: set for example GEMINI_API_KEY, or pass --provider")
344
+ if not provider:
345
+ raise ConfigError("no API key found: set one (for example GEMINI_API_KEY) or pass --provider")
346
+ if not model:
347
+ if provider != "gemini":
348
+ raise ConfigError(f"choose a model: --model <name> (for {provider})")
349
+ model = settings.agents.model
350
+ return provider, model
351
+
352
+
353
+ def _chat(settings: Settings, agent: str | None, provider: str | None, model: str | None, files: list[str],
354
+ input_fn: Callable[[str], str] | None = None, env=None, mode: str | None = None) -> int:
355
+ """An interactive terminal chat: the same pipeline as the web page (token savers, scheduler, agents)."""
356
+ from pal.chat import ChatService
357
+ from pal.chat.uploads import read_upload
358
+
359
+ env = os.environ if env is None else env
360
+ read = input_fn if input_fn is not None else input # looked up now, so it can be replaced (and tested)
361
+ service = ChatService(settings)
362
+ session, pending = "terminal", []
363
+ try:
364
+ provider, model = _pick_provider(settings, provider, model, env, mode) if not agent else (provider or "", model or "")
365
+ print(f"Pal chat ({'agent: ' + agent if agent else provider + '/' + model}). /help lists commands.", file=sys.stderr)
366
+ for p in files:
367
+ pending.append(read_upload(service, _UploadFile(p), session))
368
+ while True:
369
+ try:
370
+ line = read("you> ").strip()
371
+ except (EOFError, KeyboardInterrupt):
372
+ print()
373
+ return 0
374
+ if not line:
375
+ continue
376
+ if line.startswith("/"):
377
+ cmd, _, arg = line.partition(" ")
378
+ if cmd in ("/quit", "/exit"):
379
+ return 0
380
+ if cmd == "/help":
381
+ print(_CHAT_HELP)
382
+ elif cmd == "/reset":
383
+ service.reset(session)
384
+ pending.clear()
385
+ print("new conversation")
386
+ elif cmd == "/agent":
387
+ name = arg.strip()
388
+ if name in ("", "none"):
389
+ agent = None
390
+ provider, model = _pick_provider(settings, provider, model, env, mode)
391
+ elif name in AGENTS:
392
+ agent = name
393
+ else:
394
+ print(f"unknown agent {name!r}; choose one of: {', '.join(AGENTS)}")
395
+ continue
396
+ print(f"now talking to: {agent or 'plain chat'}")
397
+ elif cmd == "/file":
398
+ try:
399
+ pending.append(read_upload(service, _UploadFile(arg.strip()), session))
400
+ print(f"attached {pending[-1].get('name', arg.strip())}; it goes with your next message")
401
+ except PalError as e:
402
+ print(f"error: {e}")
403
+ elif cmd == "/stats":
404
+ s = service.metrics.chat_summary(session)
405
+ print(f"turns {s.get('turns', 0)} | tokens saved (estimate) {s.get('tokens_saved_estimate', {})} | "
406
+ f"cost ${s.get('cost_usd', 0)} | saved about ${s.get('saved_cost_usd', 0)}")
407
+ else:
408
+ print("unknown command; /help lists them")
409
+ continue
410
+ try:
411
+ res = service.send(session, provider or "", model or "", None, line, pending, agent=agent)
412
+ except PalError as e:
413
+ print(f"error: {e}")
414
+ continue
415
+ pending = []
416
+ print(res.reply)
417
+ note = [res.handled_by, f"{res.input_tokens} in / {res.output_tokens} out"]
418
+ if res.fallback:
419
+ note.append(f"answered by fallback {res.fallback['provider']}/{res.fallback['model']}")
420
+ if res.cost_usd:
421
+ note.append(f"${res.cost_usd:.4f}")
422
+ print("[" + " | ".join(note) + "]", file=sys.stderr)
423
+ except PalError as e:
424
+ print(f"error: {e}", file=sys.stderr)
425
+ return 1
426
+ finally:
427
+ service.close()
428
+
429
+
430
+ def build_parser() -> argparse.ArgumentParser:
431
+ p = argparse.ArgumentParser(prog="pal", description="Pal chat layer")
432
+ p.add_argument("--version", action="version", version=f"pal-code {__version__}")
433
+ sub = p.add_subparsers(dest="cmd", required=True)
434
+ for name, help_ in (("serve", "run the web chat"), ("doctor", "check this machine"),
435
+ ("config", "print the effective settings"), ("agent", "run the coding or documentation agent"),
436
+ ("chat", "chat in the terminal"), ("tools", "list every tool by name"),
437
+ ("models", "browse the models OpenRouter can reach (Explore mode)")):
438
+ sp = sub.add_parser(name, help=help_)
439
+ sp.add_argument("--config", help="path to a settings YAML file (or set PAL_CONFIG)")
440
+ if name == "agent":
441
+ sp.add_argument("name", choices=list(AGENTS))
442
+ sp.add_argument("message")
443
+ sp.add_argument("--file", action="append", default=[], help="attach a file or source zip (repeatable)")
444
+ if name == "models":
445
+ sp.add_argument("--search", help="only models whose name contains these words")
446
+ sp.add_argument("--free", action="store_true", help="only free models")
447
+ sp.add_argument("--limit", type=int, default=30, help="at most this many (default 30; 0 for all)")
448
+ sp.add_argument("--json", action="store_true")
449
+ if name == "chat":
450
+ sp.add_argument("--agent", choices=list(AGENTS))
451
+ sp.add_argument("--mode", choices=["explore", "own_key"], help="explore: one OpenRouter key, many models. own_key: your key for one provider")
452
+ sp.add_argument("--provider")
453
+ sp.add_argument("--model")
454
+ sp.add_argument("--file", action="append", default=[], help="attach a file or source zip (repeatable)")
455
+ if name == "serve":
456
+ sp.add_argument("--host")
457
+ sp.add_argument("--port", type=int)
458
+ sp.add_argument("--open", action="store_true", help="open the page in your browser")
459
+ sp.add_argument("--page", choices=["", "studio"], default="", help="which page to open: the chat (default) or the Studio")
460
+ return p
461
+
462
+
463
+ def main(argv: Sequence[str] | None = None) -> int:
464
+ args = build_parser().parse_args(argv)
465
+ load_dotenv()
466
+ try:
467
+ overrides: dict = {}
468
+ if args.cmd == "serve":
469
+ server = {k: v for k, v in (("host", args.host), ("port", args.port)) if v is not None}
470
+ overrides = {"server": server} if server else {}
471
+ settings = load_settings(args.config, overrides=overrides)
472
+ except ConfigError as e:
473
+ if args.cmd == "doctor":
474
+ _print_checks(run_doctor(None) + [Check("config", "fail", str(e))])
475
+ return 1
476
+ print(f"config error: {e}", file=sys.stderr)
477
+ return 2
478
+
479
+ if args.cmd == "config":
480
+ print(yaml.safe_dump(settings.to_dict(), sort_keys=False, allow_unicode=True), end="")
481
+ return 0
482
+ if args.cmd == "doctor":
483
+ checks = run_doctor(settings)
484
+ _print_checks(checks)
485
+ failed = [c for c in checks if c.status == "fail"]
486
+ print(f"\n{len(failed)} problem(s)." if failed else "\nReady. Run: pal cli (terminal) or pal web (browser)")
487
+ return 1 if failed else 0
488
+ if args.cmd == "agent":
489
+ return _agent(settings, args.name, args.message, args.file)
490
+ if args.cmd == "tools":
491
+ from pal.tool_catalog import render
492
+ print(render(settings.tools, os.environ), end="")
493
+ return 0
494
+ if args.cmd == "models":
495
+ return _models(args)
496
+ if args.cmd == "chat":
497
+ return _chat(settings, args.agent, args.provider, args.model, args.file, mode=args.mode)
498
+ return _serve(settings, open_browser=args.open, page=args.page)
499
+
500
+
501
+ if __name__ == "__main__":
502
+ raise SystemExit(main())
@@ -0,0 +1,12 @@
1
+ """The Compression component: the NLP prompt compressor and the history compressor (old assistant replies, facts of old turns)."""
2
+ from pal.compression.history import (
3
+ HistoryCompressor,
4
+ LayeredHistory,
5
+ compact_reply,
6
+ composition,
7
+ extract_facts,
8
+ facts_block,
9
+ )
10
+ from pal.compression.pipeline import CompressionPipeline
11
+
12
+ __all__ = ["CompressionPipeline", "HistoryCompressor", "LayeredHistory", "compact_reply", "composition", "extract_facts", "facts_block"]
@@ -0,0 +1,64 @@
1
+ """
2
+ Optional NLP-backed components (nltk/spacy/proselint). Each import is
3
+ independently guarded: if a dependency isn't installed yet (before
4
+ install.sh's model-download step has run, or in a minimal/CI
5
+ environment), the corresponding name is simply left unavailable here
6
+ rather than crashing the entire pal import chain -- Python
7
+ imports a package's __init__.py as a side effect of importing ANY of
8
+ its submodules, so this file being fragile would break unrelated
9
+ components too (llm_wrapper, orchestrator, ...) that have nothing to do
10
+ with text compression.
11
+
12
+ pal.compression.pipeline.CompressionPipeline already degrades
13
+ gracefully (falls back to whitespace/dedupe-only compression) when
14
+ TextMinifier itself can't be built for the same reason.
15
+ """
16
+
17
+ try:
18
+ from pal.compression.components.adverbs import Adverbs
19
+ except ImportError:
20
+ Adverbs = None
21
+
22
+ try:
23
+ from pal.compression.components.hedges import Hedges
24
+ except ImportError:
25
+ Hedges = None
26
+
27
+ try:
28
+ from pal.compression.components.interjections import Interjections
29
+ except ImportError:
30
+ Interjections = None
31
+
32
+ try:
33
+ from pal.compression.components.pleonasms import Pleonasms
34
+ except ImportError:
35
+ Pleonasms = None
36
+
37
+ try:
38
+ from pal.compression.components.punctuations import Punctuations
39
+ except ImportError:
40
+ Punctuations = None
41
+
42
+ try:
43
+ from pal.compression.components.stopwords import Stopwords
44
+ except ImportError:
45
+ Stopwords = None
46
+
47
+ try:
48
+ from pal.compression.components.minifier import TextMinifier
49
+ except ImportError:
50
+ TextMinifier = None
51
+
52
+ # No optional deps -- always available.
53
+ from pal.compression.components.output_compressor import OutputCompressor
54
+
55
+ __all__ = [
56
+ "Adverbs",
57
+ "Hedges",
58
+ "Interjections",
59
+ "TextMinifier",
60
+ "OutputCompressor",
61
+ "Pleonasms",
62
+ "Punctuations",
63
+ "Stopwords",
64
+ ]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pal.compression.utils.spacy_setup import SpacySetup
4
+
5
+
6
+ class Adverbs:
7
+ def __init__(self, spacy_setup: SpacySetup | None = None) -> None:
8
+ self.spacy_setup = spacy_setup or SpacySetup()
9
+ self.nlp = self.spacy_setup.load_model()
10
+
11
+ def _tokenize(self, text: str):
12
+ return self.nlp(text)
13
+
14
+ def _is_adverb(self, token) -> bool:
15
+ return token.pos_ == "ADV"
16
+
17
+ def remove(self, text: str) -> str:
18
+ doc = self._tokenize(text)
19
+ kept_tokens = [token.text for token in doc if not self._is_adverb(token)]
20
+ return " ".join(kept_tokens)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from proselint.tools import DEFAULT, LintFile
4
+
5
+
6
+ class Hedges:
7
+ RELEVANT_PREFIX: str = "hedging"
8
+
9
+ def _lint(self, text: str) -> list:
10
+ lint_file = LintFile("<memory>", text)
11
+ return [
12
+ result
13
+ for result in lint_file.lint(DEFAULT)
14
+ if result.check_result.check_path.startswith(self.RELEVANT_PREFIX)
15
+ ]
16
+
17
+ def _extract_edits(self, results: list) -> list[tuple[int, int, str | None]]:
18
+ return sorted(
19
+ (
20
+ (
21
+ result.check_result.span[0] - 1,
22
+ result.check_result.span[1] - 1,
23
+ result.check_result.replacements,
24
+ )
25
+ for result in results
26
+ ),
27
+ key=lambda edit: (edit[0], -(edit[1] - edit[0])),
28
+ )
29
+
30
+ def _resolve_overlaps(
31
+ self, edits: list[tuple[int, int, str | None]]
32
+ ) -> list[tuple[int, int, str | None]]:
33
+ resolved: list[tuple[int, int, str | None]] = []
34
+ last_end = -1
35
+ for start, end, replacement in edits:
36
+ if start < last_end:
37
+ continue
38
+ resolved.append((start, end, replacement))
39
+ last_end = end
40
+ return resolved
41
+
42
+ def _apply_edits(
43
+ self, text: str, edits: list[tuple[int, int, str | None]]
44
+ ) -> str:
45
+ cleaned = text
46
+ for start, end, replacement in reversed(edits):
47
+ cleaned = cleaned[:start] + (replacement or "") + cleaned[end:]
48
+ return cleaned
49
+
50
+ def remove(self, text: str) -> str:
51
+ results = self._lint(text)
52
+ edits = self._extract_edits(results)
53
+ resolved_edits = self._resolve_overlaps(edits)
54
+ return self._apply_edits(text, resolved_edits)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pal.compression.utils.spacy_setup import SpacySetup
4
+
5
+
6
+ class Interjections:
7
+ def __init__(self, spacy_setup: SpacySetup | None = None) -> None:
8
+ self.spacy_setup = spacy_setup or SpacySetup()
9
+ self.nlp = self.spacy_setup.load_model()
10
+
11
+ def _tokenize(self, text: str):
12
+ return self.nlp(text)
13
+
14
+ def _is_interjection(self, token) -> bool:
15
+ return token.pos_ == "INTJ"
16
+
17
+ def remove(self, text: str) -> str:
18
+ doc = self._tokenize(text)
19
+ kept_tokens = [token.text for token in doc if not self._is_interjection(token)]
20
+ return " ".join(kept_tokens)