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,170 @@
1
+ """The two ways a build talks to the model: a plain chat client, and Pal. Both count every token and second the same way.
2
+
3
+ PlainClient a chat that resends the whole conversation every step (what any simple chat client does).
4
+ PalClient the real Pal pipeline: input savers, paged and compressed history, document excerpts, output rules.
5
+ Tokens are the wrapper's own counts when the model reports them, else an estimate (characters / 4), the same for both.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import tempfile
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, LLMWrapper, WrapperCapabilities
15
+ from pal.tokens import estimate_tokens
16
+
17
+
18
+ @dataclass
19
+ class Reply:
20
+ text: str
21
+ prompt_tokens: int = 0 # read by the main model
22
+ completion_tokens: int = 0 # written by the main model
23
+ helper_tokens: int = 0 # Pal's small jobs (trimming, checking): an extra cost, shown separately
24
+ seconds: float = 0.0
25
+ calls: int = 0
26
+ saved_by: dict = field(default_factory=dict) # where Pal saved tokens on this request: stage -> tokens (estimates)
27
+ route: str = "api" # "api" (the main model answered), "local" (the small local model did) or "tool"
28
+ cost_usd: float = 0.0 # what the request cost, when the provider's price is known (0 for local models)
29
+
30
+
31
+ @dataclass
32
+ class Counts:
33
+ prompt: int = 0
34
+ completion: int = 0
35
+ calls: int = 0
36
+ seconds: float = 0.0
37
+
38
+ def since(self, before: Counts) -> Counts:
39
+ return Counts(self.prompt - before.prompt, self.completion - before.completion, self.calls - before.calls, self.seconds - before.seconds)
40
+
41
+
42
+ class CountingWrapper(LLMWrapper):
43
+ """Wraps a model and counts what goes through it. Everything else is passed on untouched."""
44
+ name = "counting"
45
+
46
+ def __init__(self, inner: LLMWrapper):
47
+ super().__init__(inner.config)
48
+ self.inner = inner
49
+ self.prompt = self.completion = self.calls = 0
50
+ self.estimated = 0 # calls whose token counts were estimated (the model did not report them)
51
+ self.seconds = 0.0
52
+
53
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
54
+ t0 = time.perf_counter()
55
+ r = self.inner.chat(syscall)
56
+ self.seconds += time.perf_counter() - t0
57
+ self.prompt += r.prompt_tokens or sum(estimate_tokens(m.get("content", "")) for m in syscall.messages)
58
+ self.completion += r.completion_tokens or estimate_tokens(r.text)
59
+ self.calls += 1
60
+ self.estimated += 0 if (r.prompt_tokens and r.completion_tokens) else 1
61
+ return r
62
+
63
+ def stream_chat(self, syscall: LLMSyscall):
64
+ t0 = time.perf_counter()
65
+ out = []
66
+ for piece in self.inner.stream_chat(syscall):
67
+ out.append(piece)
68
+ yield piece
69
+ self.seconds += time.perf_counter() - t0
70
+ self.prompt += sum(estimate_tokens(m.get("content", "")) for m in syscall.messages)
71
+ self.completion += estimate_tokens("".join(out))
72
+ self.calls += 1
73
+
74
+ def token_count(self, text: str) -> int:
75
+ return self.inner.token_count(text)
76
+
77
+ def capabilities(self) -> WrapperCapabilities:
78
+ return self.inner.capabilities()
79
+
80
+ def snapshot(self) -> Counts:
81
+ return Counts(self.prompt, self.completion, self.calls, self.seconds)
82
+
83
+
84
+ class PlainClient:
85
+ label = "Plain chat client"
86
+
87
+ def __init__(self, core: LLMWrapper, system: str, max_tokens: int = 3000):
88
+ self.core, self.max_tokens = CountingWrapper(core), max_tokens
89
+ self.messages: list[dict] = [{"role": "system", "content": system}]
90
+
91
+ def ask(self, message: str, references: list[dict] | None = None, query: str | None = None) -> Reply:
92
+ if references: # a plain client has no document index: it pastes the files into the message, and so resends them every step
93
+ message += "\n\nReference files from the person:\n" + "\n\n".join(f"### {r['name']}\n{r['text']}" for r in references)
94
+ self.messages.append({"role": "user", "content": message})
95
+ before = self.core.snapshot()
96
+ text = self.core.chat(LLMSyscall(messages=list(self.messages), temperature=0.0, max_tokens=self.max_tokens)).text
97
+ self.messages.append({"role": "assistant", "content": text})
98
+ d = self.core.snapshot().since(before)
99
+ return Reply(text, d.prompt, d.completion, 0, d.seconds, d.calls)
100
+
101
+ def close(self) -> None:
102
+ return None
103
+
104
+
105
+ class PalClient:
106
+ label = "Pal"
107
+
108
+ def __init__(self, core: LLMWrapper, helper: LLMWrapper | None = None, allow_tools: frozenset | None = frozenset(), network: bool = False,
109
+ hot_turns: int | None = None, home: Path | None = None, model_name: str = "model", max_tokens: int = 3000, local: bool = True, project=None):
110
+ """`local=False` (a computer without a GPU): no local model at all, so routing, input trimming and checking are off and the message
111
+ goes to the API model as typed; paging and document excerpts, which need no model, still save tokens."""
112
+ from pal.chat import ChatService
113
+ from pal.config import load_settings
114
+ from pal.knowledge_graph import KnowledgeGraph
115
+
116
+ self.main, self.helper = CountingWrapper(core), CountingWrapper(helper or core)
117
+ self.home = Path(home) if home else Path(tempfile.mkdtemp(prefix="pal-"))
118
+ self.allow, self.model_name, self.max_tokens = allow_tools, model_name, max_tokens
119
+ context = {"hot_turns": hot_turns} if hot_turns else {}
120
+ settings = load_settings(env={}, overrides={"storage_dir": str(self.home), "knowledge_graph": {"dir": str(self.home / "graph")}, "memory": {"enabled": False},
121
+ "tools": {"enabled": True, "network": bool(network)}, "context": context, "server": {"session_ttl_minutes": 240},
122
+ **({} if local else {"local_llm": {"enabled": False}})})
123
+ graph = project.graph if project is not None else KnowledgeGraph(settings.knowledge_graph)
124
+ self.svc = ChatService(settings, local_core=self.helper, graph=graph)
125
+ if project is not None: # the folder's documents and project map: passages that match each request are added
126
+ self.svc.preload_docs = project.documents()
127
+ self.role_cores: dict[str, CountingWrapper] = {} # agent -> its own model (/model plan <model>); the others use the main model
128
+ self.role: str | None = None # the agent making the current request
129
+ self.svc.api_core = lambda provider, model, api_key=None: self.core_for(self.role) # type: ignore[method-assign]
130
+ self.sid = "pal-" + self.home.name[-8:].replace("_", "-").lower()
131
+
132
+ def core_for(self, role: str | None) -> CountingWrapper:
133
+ return self.role_cores.get(role or "", self.main)
134
+
135
+ def set_role_core(self, role: str, core: LLMWrapper | None) -> None:
136
+ """Give one agent its own model (None: back to the main model). The conversation stays the same."""
137
+ if core is None:
138
+ self.role_cores.pop(role, None)
139
+ else:
140
+ self.role_cores[role] = CountingWrapper(core)
141
+
142
+ def ask(self, message: str, references: list[dict] | None = None, query: str | None = None) -> Reply:
143
+ core = self.core_for(self.role)
144
+ before_main, before_helper = core.snapshot(), self.helper.snapshot()
145
+ attachments = [{"name": r["name"], "text": r["text"], "kind": "document"} for r in (references or [])]
146
+ res = self.svc.send(self.sid, "local", self.model_name, None, message, attachments, route="api", allow_tools=self.allow, query=query)
147
+ m, h = core.snapshot().since(before_main), self.helper.snapshot().since(before_helper)
148
+ return Reply(res.reply, m.prompt, m.completion, h.prompt + h.completion, m.seconds + h.seconds, m.calls, self._saved_by(), res.handled_by,
149
+ float(getattr(res, "cost_usd", 0.0) or 0.0))
150
+
151
+ def add_documents(self, docs: list[tuple[str, str]]) -> None:
152
+ """Index documents into this conversation now (and into any later one)."""
153
+ self.svc.preload_docs = list(docs)
154
+ sess = self.svc.sessions.get_or_create(self.sid)
155
+ if sess.docs is not None:
156
+ for name, text in docs:
157
+ sess.docs.add(name, text)
158
+
159
+ def _saved_by(self) -> dict:
160
+ """The savings the engine recorded for the turn that just ran, by stage. Every number is an estimate (characters / 4)."""
161
+ _, turns = self.svc.metrics.collector.snapshot()
162
+ if not turns:
163
+ return {}
164
+ t = turns[-1]
165
+ compressed = min(max(t.compression_saved, 0), max(t.saved_paging, 0)) # history savings already include what compression saved
166
+ found = {"input": t.saved_savers, "history": t.saved_paging - compressed, "compression": compressed, "documents": t.saved_documents, "code": t.saved_graph}
167
+ return {k: int(v) for k, v in found.items() if v > 0}
168
+
169
+ def close(self) -> None:
170
+ self.svc.close()
@@ -0,0 +1,401 @@
1
+ """Session commands in the style of Claude Code, for Pal: /init, /clear, /compact, /btw, /cost, /context, /memory, /add-dir, /permissions, /config,
2
+ /plan /build /ask, /copy, /export, /agents, /simplify, /status, /doctor, /resume, /shortcuts, /voice.
3
+
4
+ `handle_extra` returns (session, keep going) for a command it knows, or None so the caller tries the others.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from pal import __version__
20
+ from pal.terminal import backend, theme, ui
21
+ from pal.terminal.project import NOTES_FILE
22
+ from pal.terminal.session import MODES, PalSession
23
+ from pal.tokens import estimate_tokens
24
+
25
+ COMMANDS: list[tuple[str, str]] = [
26
+ ("/init", f"look through the project and start a {NOTES_FILE} (its instructions for Pal); you review it like any change"),
27
+ ("/clear", "a fresh conversation (the plan, its context and pending changes stay). Alias: /reset"),
28
+ ("/compact", "shorten the conversation to a summary to save tokens: /compact [what to keep]"),
29
+ ("/btw", "a quick side question that is not added to the conversation: /btw what does HTTP 409 mean?"),
30
+ ("/cost", "tokens used and saved this session, and the cost when the provider's price is known"),
31
+ ("/context", "what goes to the model with each request, and how big each part is"),
32
+ ("/memory", f"show the project's {NOTES_FILE}; /memory edit opens it in your editor"),
33
+ ("/add-dir", "read files from another folder too: /add-dir ../shared-lib"),
34
+ ("/permissions", "what Pal may do by itself (autonomy). Alias of /autonomy"),
35
+ ("/config", "settings: model per agent, endpoint, autonomy, where things are kept. Alias: /settings"),
36
+ ("/plan", "switch to the Plan agent, and optionally ask it: /plan add a login page"),
37
+ ("/build", "switch to the Build agent, and optionally ask it"),
38
+ ("/ask", "switch to the Ask agent, and optionally ask it"),
39
+ ("/copy", "copy the last answer to the clipboard"),
40
+ ("/export", "save the conversation as markdown: /export [file]"),
41
+ ("/agents", "the three agents and the model each one uses"),
42
+ ("/simplify", "review the files changed since the last commit for reuse, quality and speed; fixes go to the sandbox"),
43
+ ("/status", "version, folder, models, plan, autonomy, this computer"),
44
+ ("/doctor", "check this computer and the install"),
45
+ ("/resume", "pick up an earlier plan and its conversation: /resume [name or number]"),
46
+ ("/voice", "talk instead of typing (also ctrl+space)"),
47
+ ("/shortcuts", "every keyboard shortcut (also ?)"),
48
+ ]
49
+
50
+ SHORTCUTS: list[tuple[str, str]] = [
51
+ ("enter", "send"), ("alt+enter or \\ then enter", "a new line"), ("↑ / ↓", "earlier messages (inside a long message: move between lines)"),
52
+ ("esc esc", "clear what you typed"), ("esc", "stop Pal while it works"), ("ctrl+c", "clear the box, stop Pal, or leave on an empty box"),
53
+ ("ctrl+d / ctrl+q", "leave"), ("tab", "switch agent: Build, Plan, Ask (or complete a command)"),
54
+ ("shift+tab", "cycle autonomy: none, basic, plus, semi (full only with /autonomy full)"), ("/ or ctrl+p", "the command menu (↑↓ to pick, enter or tab)"),
55
+ ("ctrl+r", "search earlier messages (again: older)"), ("ctrl+space", "talk instead of typing"), ("ctrl+l", "clear the screen"),
56
+ ("?", "these shortcuts (on an empty box)"), ("@path", "add a file to the message"), ("drop files on the window", "attach them"),
57
+ ("ctrl+a / ctrl+e", "start / end of the line"), ("ctrl+u / ctrl+k", "delete to the start / end of the line"), ("alt+b / alt+f", "a word back / forward"),
58
+ ]
59
+
60
+
61
+ def _say(console, text: str, style: str = theme.MUTED) -> None:
62
+ ui.say(console, Text(text, style=style))
63
+
64
+
65
+ def copy_to_clipboard(text: str) -> str:
66
+ """Copy with the system's tool, or the terminal's OSC 52 (works over SSH in most terminals). Returns how it was copied, or ''."""
67
+ tools = [["pbcopy"]] if sys.platform == "darwin" else [["clip"]] if os.name == "nt" else [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "-b", "-i"]]
68
+ for cmd in tools:
69
+ if shutil.which(cmd[0]):
70
+ try:
71
+ subprocess.run(cmd, input=text.encode("utf-16le" if cmd[0] == "clip" else "utf-8"), check=True, timeout=5)
72
+ return cmd[0]
73
+ except (OSError, subprocess.SubprocessError):
74
+ continue
75
+ if sys.stdout.isatty():
76
+ sys.stdout.write("\x1b]52;c;" + base64.b64encode(text.encode("utf-8")).decode("ascii") + "\x07")
77
+ sys.stdout.flush()
78
+ return "the terminal (OSC 52)"
79
+ return ""
80
+
81
+
82
+ def conversation_markdown(wf) -> str:
83
+ b = wf.plan.b
84
+ out = [f"# Pal conversation: plan {wf.plan.name} ({wf.plan.branch})", "", f"_{time.strftime('%Y-%m-%d %H:%M')} · {wf.session.cwd}_", ""]
85
+ for m in b.convo:
86
+ who = "**You**" if m.get("role") == "you" else f"**Pal ({m.get('agent', '')})**"
87
+ out += [f"{who}", "", m.get("text", ""), ""]
88
+ if b.pending:
89
+ out += ["## Pending changes", ""] + [f"- {p}" for p in sorted(b.pending)] + [""]
90
+ return "\n".join(out)
91
+
92
+
93
+ def handle_extra(cmd: str, rest: str, args, session: PalSession, console, core_factory, box: dict | None):
94
+ wf = box["wf"] if box else None
95
+ host = getattr(args, "host", None) or backend.DEFAULT_HOST
96
+ if cmd in ("/shortcuts", "/keys", "?"):
97
+ grid = Table.grid(padding=(0, 3))
98
+ for k, what in SHORTCUTS:
99
+ grid.add_row(Text(k, style=f"bold {theme.WHITE}"), Text(what, style=theme.MUTED))
100
+ ui.say(console, grid)
101
+ console.print()
102
+ return session, True
103
+
104
+ if cmd in ("/plan", "/build", "/ask"):
105
+ session.mode = cmd[1:]
106
+ backend.save_prefs(mode=session.mode)
107
+ _say(console, f"Agent: {session.mode.capitalize()}")
108
+ console.print()
109
+ if rest and wf is not None:
110
+ from pal.terminal import flow_cli
111
+ flow_cli.run_turn(wf, rest, console)
112
+ return session, True
113
+
114
+ if cmd in ("/clear", "/reset"):
115
+ if wf is None or box is None:
116
+ return session, True
117
+ from pal.terminal.cli import _bind_status, _fresh
118
+ from pal.terminal.workflow import Workflow
119
+ fresh = _fresh(session, args, core_factory)
120
+ wf.plan.b.convo = []
121
+ wf.save()
122
+ box["wf"] = Workflow(fresh, wf.store, plan=wf.plan.name)
123
+ _bind_status(box["wf"])
124
+ _say(console, "Fresh conversation. The plan, its context and pending changes are still here (/current).")
125
+ console.print()
126
+ return fresh, True
127
+
128
+ if cmd == "/compact":
129
+ if wf is None or box is None or not wf.plan.b.convo:
130
+ _say(console, "Nothing to compact yet.")
131
+ console.print()
132
+ return session, True
133
+ from pal.terminal import flow_cli
134
+ from pal.terminal.cli import _bind_status, _fresh
135
+ from pal.terminal.workflow import Workflow
136
+ before = sum(estimate_tokens(m.get("text", "")) for m in wf.plan.b.convo)
137
+ transcript = "\n".join(f"{m.get('role')}: {m.get('text', '')[:3000]}" for m in wf.plan.b.convo[-60:])
138
+ focus = f" Keep in particular: {rest}." if rest else ""
139
+ mode = session.mode
140
+ session.mode = "ask"
141
+ try:
142
+ reply = flow_cli.safely(console, lambda: flow_cli.spin(session, console, "Pal is summarising…", lambda: session.client.ask(
143
+ "Summarise this conversation so it can continue from the summary alone: decisions made, what was built, open questions, "
144
+ f"names of files and functions that matter. Short bullet points, no preface.{focus}\n\n{transcript}")))
145
+ finally:
146
+ session.mode = mode
147
+ if reply is None:
148
+ return session, True
149
+ summary = ui.clean_terminal(reply.text).strip()
150
+ fresh = _fresh(session, args, core_factory)
151
+ wf.plan.b.convo = [{"role": "pal", "agent": "summary", "text": "Summary of the conversation so far:\n" + summary, "t": time.time()}]
152
+ wf.save()
153
+ box["wf"] = Workflow(fresh, wf.store, plan=wf.plan.name)
154
+ _bind_status(box["wf"])
155
+ after = estimate_tokens(summary)
156
+ _say(console, f"Compacted: the conversation is now a summary of about {after:,} tokens (it was about {before:,}).")
157
+ console.print()
158
+ return fresh, True
159
+
160
+ if cmd == "/btw":
161
+ if not rest:
162
+ _say(console, "Use: /btw <a quick question> (not added to the conversation)")
163
+ console.print()
164
+ return session, True
165
+ from pal.terminal import flow_cli
166
+ from pal.terminal.session import make_session
167
+ prov, model = session.model_for("ask")
168
+ try:
169
+ core = (core_factory or backend.make_core)(prov, model, host)
170
+ except Exception as e: # noqa: BLE001
171
+ _say(console, f"Could not start the side question: {e}", theme.RED_HI)
172
+ return session, True
173
+ side = make_session(core, model, prov, session.cwd, "ask", helper=session.helper_core, local=session.local, project=session.project)
174
+ try:
175
+ ui.show_user(console, "btw: " + rest)
176
+ result = flow_cli.safely(console, lambda: flow_cli.spin(side, console, "Pal is answering on the side…", lambda: side.ask(rest)))
177
+ if result is not None:
178
+ ui.show_reply(console, result, side)
179
+ session.pal_tokens += side.pal_tokens
180
+ session.cost_usd += side.cost_usd
181
+ finally:
182
+ side.close()
183
+ _say(console, "(not added to the conversation)")
184
+ console.print()
185
+ return session, True
186
+
187
+ if cmd == "/cost":
188
+ grid = Table.grid(padding=(0, 3))
189
+ rows = [("used", f"{session.pal_tokens:,} tokens"), ("helper (local)", f"{session.helper_tokens:,} tokens"),
190
+ ("a plain chat would have used", f"about {session.plain_tokens:,} tokens"),
191
+ ("saved", f"about {max(0, session.saved):,} tokens ({session.saved_pct:g}%)"),
192
+ ("cost", f"${session.cost_usd:.4f}" if session.cost_usd else "no price known (local models are free)"),
193
+ ("requests", str(session.turns)), ("time with the model", f"{session.seconds:.0f}s")]
194
+ for k, v in rows:
195
+ grid.add_row(Text(k, style=theme.MUTED), Text(v, style=f"bold {theme.WHITE}"))
196
+ ui.say(console, grid)
197
+ _say(console, "Where the savings came from: /tokens")
198
+ console.print()
199
+ return session, True
200
+
201
+ if cmd == "/context":
202
+ parts: list[tuple[str, int, str]] = []
203
+ if session.project is not None:
204
+ r = session.project.report
205
+ parts.append(("project map (indexed)", r.map_tokens, "only matching lines are sent"))
206
+ docs = sum(d.tokens for d in r.docs if not d.error)
207
+ if docs:
208
+ parts.append((f"documents, {len(r.docs)} (indexed)", docs, "only matching passages are sent"))
209
+ parts.append(("code graph", 0, r.graph))
210
+ notes = session.project.notes()
211
+ if notes:
212
+ parts.append((NOTES_FILE, estimate_tokens(notes), "sent once per session"))
213
+ if wf is not None:
214
+ rows = wf.context_rows()
215
+ if rows:
216
+ parts.append((f"loaded context, {len(rows)} item(s)", sum(row[2] for row in rows), "sent when it changes"))
217
+ if wf.plan.b.pending:
218
+ parts.append((f"pending changes, {len(wf.plan.b.pending)} file(s)", sum(estimate_tokens(v) for v in wf.plan.b.pending.values()), "diffs are taken against these"))
219
+ parts.append(("conversation so far", session.history_tokens, "recent turns sent, older ones only when relevant"))
220
+ top = max((n for _, n, _ in parts), default=1) or 1
221
+ grid = Table.grid(padding=(0, 2))
222
+ for name, n, note in parts:
223
+ bar = "█" * max(1, round(24 * n / top)) if n else ""
224
+ grid.add_row(Text(name, style=theme.WHITE), Text(f"{n:,}" if n else "-", style=theme.INK, justify="right"),
225
+ Text(bar, style=theme.RED), Text(note, style=theme.MUTED))
226
+ ui.say(console, grid)
227
+ _say(console, "Tokens are estimates (characters / 4). /cost for the totals, /tokens for the savings.")
228
+ console.print()
229
+ return session, True
230
+
231
+ if cmd == "/memory":
232
+ f = session.cwd / NOTES_FILE
233
+ if rest.strip() == "edit":
234
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if os.name == "nt" else "nano" if shutil.which("nano") else "vi")
235
+ if not f.exists():
236
+ f.write_text(f"# {session.cwd.name}\n\nInstructions for Pal in this project.\n", encoding="utf-8")
237
+ subprocess.call([*editor.split(), str(f)]) # noqa: S603 (your own editor, on your own file)
238
+ session.brief = session.project.brief() if session.project is not None else session.brief
239
+ _say(console, f"{NOTES_FILE} saved. It goes with the first request of every session.")
240
+ elif f.is_file():
241
+ ui.say(console, Text(f.read_text(encoding="utf-8", errors="replace")[:4000], style=theme.INK))
242
+ _say(console, f"/memory edit opens it in your editor ({ui.short_path(f)})")
243
+ else:
244
+ _say(console, f"No {NOTES_FILE} yet. /init starts one from the project, /memory edit writes one yourself.")
245
+ console.print()
246
+ return session, True
247
+
248
+ if cmd == "/add-dir":
249
+ if wf is None or not rest:
250
+ _say(console, "Use: /add-dir <folder> its files join the context (read-only; secrets stay hidden)")
251
+ console.print()
252
+ return session, True
253
+ target = Path(rest.strip().strip("\"'")).expanduser().resolve()
254
+ if not target.is_dir():
255
+ _say(console, f"{rest}: not a folder", theme.RED_HI)
256
+ else:
257
+ added, problems = wf.load([str(target)])
258
+ _say(console, f"Added {len(added)} file(s) from {ui.short_path(target)} to the context." + (f" {'; '.join(problems)}" if problems else ""))
259
+ console.print()
260
+ return session, True
261
+
262
+ if cmd == "/permissions":
263
+ from pal.terminal import flow_cli
264
+ flow_cli.handle_flow("/autonomy", rest, wf, console) if wf is not None else None
265
+ return session, True
266
+
267
+ if cmd in ("/config", "/settings"):
268
+ grid = Table.grid(padding=(0, 3))
269
+ rows = [("main model", f"{session.model} ({session.provider})")]
270
+ rows += [(f"{m} agent", f"{session.model_for(m)[1]} ({session.model_for(m)[0]})" + ("" if m in session.role_models else " (main)")) for m in MODES]
271
+ rows += [("helper", session.helper_model or "none"), ("custom endpoint", backend.custom_base_url() or "none"),
272
+ ("autonomy", wf.plan.autonomy if wf else "-"), ("token cap", f"{session.budget:,}" if session.budget else "none"),
273
+ ("preferences", str(backend.prefs_path())), ("project data", str(wf.store.root) if wf else "-")]
274
+ for k, v in rows:
275
+ grid.add_row(Text(k, style=theme.MUTED), Text(v, style=theme.WHITE))
276
+ ui.say(console, grid)
277
+ _say(console, "Change: /model [agent] <provider>:<model>, /endpoint <url>, /autonomy <level>, /set <setting> on|off, /budget <tokens>. Keys are never saved.")
278
+ console.print()
279
+ return session, True
280
+
281
+ if cmd == "/copy":
282
+ text = session.last_reply
283
+ if not text:
284
+ _say(console, "Nothing to copy yet.")
285
+ else:
286
+ how = copy_to_clipboard(text)
287
+ _say(console, f"Copied the last answer ({len(text):,} characters) with {how}." if how else "No clipboard here (install xclip or wl-copy).")
288
+ console.print()
289
+ return session, True
290
+
291
+ if cmd == "/export":
292
+ if wf is None:
293
+ return session, True
294
+ out = Path(rest.strip()).expanduser() if rest.strip() else session.cwd / f"pal-{wf.plan.name}-{time.strftime('%Y%m%d-%H%M')}.md"
295
+ if not out.is_absolute():
296
+ out = session.cwd / out
297
+ try:
298
+ out.parent.mkdir(parents=True, exist_ok=True)
299
+ out.write_text(conversation_markdown(wf), encoding="utf-8")
300
+ _say(console, f"Saved the conversation to {ui.short_path(out)}")
301
+ except OSError as e:
302
+ _say(console, f"Could not save: {e.strerror or e}", theme.RED_HI)
303
+ console.print()
304
+ return session, True
305
+
306
+ if cmd == "/agents":
307
+ from pal.terminal.session import AGENT_ABOUT
308
+ grid = Table.grid(padding=(0, 3))
309
+ for m in MODES:
310
+ prov, model = session.model_for(m)
311
+ grid.add_row(Text(m.capitalize(), style=f"bold {theme.RED_HI if m == session.mode else theme.WHITE}"), Text(f"{model} ({prov})", style=theme.WHITE),
312
+ Text(AGENT_ABOUT.get(m, ""), style=theme.MUTED))
313
+ if session.helper_model:
314
+ grid.add_row(Text("helper", style=theme.MUTED), Text(session.helper_model, style=theme.WHITE), Text("Pal's own small jobs: routing, trimming, checking", style=theme.MUTED))
315
+ ui.say(console, grid)
316
+ _say(console, "Give an agent its own model: /model plan <provider>:<model> tab switches agent")
317
+ console.print()
318
+ return session, True
319
+
320
+ if cmd == "/simplify":
321
+ if wf is None:
322
+ return session, True
323
+ from pal.terminal import flow_cli
324
+ from pal.terminal.workflow import is_git_repo
325
+ files: list[str] = []
326
+ if is_git_repo(session.cwd):
327
+ r = subprocess.run(["git", "diff", "--name-only", "HEAD~1", "HEAD"], cwd=session.cwd, capture_output=True, text=True) # noqa: S607
328
+ r2 = subprocess.run(["git", "diff", "--name-only"], cwd=session.cwd, capture_output=True, text=True) # noqa: S607
329
+ files = sorted({x for x in (r.stdout + r2.stdout).split() if (session.cwd / x).is_file()})
330
+ files = sorted(set(files) | set(wf.plan.b.pending))[:12]
331
+ if not files:
332
+ _say(console, "No recently changed files found (it looks at the last commit, uncommitted changes and the sandbox).")
333
+ console.print()
334
+ return session, True
335
+ mode = session.mode
336
+ session.mode = "build"
337
+ try:
338
+ flow_cli.run_turn(wf, "Review these recently changed files for reuse (duplicated code), quality (naming, structure, error handling) and efficiency, "
339
+ "then fix what is worth fixing without changing behaviour: " + " ".join(f"@{f}" for f in files) + (f". Focus: {rest}" if rest else ""), console)
340
+ finally:
341
+ session.mode = mode
342
+ return session, True
343
+
344
+ if cmd == "/status":
345
+ hw = backend.hardware()
346
+ grid = Table.grid(padding=(0, 3))
347
+ rows = [("pal", __version__), ("folder", str(session.cwd)), ("agent", session.mode), ("model", f"{session.model_for()[1]} ({session.model_for()[0]})"),
348
+ ("local", session.local_status or ("off (no GPU)" if not session.local else "-")), ("this computer", hw.summary())]
349
+ if wf is not None:
350
+ rows += [("plan", f"{wf.plan.name} / {wf.plan.branch}"), ("autonomy", wf.plan.autonomy), ("pending", f"{len(wf.plan.b.pending)} file(s)")]
351
+ rows += [("tokens", f"{session.pal_tokens:,} used, about {max(0, session.saved):,} saved")]
352
+ for k, v in rows:
353
+ grid.add_row(Text(k, style=theme.MUTED), Text(v, style=theme.WHITE))
354
+ ui.say(console, grid)
355
+ console.print()
356
+ return session, True
357
+
358
+ if cmd == "/doctor":
359
+ from pal.cli import main as tools_main
360
+ try:
361
+ tools_main(["doctor"])
362
+ except SystemExit:
363
+ pass
364
+ console.print()
365
+ return session, True
366
+
367
+ if cmd == "/resume":
368
+ if wf is None:
369
+ return session, True
370
+ if not rest:
371
+ names = wf.store.names()
372
+ for i, name in enumerate(names, 1):
373
+ b = wf.store.load(name).b
374
+ last = (b.convo[-1]["text"][:60] + "…") if b.convo else "no conversation yet"
375
+ ui.say(console, Text.assemble((f"{i:>3} {name:<16}", f"bold {theme.WHITE}" if name == wf.plan.name else theme.INK), (last, theme.MUTED)))
376
+ _say(console, "/resume <name or number> picks one up where it stopped")
377
+ console.print()
378
+ return session, True
379
+ from pal.terminal.cli import handle
380
+ return handle(f"/cd {rest}", args, session, console, core_factory, box)
381
+
382
+ if cmd == "/init":
383
+ if wf is None:
384
+ return session, True
385
+ from pal.terminal import flow_cli
386
+ mode = session.mode
387
+ session.mode = "build"
388
+ exists = (session.cwd / NOTES_FILE).is_file()
389
+ try:
390
+ flow_cli.run_turn(wf, (f"{'Improve' if exists else 'Write'} {NOTES_FILE} for this project: what it is, how it is laid out, how to run and test it, the "
391
+ "conventions the code follows, and anything a new developer (or Pal) must not get wrong. Use the project map, the documents "
392
+ f"and the files you can see. Short sections, bullet points, under 120 lines. Reply with the complete {NOTES_FILE} only."), console)
393
+ finally:
394
+ session.mode = mode
395
+ _say(console, f"Review it with /diff, write it with /apply. {NOTES_FILE} then goes with the first request of every session.")
396
+ console.print()
397
+ return session, True
398
+
399
+ if cmd == "/voice":
400
+ return None # the loop handles it (it needs the input box)
401
+ return None