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,624 @@
1
+ """Plans, context and the change sandbox: the Pal workflow.
2
+
3
+ 1 Chat (Ask agent) talk the task through until it is clear
4
+ 2 Plan (Plan agent) broad strokes, the files it needs (loaded into context automatically at "semi" and "full")
5
+ 3 Detailed plan numbered steps, kept with the plan
6
+ 4 Build (Build agent) each step implemented as pending changes in a sandbox: nothing touches your files yet
7
+ 5 Review /diff, /review (in the browser), /rewind, /reject
8
+ 6 Apply and debug /apply writes the changes, runs the checking commands you approve, rolls everything back if one fails,
9
+ and (auto-debug) asks the Build agent to fix it and tries again
10
+ 7 Commit in a git repository, a commit message is written and the changes are committed (auto-commit)
11
+
12
+ Everything is saved per project folder in ~/.pal/projects/<folder id>/: each plan has branches, and each branch keeps its conversation,
13
+ its context, its numbered steps and every version of the sandbox, so /rewind and /checkout can go back or try another way.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import time
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+
27
+ from pal.paths import pal_home
28
+ from pal.terminal.security import Finding, review_code
29
+ from pal.terminal.session import MAX_REF_BYTES, PalSession, Turn, read_any
30
+ from pal.tokens import estimate_tokens
31
+
32
+ AUTONOMY = { # what each level does by itself
33
+ "none": set(),
34
+ "basic": {"auto-continue"},
35
+ "plus": {"auto-continue", "can-exec", "auto-commit"},
36
+ "semi": {"auto-continue", "auto-load-context", "can-exec", "auto-commit"},
37
+ "full": {"auto-continue", "auto-load-context", "auto-apply", "can-exec", "auto-exec", "auto-debug", "auto-commit"},
38
+ }
39
+ SETTINGS = ("auto-continue", "auto-load-context", "auto-apply", "can-exec", "auto-exec", "auto-debug", "auto-commit")
40
+ SETTING_ABOUT = {
41
+ "auto-continue": "after a plan, Build implements every step in turn without waiting",
42
+ "auto-load-context": "files the Plan agent names are loaded into context",
43
+ "auto-apply": "pending changes are applied without asking",
44
+ "can-exec": "checking commands may be run (each one asks first)",
45
+ "auto-exec": "checking commands run without asking",
46
+ "auto-debug": "when a command fails, the Build agent is asked to fix it (up to 3 tries)",
47
+ "auto-commit": "in a git repository, applied changes are committed with a written message",
48
+ }
49
+ MAX_CONTEXT_FILES, MAX_STEPS, DEBUG_TRIES = 40, 12, 3
50
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.\-]{0,40}$")
51
+
52
+
53
+ @dataclass
54
+ class Version:
55
+ """One state of the sandbox: every pending file (path -> new content) and the commands to check it."""
56
+ id: str
57
+ label: str
58
+ t: float
59
+ pending: dict[str, str] = field(default_factory=dict)
60
+ commands: list[str] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class Branch:
65
+ name: str = "main"
66
+ convo: list[dict] = field(default_factory=list) # {"role": "you"|"pal", "agent", "text", "t"}
67
+ context: list[str] = field(default_factory=list) # project-relative paths, or absolute paths outside the folder
68
+ notes: list[str] = field(default_factory=list) # text loaded with /load -n
69
+ steps: list[str] = field(default_factory=list) # the detailed plan
70
+ done: int = 0 # how many steps are built
71
+ versions: list[Version] = field(default_factory=list)
72
+ applied: list[dict] = field(default_factory=list) # what /apply wrote: {"t", "files", "commit"}
73
+
74
+ @property
75
+ def pending(self) -> dict[str, str]:
76
+ return dict(self.versions[-1].pending) if self.versions else {}
77
+
78
+ @property
79
+ def commands(self) -> list[str]:
80
+ return list(self.versions[-1].commands) if self.versions else []
81
+
82
+
83
+ @dataclass
84
+ class Plan:
85
+ name: str
86
+ created: float = field(default_factory=time.time)
87
+ branch: str = "main"
88
+ autonomy: str = "semi"
89
+ custom: dict[str, bool] = field(default_factory=dict)
90
+ branches: dict[str, Branch] = field(default_factory=lambda: {"main": Branch()})
91
+
92
+ @property
93
+ def b(self) -> Branch:
94
+ return self.branches[self.branch]
95
+
96
+ def setting(self, name: str) -> bool:
97
+ return self.custom.get(name, name in AUTONOMY.get(self.autonomy, set()))
98
+
99
+
100
+ def pid_alive(pid: int) -> bool:
101
+ """Is that process still running? (Never sends it a signal: on Windows os.kill would end it.)"""
102
+ if os.name == "nt":
103
+ import ctypes
104
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
105
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
106
+ if not handle:
107
+ return False
108
+ code = ctypes.c_ulong()
109
+ try:
110
+ return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
111
+ finally:
112
+ kernel.CloseHandle(handle)
113
+ try:
114
+ os.kill(pid, 0)
115
+ except ProcessLookupError:
116
+ return False
117
+ except PermissionError: # alive, owned by someone else
118
+ return True
119
+ except OSError:
120
+ return False
121
+ return True
122
+
123
+
124
+ def project_id(folder: Path) -> str:
125
+ folder = folder.resolve()
126
+ return f"{folder.name[:24]}-{hashlib.sha1(str(folder).encode('utf-8')).hexdigest()[:10]}" # noqa: S324 (a folder id, not security)
127
+
128
+
129
+ class Store:
130
+ """Plans for one project folder, saved as JSON."""
131
+
132
+ def __init__(self, folder: Path, home: Path | None = None):
133
+ self.folder = Path(folder).resolve()
134
+ self.root = (home or pal_home()) / "projects" / project_id(self.folder)
135
+ self.root.mkdir(parents=True, exist_ok=True)
136
+
137
+ def _path(self, name: str) -> Path:
138
+ return self.root / f"{name}.plan.json"
139
+
140
+ def names(self) -> list[str]:
141
+ return sorted(p.name[: -len(".plan.json")] for p in self.root.glob("*.plan.json"))
142
+
143
+ def load(self, name: str) -> Plan:
144
+ raw = json.loads(self._path(name).read_text(encoding="utf-8"))
145
+ branches = {k: Branch(**{**v, "versions": [Version(**x) for x in v.get("versions", [])]}) for k, v in raw.pop("branches").items()}
146
+ return Plan(**raw, branches=branches)
147
+
148
+ def save(self, plan: Plan) -> None:
149
+ data = asdict(plan)
150
+ tmp = self._path(plan.name).with_suffix(".tmp")
151
+ tmp.write_text(json.dumps(data, indent=1), encoding="utf-8")
152
+ tmp.replace(self._path(plan.name))
153
+ (self.root / "current").write_text(plan.name, encoding="utf-8")
154
+
155
+ def current_name(self) -> str | None:
156
+ f = self.root / "current"
157
+ name = f.read_text(encoding="utf-8").strip() if f.is_file() else ""
158
+ return name if name in self.names() else None
159
+
160
+ def delete(self, name: str) -> None:
161
+ self._path(name).unlink(missing_ok=True)
162
+ self.release(name)
163
+
164
+ # -- one terminal per plan: a second one would overwrite the first one's changes ---------------------------------------
165
+ def _lock(self, name: str) -> Path:
166
+ return self.root / f"{name}.lock"
167
+
168
+ def acquire(self, name: str) -> int | None:
169
+ """Mark the plan as open in this process. Returns the process id of another live Pal that has it open (the plan is then not taken),
170
+ or None when it is ours now. A lock left by a Pal that has exited is taken over."""
171
+ f = self._lock(name)
172
+ try:
173
+ pid = int(f.read_text(encoding="utf-8").split()[0]) if f.is_file() else 0
174
+ except (OSError, ValueError, IndexError):
175
+ pid = 0
176
+ if pid and pid != os.getpid() and pid_alive(pid):
177
+ return pid
178
+ try:
179
+ f.write_text(f"{os.getpid()} {time.time():.0f}", encoding="utf-8")
180
+ except OSError:
181
+ pass
182
+ return None
183
+
184
+ def release(self, name: str) -> None:
185
+ f = self._lock(name)
186
+ try:
187
+ if f.is_file() and f.read_text(encoding="utf-8").split()[0] == str(os.getpid()):
188
+ f.unlink()
189
+ except (OSError, IndexError):
190
+ pass
191
+
192
+ def new_name(self) -> str:
193
+ n = 1
194
+ while f"plan-{n}" in self.names():
195
+ n += 1
196
+ return f"plan-{n}"
197
+
198
+
199
+ @dataclass
200
+ class ApplyResult:
201
+ written: list[str] = field(default_factory=list)
202
+ failed_command: str = ""
203
+ output: str = ""
204
+ rolled_back: bool = False
205
+ commit: str = ""
206
+ message: str = ""
207
+
208
+
209
+ class Workflow:
210
+ """The current plan and branch, tied to a session: context goes with every request, Build's changes go to the sandbox."""
211
+
212
+ def __init__(self, session: PalSession, store: Store | None = None, plan: str | None = None):
213
+ self.session, self.store = session, store or Store(session.cwd)
214
+ self.notice = "" # said once when the session opens (a plan open in another terminal)
215
+ name = plan or self.store.current_name()
216
+ other = self.store.acquire(name) if name else None
217
+ if other:
218
+ fresh = self.store.new_name()
219
+ self.notice = (f"Plan {name} is open in another Pal (process {other}), so this one started a new plan, {fresh}, "
220
+ "and the two cannot overwrite each other. /cd to it after closing the other.")
221
+ name = None
222
+ self.store.acquire(fresh)
223
+ self.plan = Plan(fresh)
224
+ else:
225
+ self.plan = self.store.load(name) if name else Plan(self.store.new_name())
226
+ self.store.acquire(self.plan.name)
227
+ self.store.save(self.plan)
228
+ self._attach()
229
+
230
+ def close(self) -> None:
231
+ """Let other terminals open this plan."""
232
+ self.store.release(self.plan.name)
233
+
234
+ def _take(self, name: str) -> None:
235
+ other = self.store.acquire(name)
236
+ if other:
237
+ raise ValueError(f"plan {name} is open in another Pal (process {other}): close that one first, or /new")
238
+ if self.plan.name != name:
239
+ self.store.release(self.plan.name)
240
+
241
+ # -- wiring -------------------------------------------------------------------------------------------------------------
242
+ def _attach(self) -> None:
243
+ s = self.session
244
+ s.context = self.context_refs
245
+ s.overlay = self.plan.b.pending
246
+ if self.plan.b.convo: # resuming: the model gets the earlier conversation once, compactly
247
+ s.preamble = self.recap()
248
+
249
+ def save(self) -> None:
250
+ self.session.overlay = self.plan.b.pending
251
+ self.store.save(self.plan)
252
+
253
+ def recap(self, limit: int = 12) -> str:
254
+ lines = [f"{m['role']}: {m['text'][:600]}" for m in self.plan.b.convo[-limit:]]
255
+ steps = "\n".join(f"{i}. {t}" for i, t in enumerate(self.plan.b.steps, 1))
256
+ return ("Earlier in this plan (for context, do not answer it again):\n" + "\n".join(lines)
257
+ + (f"\nThe plan's steps ({self.plan.b.done} built):\n{steps}" if steps else ""))
258
+
259
+ # -- plans and branches ----------------------------------------------------------------------------------------------------
260
+ def new(self, name: str = "") -> str:
261
+ name = name or self.store.new_name()
262
+ if not _NAME.match(name):
263
+ raise ValueError("a plan name is letters, digits, dot, dash or underscore (up to 40)")
264
+ if name in self.store.names():
265
+ raise ValueError(f"there is already a plan called {name}")
266
+ self._take(name)
267
+ self.plan = Plan(name)
268
+ self.session.preamble = ""
269
+ self.store.save(self.plan)
270
+ self._attach()
271
+ return name
272
+
273
+ def switch(self, which: str) -> str:
274
+ names = self.store.names()
275
+ name = names[int(which) - 1] if which.isdigit() and 0 < int(which) <= len(names) else which
276
+ if name not in names:
277
+ raise ValueError(f"no plan called {which}")
278
+ self._take(name)
279
+ self.plan = self.store.load(name)
280
+ self.store.save(self.plan)
281
+ self._attach()
282
+ return name
283
+
284
+ def rename(self, new: str) -> None:
285
+ if not _NAME.match(new) or new in self.store.names():
286
+ raise ValueError("pick another name (letters, digits, dot, dash, underscore; not already used)")
287
+ old = self.plan.name
288
+ self.store.acquire(new)
289
+ self.plan.name = new
290
+ self.store.save(self.plan)
291
+ self.store.delete(old)
292
+
293
+ def delete(self, name: str) -> None:
294
+ if name not in self.store.names():
295
+ raise ValueError(f"no plan called {name}")
296
+ if name != self.plan.name and (other := self.store.acquire(name)):
297
+ raise ValueError(f"plan {name} is open in another Pal (process {other})")
298
+ self.store.delete(name)
299
+ if name == self.plan.name:
300
+ others = [n for n in self.store.names() if not self.store.acquire(n)]
301
+ self.plan = self.store.load(others[0]) if others else Plan(self.store.new_name())
302
+ self.store.acquire(self.plan.name)
303
+ for n in others[1:]:
304
+ self.store.release(n)
305
+ self.store.save(self.plan)
306
+ self._attach()
307
+
308
+ def checkout(self, name: str) -> bool:
309
+ """Switch to a branch; a new one starts as a copy of the current branch. True when it was created."""
310
+ if not _NAME.match(name):
311
+ raise ValueError("a branch name is letters, digits, dot, dash or underscore")
312
+ created = name not in self.plan.branches
313
+ if created:
314
+ self.plan.branches[name] = Branch(**json.loads(json.dumps(asdict(self.plan.b), default=str)) | {"name": name})
315
+ self.plan.branches[name].versions = [Version(**v) if isinstance(v, dict) else v for v in self.plan.branches[name].versions]
316
+ self.plan.branch = name
317
+ self.save()
318
+ return created
319
+
320
+ def delete_branch(self, name: str) -> None:
321
+ if name == "main" or name not in self.plan.branches:
322
+ raise ValueError("pick a branch other than main that exists")
323
+ if self.plan.branch == name:
324
+ self.plan.branch = "main"
325
+ del self.plan.branches[name]
326
+ self.save()
327
+
328
+ # -- context ------------------------------------------------------------------------------------------------------------------
329
+ def _resolve(self, raw: str) -> list[Path]:
330
+ root = self.session.cwd
331
+ raw = raw.strip().strip("\"'")
332
+ p = (root / raw) if not Path(raw).expanduser().is_absolute() else Path(raw).expanduser()
333
+ if any(c in raw for c in "*?["):
334
+ return sorted(x for x in root.glob(raw) if x.is_file())
335
+ if p.is_dir():
336
+ from pal.terminal.session import SKIP_DIRS
337
+ return sorted(x for x in p.rglob("*") if x.is_file() and not any(part in SKIP_DIRS or part.startswith(".") for part in x.relative_to(p).parts))
338
+ return [p] if p.is_file() else []
339
+
340
+ def _key(self, p: Path) -> str:
341
+ try:
342
+ return p.resolve().relative_to(self.session.cwd).as_posix()
343
+ except ValueError:
344
+ return str(p.resolve())
345
+
346
+ def load(self, items: list[str], note: str = "") -> tuple[list[str], list[str]]:
347
+ """Add files, folders or globs (and an optional note) to the context. (added, problems)."""
348
+ added, problems = [], []
349
+ if note:
350
+ self.plan.b.notes.append(note[:4000])
351
+ added.append("note")
352
+ for raw in items:
353
+ found = self._resolve(raw)
354
+ if not found:
355
+ problems.append(f"{raw}: nothing found")
356
+ for p in found:
357
+ key = self._key(p)
358
+ if key in self.plan.b.context:
359
+ continue
360
+ if len(self.plan.b.context) >= MAX_CONTEXT_FILES:
361
+ problems.append(f"context is full ({MAX_CONTEXT_FILES} files): /rm something first")
362
+ break
363
+ self.plan.b.context.append(key)
364
+ added.append(key)
365
+ self.save()
366
+ return added, problems
367
+
368
+ def remove(self, items: list[str]) -> list[str]:
369
+ """Remove context by number (from /ls), range (2-4), name or glob."""
370
+ ctx = self.plan.b.context
371
+ drop: set[str] = set()
372
+ for raw in items:
373
+ m = re.fullmatch(r"(\d+)(?:-(\d+))?", raw)
374
+ if m:
375
+ a, b = int(m.group(1)), int(m.group(2) or m.group(1))
376
+ drop |= {ctx[i - 1] for i in range(a, b + 1) if 0 < i <= len(ctx)}
377
+ else:
378
+ from fnmatch import fnmatch
379
+ drop |= {c for c in ctx if c == raw or fnmatch(c, raw)}
380
+ self.plan.b.context = [c for c in ctx if c not in drop]
381
+ self.save()
382
+ return sorted(drop)
383
+
384
+ def clear(self) -> int:
385
+ n = len(self.plan.b.context) + len(self.plan.b.notes)
386
+ self.plan.b.context, self.plan.b.notes = [], []
387
+ self.save()
388
+ return n
389
+
390
+ def context_rows(self) -> list[tuple[int, str, int, str]]:
391
+ """(number, name, tokens, note) for /ls."""
392
+ rows = []
393
+ for i, key in enumerate(self.plan.b.context, 1):
394
+ p = Path(key) if Path(key).is_absolute() else self.session.cwd / key
395
+ text, problem = read_any(p) if p.is_file() else (None, "missing")
396
+ rows.append((i, key, estimate_tokens(text or ""), problem or ""))
397
+ for j, note in enumerate(self.plan.b.notes, len(rows) + 1):
398
+ rows.append((j, f"note: {note[:40]}", estimate_tokens(note), ""))
399
+ return rows
400
+
401
+ def context_refs(self) -> list[dict]:
402
+ """What goes with every request: the loaded files as they are now (with pending changes on top), secrets hidden, and the notes."""
403
+ from pal.terminal.security import guard_outgoing
404
+ refs, pending = [], self.plan.b.pending
405
+ text: str | None
406
+ for key in self.plan.b.context:
407
+ if key in pending:
408
+ text = pending[key]
409
+ else:
410
+ p = Path(key) if Path(key).is_absolute() else self.session.cwd / key
411
+ text, _ = read_any(p) if p.is_file() else (None, None)
412
+ if text is None:
413
+ continue
414
+ safe, _ = guard_outgoing(key, text)
415
+ if safe is not None and len(safe.encode("utf-8")) <= MAX_REF_BYTES:
416
+ refs.append({"name": key, "text": safe})
417
+ if self.plan.b.notes:
418
+ refs.append({"name": "notes.md", "text": "\n\n".join(self.plan.b.notes)})
419
+ return refs
420
+
421
+ # -- talking ------------------------------------------------------------------------------------------------------------------
422
+ def tell(self, text: str) -> Turn:
423
+ """One request with the session's current agent. Plan replies become the plan's steps; Build replies become a sandbox version."""
424
+ turn = self.session.ask(text)
425
+ b = self.plan.b
426
+ b.convo += [{"role": "you", "agent": self.session.mode, "text": text, "t": time.time()},
427
+ {"role": "pal", "agent": self.session.mode, "text": turn.text[:4000], "t": time.time()}]
428
+ if self.session.mode == "plan":
429
+ steps = parse_steps(turn.text)
430
+ if steps:
431
+ b.steps, b.done = steps[:MAX_STEPS], 0
432
+ if self.plan.setting("auto-load-context"):
433
+ named = [x for x in mentioned_files(turn.text, self.session.cwd) if x not in b.context]
434
+ if named:
435
+ self.load(named)
436
+ turn.warnings.append("loaded into context: " + ", ".join(named))
437
+ if self.session.mode == "build" and (turn.changes or turn.commands):
438
+ self.add_version(turn, label=text[:80])
439
+ self.save()
440
+ return turn
441
+
442
+ def add_version(self, turn: Turn, label: str) -> Version:
443
+ pending = self.plan.b.pending
444
+ for c in turn.changes:
445
+ pending[c.path] = c.new
446
+ commands = turn.commands or self.plan.b.commands
447
+ v = Version(hashlib.sha1(f"{time.time()}{label}".encode()).hexdigest()[:7], label, time.time(), pending, commands) # noqa: S324
448
+ self.plan.b.versions.append(v)
449
+ self.save()
450
+ return v
451
+
452
+ def next_step(self) -> tuple[int, str] | None:
453
+ b = self.plan.b
454
+ return (b.done + 1, b.steps[b.done]) if b.done < len(b.steps) else None
455
+
456
+ def build_step(self) -> Turn | None:
457
+ """/continue: implement the next step of the plan in the sandbox."""
458
+ nxt = self.next_step()
459
+ if nxt is None:
460
+ return None
461
+ i, step = nxt
462
+ mode = self.session.mode
463
+ self.session.mode = "build"
464
+ try:
465
+ turn = self.tell(f"Implement step {i} of {len(self.plan.b.steps)} of the plan: {step}. Only this step; keep everything else as it is.")
466
+ finally:
467
+ self.session.mode = mode
468
+ self.plan.b.done = i
469
+ self.save()
470
+ return turn
471
+
472
+ # -- the sandbox ----------------------------------------------------------------------------------------------------------------
473
+ def disk(self, path: str) -> str | None:
474
+ p = self.session.cwd / path
475
+ try:
476
+ return p.read_text(encoding="utf-8") if p.is_file() else None
477
+ except (OSError, UnicodeDecodeError):
478
+ return None
479
+
480
+ def diffs(self) -> list[tuple[str, bool, list[list[str]], list[Finding]]]:
481
+ from pal.terminal.session import diff_lines
482
+ out = []
483
+ for path, new in sorted(self.plan.b.pending.items()):
484
+ old = self.disk(path)
485
+ out.append((path, old is None, diff_lines(old or "", new), review_code(path, new)))
486
+ return out
487
+
488
+ def reject(self, paths: list[str] | None = None) -> list[str]:
489
+ """Drop pending changes for some files (or all). Recorded as a new version, so /rewind can bring them back."""
490
+ pending = self.plan.b.pending
491
+ gone = sorted(pending) if not paths else sorted(p for p in paths if p in pending)
492
+ for p in gone:
493
+ pending.pop(p)
494
+ if gone:
495
+ self.plan.b.versions.append(Version(hashlib.sha1(f"{time.time()}reject".encode()).hexdigest()[:7], "rejected " + ", ".join(gone)[:70], time.time(), # noqa: S324
496
+ pending, self.plan.b.commands if pending else []))
497
+ self.save()
498
+ return gone
499
+
500
+ def rewind(self, steps: int = 1, to: str = "") -> int:
501
+ """Go back n versions, or to a version id from /log. Returns how many were undone."""
502
+ vs = self.plan.b.versions
503
+ if to:
504
+ idx = next((i for i, v in enumerate(vs) if v.id.startswith(to)), None)
505
+ if idx is None:
506
+ raise ValueError(f"no version {to}: see /log")
507
+ n = len(vs) - 1 - idx
508
+ else:
509
+ n = min(max(steps, 1), len(vs))
510
+ del vs[len(vs) - n:]
511
+ self.save()
512
+ return n
513
+
514
+ def log(self) -> list[Version]:
515
+ return list(self.plan.b.versions)
516
+
517
+ # -- apply, check, commit -------------------------------------------------------------------------------------------------------
518
+ def apply(self, run_command, debug=None, commit_message=None) -> ApplyResult:
519
+ """Write the pending changes, run the checking commands (`run_command(cmd) -> (ok, output)`, which asks the person when it must),
520
+ and roll every file back if one fails. With `debug` (auto-debug), the Build agent is asked to fix the failure and it is tried again."""
521
+ res = ApplyResult()
522
+ for attempt in range(DEBUG_TRIES + 1):
523
+ pending, commands = self.plan.b.pending, self.plan.b.commands
524
+ if not pending:
525
+ res.message = "nothing pending"
526
+ return res
527
+ snapshot = {p: self.disk(p) for p in pending}
528
+ written = self.session.apply([_change(p, snapshot[p], new) for p, new in pending.items()])
529
+ res.written = [p for p, status in written if status == "written"]
530
+ failed, output = "", ""
531
+ if self.plan.setting("can-exec"):
532
+ for cmd in commands:
533
+ ok, output = run_command(cmd)
534
+ if ok is None: # the person declined: the changes stay applied, unchecked
535
+ continue
536
+ if not ok:
537
+ failed = cmd
538
+ break
539
+ if not failed:
540
+ break
541
+ self._roll_back(snapshot)
542
+ res.failed_command, res.output, res.rolled_back = failed, output[-4000:], True
543
+ if not debug or attempt == DEBUG_TRIES:
544
+ return res
545
+ fixed = debug(failed, output[-3000:])
546
+ if not fixed:
547
+ return res
548
+ self.plan.b.applied.append({"t": time.time(), "files": res.written, "commit": ""})
549
+ self.plan.b.versions.append(Version(hashlib.sha1(f"{time.time()}applied".encode()).hexdigest()[:7], "applied " + ", ".join(res.written)[:70], time.time(), {}, [])) # noqa: S324
550
+ res.failed_command, res.rolled_back = "", False
551
+ if self.plan.setting("auto-commit") and commit_message and is_git_repo(self.session.cwd):
552
+ res.commit = git_commit(self.session.cwd, res.written, commit_message(res.written))
553
+ self.plan.b.applied[-1]["commit"] = res.commit
554
+ self.save()
555
+ return res
556
+
557
+ def _roll_back(self, snapshot: dict[str, str | None]) -> None:
558
+ for path, old in snapshot.items():
559
+ p = self.session.cwd / path
560
+ if old is None:
561
+ p.unlink(missing_ok=True)
562
+ else:
563
+ p.write_text(old, encoding="utf-8", newline="")
564
+
565
+
566
+ def _change(path: str, old: str | None, new: str):
567
+ from pal.terminal.session import Change, diff_lines
568
+ return Change(path, old or "", new, old is None, diff_lines(old or "", new), review_code(path, new))
569
+
570
+
571
+ def parse_steps(text: str) -> list[str]:
572
+ """Numbered lines (1. / 1) / Step 1:) from a plan, in order."""
573
+ steps = []
574
+ for line in text.splitlines():
575
+ m = re.match(r"^\s*(?:step\s*)?(\d{1,2})[.):]\s+(.+)$", line, re.I)
576
+ if m:
577
+ steps.append(re.sub(r"\*\*|`", "", m.group(2)).strip())
578
+ return steps
579
+
580
+
581
+ def mentioned_files(text: str, root: Path) -> list[str]:
582
+ """Paths in the text that are files in the project (what a plan says it needs)."""
583
+ found = []
584
+ for m in re.finditer(r"(?<![\w/.-])((?:[\w.-]+/)*[\w.-]+\.[A-Za-z0-9]{1,6})(?![\w/])", text):
585
+ rel = m.group(1).strip("`'\".,;:()")
586
+ p = (root / rel)
587
+ try:
588
+ inside = root.resolve() in p.resolve().parents
589
+ except OSError:
590
+ inside = False
591
+ if inside and p.is_file() and rel not in found:
592
+ found.append(rel)
593
+ return found[:12]
594
+
595
+
596
+ def is_git_repo(folder: Path) -> bool:
597
+ return shutil.which("git") is not None and (folder / ".git").exists()
598
+
599
+
600
+ def git_commit(folder: Path, files: list[str], message: str) -> str:
601
+ """Commit just these files. Returns the short commit id, or '' if git refused."""
602
+ git = shutil.which("git")
603
+ if not git or not files:
604
+ return ""
605
+ try:
606
+ subprocess.run([git, "-C", str(folder), "add", "--", *files], check=True, capture_output=True, timeout=30)
607
+ r = subprocess.run([git, "-C", str(folder), "commit", "-m", message, "--", *files], capture_output=True, text=True, timeout=30)
608
+ if r.returncode != 0:
609
+ return ""
610
+ return subprocess.run([git, "-C", str(folder), "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=10).stdout.strip()
611
+ except (OSError, subprocess.SubprocessError):
612
+ return ""
613
+
614
+
615
+ def run_shell(cmd: str, cwd: Path, timeout: int = 300) -> tuple[bool, str]:
616
+ """Run one approved command in the project folder. The person has seen and accepted the exact text."""
617
+ try:
618
+ env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1"} # a file Pal just rewrote must not be run from a stale .pyc of the same size and second
619
+ r = subprocess.run(cmd, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=timeout, env=env) # noqa: S602 (shown to and approved by the person)
620
+ return r.returncode == 0, (r.stdout + r.stderr)
621
+ except subprocess.TimeoutExpired:
622
+ return False, f"timed out after {timeout}s"
623
+ except OSError as e:
624
+ return False, str(e)
@@ -0,0 +1,3 @@
1
+ from pal.token_tracker.pipeline import SOURCES, TokenTracker
2
+
3
+ __all__ = ["SOURCES", "TokenTracker"]
@@ -0,0 +1,54 @@
1
+ """The Token Tracker: counts tokens and keeps the books on them.
2
+
3
+ * COUNTING `count()` is the one way to count tokens: a quick estimate (characters / 4), or the provider's own counter when the
4
+ settings ask for it (`counter_for`).
5
+ * SAVINGS each part that saves tokens (the input savers, history paging, the knowledge graph, document excerpts) credits its saving
6
+ to the turn through `credit()`, instead of every part reaching into the turn's record itself.
7
+ * SUMMARY `summarize()` adds a list of turns up: tokens billed, tokens saved by which part, what the API calls cost, what the savings
8
+ would have cost.
9
+ All saved-token figures are estimates: what a naive client would have sent minus what was sent.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterable
14
+
15
+ from pal.tokens import estimate_tokens, make_counter
16
+
17
+ # the name each part credits under -> the field on the turn's record
18
+ SOURCES = {"input_savers": "saved_savers", "context_paging": "saved_paging", "code_graph": "saved_graph", "documents": "saved_documents"}
19
+
20
+
21
+ class TokenTracker:
22
+ def __init__(self, counter: Callable[[str], int] = estimate_tokens):
23
+ self.counter = counter
24
+
25
+ def count(self, text: str) -> int:
26
+ return self.counter(text)
27
+
28
+ @staticmethod
29
+ def counter_for(mode: str, core=None) -> Callable[[str], int]:
30
+ """The counter the settings ask for: the provider's own (`provider`), or the quick estimate."""
31
+ return make_counter(mode, core)
32
+
33
+ @staticmethod
34
+ def credit(turn, source: str, tokens: int) -> None:
35
+ """Record that `source` saved `tokens` on this turn. Unknown sources are an error: a typo must not silently lose a saving."""
36
+ if source not in SOURCES:
37
+ raise KeyError(f"unknown saving source {source!r} (known: {', '.join(SOURCES)})")
38
+ setattr(turn, SOURCES[source], max(0, int(tokens)))
39
+
40
+ @staticmethod
41
+ def summarize(turns: Iterable) -> dict:
42
+ turns = list(turns)
43
+ saved = {name: sum(getattr(t, field) for t in turns) for name, field in SOURCES.items()}
44
+ saved["total"] = sum(t.saved_total for t in turns)
45
+ return {
46
+ "api_input_tokens": sum(t.input_tokens for t in turns),
47
+ "api_output_tokens": sum(t.output_tokens for t in turns),
48
+ "local_llm_tokens": sum(t.local_tokens for t in turns),
49
+ "tokens_saved_estimate": saved,
50
+ "compressed_turns": sum(t.compressed_turns for t in turns),
51
+ "compression_saved": sum(t.compression_saved for t in turns),
52
+ "cost_usd": round(sum(t.cost_usd for t in turns), 6),
53
+ "saved_cost_usd": round(sum(t.saved_cost_usd for t in turns), 6),
54
+ }