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,155 @@
1
+ """Per-session document index on top of the SDK: full text goes to the storage manager's files,
2
+ chunks go to its vector store. Later turns retrieve only the chunks that match the question."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass, field
9
+
10
+ from pal.config import DocumentsConfig
11
+ from pal.documents.components.chunker import Chunk, chunk_markdown
12
+ from pal.errors import NotFoundError
13
+ from pal.paths import safe_id
14
+ from pal.tokens import estimate_tokens
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class DocInfo:
21
+ name: str
22
+ slug: str
23
+ tokens: int
24
+ parts: int
25
+ outline: list[str] = field(default_factory=list)
26
+ inline: bool = True # small enough to send in full on the upload turn
27
+
28
+
29
+ @dataclass
30
+ class Hit:
31
+ doc: str
32
+ part: int # 1-based
33
+ parts: int
34
+ heading: str
35
+ text: str
36
+ score: float | None
37
+
38
+
39
+ def _slug(name: str) -> str:
40
+ return f"{safe_id(name.rsplit('.', 1)[0], default='doc')[:32]}-{hashlib.sha1(name.encode()).hexdigest()[:6]}" # noqa: S324
41
+
42
+
43
+ class DocumentIndex:
44
+ def __init__(self, sdk, cfg: DocumentsConfig | dict | None = None, count: Callable[[str], int] = estimate_tokens):
45
+ self.sdk = sdk
46
+ self.cfg = DocumentsConfig.coerce(cfg)
47
+ self.count = count
48
+ self.docs: dict[str, DocInfo] = {}
49
+
50
+ # -- indexing --------------------------------------------------------------------------
51
+ def add(self, name: str, text: str) -> DocInfo:
52
+ chunks = chunk_markdown(text, self.cfg.chunk_tokens, self.count)
53
+ slug = _slug(name)
54
+ total = sum(c.tokens for c in chunks)
55
+ self.sdk.write(f"docs/{slug}.json", text)
56
+ for c in chunks:
57
+ self.sdk.add_knowledge(f"{slug}:{c.index:04d}", c.text,
58
+ {"doc": name, "part": c.index + 1, "parts": len(chunks), "heading": c.heading})
59
+ outline: list[str] = []
60
+ for c in chunks:
61
+ if c.heading and (not outline or not outline[-1].endswith(f" {c.heading}")):
62
+ outline.append(f"{c.index + 1} {c.heading}")
63
+ info = DocInfo(name=name, slug=slug, tokens=total, parts=len(chunks), outline=outline[:25],
64
+ inline=total <= self.cfg.inline_max_tokens)
65
+ self.docs[name] = info
66
+ log.debug("indexed %s: %d tokens, %d parts", name, total, len(chunks))
67
+ return info
68
+
69
+ @property
70
+ def total_tokens(self) -> int:
71
+ return sum(d.tokens for d in self.docs.values())
72
+
73
+ def _chunks(self, info: DocInfo) -> list[Chunk]:
74
+ text = self.sdk.read(f"docs/{info.slug}.json")
75
+ return chunk_markdown(text, self.cfg.chunk_tokens, self.count) if isinstance(text, str) else []
76
+
77
+ # -- what the API model sees on the upload turn -----------------------------------------
78
+ def upload_view(self, name: str, text: str, tools: bool) -> str:
79
+ """Small documents in full. Large ones as a preview plus an outline."""
80
+ info = self.docs[name]
81
+ if info.inline:
82
+ return text
83
+ chunks = self._chunks(info)
84
+ taken: list[str] = []
85
+ used = 0
86
+ for c in chunks:
87
+ if taken and used + c.tokens > self.cfg.preview_tokens:
88
+ break
89
+ taken.append(c.text)
90
+ used += c.tokens
91
+ rest = info.tokens - used
92
+ how = ("Use search_documents(query) or read_document(name, part) to read more."
93
+ if tools else "The parts relevant to each question are attached to it.")
94
+ outline = ("Outline: " + "; ".join(info.outline) + ". ") if info.outline else ""
95
+ return "\n\n".join(taken) + f"\n\n[Preview only. {rest:,} more tokens in {info.parts} parts. {outline}{how}]"
96
+
97
+ def stub(self, name: str) -> str:
98
+ """What the history keeps instead of the file body."""
99
+ i = self.docs[name]
100
+ return f"[file: {name} | {i.tokens:,} tokens | indexed; relevant parts are attached to each question]"
101
+
102
+ # -- retrieval ------------------------------------------------------------------------------------
103
+ def search(self, query: str, top_k: int | None = None, exclude: set[str] | None = None) -> list[Hit]:
104
+ if not self.docs or not query.strip():
105
+ return []
106
+ k = top_k or self.cfg.retrieve_chunks
107
+ hits = []
108
+ for r in self.sdk.search_knowledge(query, top_k=k * 3):
109
+ score = r.get("score")
110
+ if score is not None and score < self.cfg.min_score:
111
+ continue
112
+ m = r.get("metadata") or {}
113
+ if m.get("doc") not in self.docs or m.get("doc") in (exclude or ()):
114
+ continue
115
+ hits.append(Hit(m["doc"], int(m.get("part", 1)), int(m.get("parts", 1)), m.get("heading", ""),
116
+ r["text"], score))
117
+ return hits[:k]
118
+
119
+ def excerpts(self, query: str, budget: int | None = None, exclude: set[str] | None = None) -> tuple[str, int, int]:
120
+ """(text block, tokens used, number of chunks). Empty when nothing is relevant enough."""
121
+ budget = budget or self.cfg.budget_tokens
122
+ blocks, used = [], 0
123
+ for h in self.search(query, exclude=exclude):
124
+ body = f"[{h.doc} part {h.part}/{h.parts}{': ' + h.heading if h.heading else ''}]\n{h.text}"
125
+ cost = self.count(body)
126
+ if used + cost > budget:
127
+ continue
128
+ blocks.append(body)
129
+ used += cost
130
+ if not blocks:
131
+ return "", 0, 0
132
+ return "<documents>\n" + "\n\n".join(blocks) + "\n</documents>", used, len(blocks)
133
+
134
+ def outline_text(self, name: str) -> str:
135
+ """Every part of a document with its heading and size, so a model can decide which part to read."""
136
+ match = next((n for n in self.docs if n.lower() == name.strip().lower()), None)
137
+ if match is None:
138
+ return f"no document named {name!r}; documents: {', '.join(self.docs) or 'none'}"
139
+ info = self.docs[match]
140
+ lines = [f"{info.name}: {info.tokens:,} tokens in {info.parts} parts"]
141
+ for c in self._chunks(info)[:60]:
142
+ lines.append(f"part {c.index + 1} ({c.tokens} tokens): {c.heading or '(no heading)'}")
143
+ if info.parts > 60:
144
+ lines.append(f"[{info.parts - 60} more parts not shown]")
145
+ return "\n".join(lines)
146
+
147
+ def read_part(self, name: str, part: int) -> str:
148
+ info = self.docs.get(name) or next((d for n, d in self.docs.items() if n.lower() == name.lower()), None)
149
+ if info is None:
150
+ raise NotFoundError(f"no document named {name!r}. Available: {', '.join(self.docs) or 'none'}")
151
+ chunks = self._chunks(info)
152
+ if not 1 <= part <= len(chunks):
153
+ raise NotFoundError(f"{info.name} has parts 1 to {len(chunks)}")
154
+ c = chunks[part - 1]
155
+ return f"[{info.name} part {part}/{len(chunks)}{': ' + c.heading if c.heading else ''}]\n{c.text}"
@@ -0,0 +1 @@
1
+
pal/errors.py ADDED
@@ -0,0 +1,40 @@
1
+ """Exception hierarchy. Everything Pal raises on purpose derives from PalError, so the
2
+ web layer can map it to an HTTP status without string matching and never leaks a traceback."""
3
+ from __future__ import annotations
4
+
5
+
6
+ class PalError(Exception):
7
+ """Base class. `http_status` is what the web layer answers with; `public` says whether the
8
+ message is safe to show the user as-is."""
9
+
10
+ http_status = 500
11
+ public = False
12
+
13
+
14
+ class UserInputError(PalError, ValueError):
15
+ """The request was wrong: bad file, empty message, unknown option. Message is user-safe."""
16
+
17
+ http_status = 400
18
+ public = True
19
+
20
+
21
+ class ConfigError(PalError, ValueError):
22
+ """Settings are invalid. Raised at startup, so a bad config fails fast and clearly."""
23
+
24
+ http_status = 500
25
+ public = True
26
+
27
+
28
+ class ProviderError(PalError):
29
+ """The API model or the local model failed (network, auth, rate limit, bad response)."""
30
+
31
+ http_status = 502
32
+ public = True
33
+
34
+
35
+ class DependencyMissingError(UserInputError):
36
+ """An optional dependency or system tool (pandoc, graphify) is not installed."""
37
+
38
+
39
+ class NotFoundError(UserInputError):
40
+ http_status = 404
@@ -0,0 +1,8 @@
1
+ from pal.input_converters.pipeline import (
2
+ CONVERTIBLE_EXTENSIONS,
3
+ ConversionError,
4
+ ConvertedFile,
5
+ convert_to_markdown,
6
+ )
7
+
8
+ __all__ = ["CONVERTIBLE_EXTENSIONS", "ConversionError", "ConvertedFile", "convert_to_markdown"]
File without changes
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pal.errors import UserInputError
6
+
7
+
8
+ class ConversionError(UserInputError):
9
+ """A file could not be turned into markdown. A UserInputError: shown to the user as a per-file error."""
10
+
11
+
12
+ @dataclass
13
+ class ConvertedFile:
14
+ name: str
15
+ text: str # markdown (or the original text for plain-text files)
16
+ source_format: str # "html", "pdf", "docx", ... or "text"
17
+ converted: bool # False when the file was already text and passed through
18
+
19
+
20
+ def table_to_markdown(rows: list[list[str]]) -> str:
21
+ """Render rows (first row = header) as a GitHub-style pipe table."""
22
+ rows = [r for r in rows if any(c.strip() for c in r)]
23
+ if not rows:
24
+ return ""
25
+ width = max(len(r) for r in rows)
26
+ # drop columns that are empty in every row
27
+ keep = [i for i in range(width) if any(i < len(r) and r[i].strip() for r in rows)]
28
+ if not keep:
29
+ return ""
30
+
31
+ def cell(r: list[str], i: int) -> str:
32
+ v = r[i] if i < len(r) else ""
33
+ return v.replace("|", "\\|").replace("\r", " ").replace("\n", " ").strip()
34
+
35
+ lines = ["| " + " | ".join(cell(rows[0], i) for i in keep) + " |",
36
+ "| " + " | ".join("---" for _ in keep) + " |"]
37
+ lines += ["| " + " | ".join(cell(r, i) for i in keep) + " |" for r in rows[1:]]
38
+ return "\n".join(lines)
@@ -0,0 +1,87 @@
1
+ """HTML, DOCX, ODT, RTF, EPUB and LaTeX -> GitHub-flavoured markdown via pandoc."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+
7
+ from pal.input_converters.components.base import ConversionError
8
+
9
+ # extension -> pandoc reader
10
+ READERS = {
11
+ "html": "html", "htm": "html", "xhtml": "html",
12
+ "docx": "docx", "odt": "odt", "rtf": "rtf",
13
+ "epub": "epub", "tex": "latex", "latex": "latex",
14
+ }
15
+
16
+ _TIMEOUT_S = 60
17
+
18
+
19
+ def available() -> bool:
20
+ return shutil.which("pandoc") is not None
21
+
22
+
23
+ def docx_without_pandoc(raw: bytes) -> str:
24
+ """A Word file as markdown without pandoc: headings, lists and tables from the document's XML (Python's standard library only)."""
25
+ import io
26
+ import xml.etree.ElementTree as ET # noqa: S405 (expat in Python 3.10+ limits entity expansion)
27
+ import zipfile
28
+ w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
29
+ try:
30
+ xml = zipfile.ZipFile(io.BytesIO(raw)).read("word/document.xml")
31
+ except (zipfile.BadZipFile, KeyError):
32
+ raise ConversionError("not a valid Word (.docx) file") from None
33
+ if len(xml) > 50_000_000:
34
+ raise ConversionError("the Word file is too large to read")
35
+ body = ET.fromstring(xml).find(f"{w}body") # noqa: S314
36
+ if body is None:
37
+ return ""
38
+
39
+ def text_of(el) -> str:
40
+ return "".join(t.text or "" for t in el.iter(f"{w}t"))
41
+ out: list[str] = []
42
+ for el in body:
43
+ if el.tag == f"{w}p":
44
+ text = text_of(el).strip()
45
+ if not text:
46
+ continue
47
+ style = el.find(f"{w}pPr/{w}pStyle")
48
+ name = (style.get(f"{w}val") or "") if style is not None else ""
49
+ if name.lower().startswith("heading") and name[-1:].isdigit():
50
+ out.append("#" * min(6, int(name[-1])) + " " + text)
51
+ elif name.lower() == "title":
52
+ out.append("# " + text)
53
+ elif el.find(f"{w}pPr/{w}numPr") is not None or name.lower().startswith("list"):
54
+ if out and out[-1].startswith("- "):
55
+ out[-1] += "\n- " + text # one list, not a paragraph per item
56
+ else:
57
+ out.append("- " + text)
58
+ else:
59
+ out.append(text)
60
+ elif el.tag == f"{w}tbl":
61
+ rows = [[text_of(c).strip().replace("|", "\\|") for c in r.iter(f"{w}tc")] for r in el.iter(f"{w}tr")]
62
+ rows = [r for r in rows if any(r)]
63
+ if rows:
64
+ width = max(len(r) for r in rows)
65
+ rows = [r + [""] * (width - len(r)) for r in rows]
66
+ out.append("\n".join(["| " + " | ".join(rows[0]) + " |", "|" + " --- |" * width] + ["| " + " | ".join(r) + " |" for r in rows[1:]]))
67
+ return "\n\n".join(out)
68
+
69
+
70
+ def convert(ext: str, raw: bytes) -> str:
71
+ reader = READERS[ext]
72
+ if not available() and ext == "docx":
73
+ return docx_without_pandoc(raw)
74
+ if not available():
75
+ raise ConversionError(
76
+ f".{ext} needs pandoc, which is not installed (apt install pandoc / brew install pandoc)")
77
+ cmd = ["pandoc", "-f", reader, "-t", "gfm-raw_html", "--wrap=none"]
78
+ try:
79
+ p = subprocess.run(cmd, input=raw, capture_output=True, timeout=_TIMEOUT_S)
80
+ except subprocess.TimeoutExpired:
81
+ raise ConversionError(f"pandoc timed out after {_TIMEOUT_S}s") from None
82
+ if p.returncode != 0 and ext == "docx":
83
+ return docx_without_pandoc(raw) # pandoc rejects some Word files that are still readable
84
+ if p.returncode != 0:
85
+ err = p.stderr.decode("utf-8", errors="replace").strip().splitlines()
86
+ raise ConversionError(f"pandoc could not read the file ({err[-1] if err else 'unknown error'})") from None
87
+ return p.stdout.decode("utf-8", errors="replace")
@@ -0,0 +1,46 @@
1
+ """Text-layer PDFs -> markdown. Uses pymupdf4llm (headings, tables) when it is
2
+ installed, otherwise pypdf with one section per page. Scans have no text layer
3
+ and are rejected: there is no OCR here."""
4
+ from __future__ import annotations
5
+
6
+ import io
7
+
8
+ from pal.input_converters.components.base import ConversionError
9
+
10
+
11
+ def _with_pymupdf(raw: bytes) -> str | None:
12
+ try:
13
+ import pymupdf # noqa: F401
14
+ import pymupdf4llm
15
+ except ImportError:
16
+ return None
17
+ doc = pymupdf.open(stream=raw, filetype="pdf")
18
+ try:
19
+ return pymupdf4llm.to_markdown(doc)
20
+ finally:
21
+ doc.close()
22
+
23
+
24
+ def _with_pypdf(raw: bytes) -> str:
25
+ try:
26
+ from pypdf import PdfReader
27
+ except ImportError:
28
+ raise ConversionError("PDF support needs pypdf (pip install pypdf)") from None
29
+ pages = [(p.extract_text() or "").strip() for p in PdfReader(io.BytesIO(raw)).pages]
30
+ if len(pages) == 1:
31
+ return pages[0]
32
+ return "\n\n".join(f"## Page {i}\n\n{t}" for i, t in enumerate(pages, 1) if t)
33
+
34
+
35
+ def convert(raw: bytes) -> str:
36
+ try:
37
+ text = _with_pymupdf(raw)
38
+ if text is None or not text.strip():
39
+ text = _with_pypdf(raw)
40
+ except ConversionError:
41
+ raise
42
+ except Exception as e:
43
+ raise ConversionError(f"could not read PDF ({e})") from e
44
+ if not text.strip():
45
+ raise ConversionError("PDF has no text layer (scanned?); OCR is not supported")
46
+ return text
@@ -0,0 +1,52 @@
1
+ """PowerPoint -> markdown: one `## Slide N` section per slide, speaker notes kept."""
2
+ from __future__ import annotations
3
+
4
+ import io
5
+
6
+ from pal.input_converters.components.base import ConversionError, table_to_markdown
7
+
8
+
9
+ def _shape_lines(shape, out: list[str]) -> None:
10
+ if getattr(shape, "shape_type", None) == 6 or hasattr(shape, "shapes"): # group
11
+ for s in getattr(shape, "shapes", []):
12
+ _shape_lines(s, out)
13
+ return
14
+ if getattr(shape, "has_table", False) and shape.has_table:
15
+ rows = [[c.text for c in r.cells] for r in shape.table.rows]
16
+ md = table_to_markdown(rows)
17
+ if md:
18
+ out.append(md)
19
+ return
20
+ if getattr(shape, "has_text_frame", False) and shape.has_text_frame:
21
+ for para in shape.text_frame.paragraphs:
22
+ t = "".join(r.text for r in para.runs).strip() or para.text.strip()
23
+ if t:
24
+ out.append((" " * min(para.level, 6)) + "- " + t if para.level else t)
25
+
26
+
27
+ def convert(raw: bytes) -> str:
28
+ try:
29
+ from pptx import Presentation
30
+ except ImportError:
31
+ raise ConversionError("PowerPoint support needs python-pptx (pip install python-pptx)") from None
32
+ try:
33
+ prs = Presentation(io.BytesIO(raw))
34
+ except Exception as e:
35
+ raise ConversionError(f"could not read PPTX ({e})") from e
36
+ sections = []
37
+ for i, slide in enumerate(prs.slides, 1):
38
+ title = ""
39
+ if slide.shapes.title is not None and slide.shapes.title.has_text_frame:
40
+ title = slide.shapes.title.text_frame.text.strip()
41
+ lines: list[str] = []
42
+ for shape in slide.shapes:
43
+ if slide.shapes.title is not None and shape.shape_id == slide.shapes.title.shape_id:
44
+ continue
45
+ _shape_lines(shape, lines)
46
+ if slide.has_notes_slide and slide.notes_slide.notes_text_frame is not None:
47
+ notes = slide.notes_slide.notes_text_frame.text.strip()
48
+ if notes:
49
+ lines.append(f"**Notes:** {notes}")
50
+ head = f"## Slide {i}" + (f": {title}" if title else "")
51
+ sections.append(head + ("\n\n" + "\n\n".join(lines) if lines else ""))
52
+ return "\n\n".join(sections)
@@ -0,0 +1,38 @@
1
+ """Excel -> markdown: one `## Sheet: name` section with a pipe table per sheet.
2
+ Formula cells give their cached (last-saved) value."""
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ from pal.input_converters.components.base import ConversionError, table_to_markdown
8
+
9
+
10
+ def _fmt(v) -> str:
11
+ if v is None:
12
+ return ""
13
+ if isinstance(v, float) and v.is_integer():
14
+ return str(int(v))
15
+ if hasattr(v, "isoformat"):
16
+ return v.isoformat(sep=" ") if hasattr(v, "hour") and (v.hour or v.minute or v.second) else v.isoformat()[:10]
17
+ return str(v)
18
+
19
+
20
+ def convert(raw: bytes) -> str:
21
+ try:
22
+ from openpyxl import load_workbook
23
+ except ImportError:
24
+ raise ConversionError("Excel support needs openpyxl (pip install openpyxl)") from None
25
+ try:
26
+ wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
27
+ except Exception as e:
28
+ raise ConversionError(f"could not read XLSX ({e})") from e
29
+ sections = []
30
+ try:
31
+ for ws in wb.worksheets:
32
+ rows = [[_fmt(v) for v in row] for row in ws.iter_rows(values_only=True)]
33
+ table = table_to_markdown(rows)
34
+ if table:
35
+ sections.append(f"## Sheet: {ws.title}\n\n{table}")
36
+ finally:
37
+ wb.close()
38
+ return "\n\n".join(sections)
@@ -0,0 +1,72 @@
1
+ """Attachment -> markdown, before anything reaches an API model.
2
+
3
+ Text-heavy binary/markup formats are converted to markdown because it costs
4
+ fewer tokens than the source markup and keeps structure (headings, lists,
5
+ tables) that flat extraction loses:
6
+
7
+ html/htm/xhtml pandoc
8
+ docx, odt, rtf pandoc
9
+ epub pandoc
10
+ tex/latex pandoc
11
+ pdf (text layer only) pymupdf4llm if installed, else pypdf
12
+ pptx python-pptx
13
+ xlsx openpyxl
14
+
15
+ Anything else is treated as plain text (code, CSV, JSON, .md, ...) and passed
16
+ through unchanged. Binary files that are not on the list are rejected.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from pathlib import PurePath
21
+
22
+ from pal.input_converters.components import (
23
+ pandoc_converter,
24
+ pdf_converter,
25
+ pptx_converter,
26
+ xlsx_converter,
27
+ )
28
+ from pal.input_converters.components.base import ConversionError, ConvertedFile
29
+
30
+ CONVERTIBLE_EXTENSIONS = tuple(sorted(
31
+ set(pandoc_converter.READERS) | {"pdf", "pptx", "xlsx"}
32
+ ))
33
+
34
+ # Legacy binary formats we cannot convert; say so instead of "binary not supported".
35
+ _LEGACY = {"doc": "docx", "ppt": "pptx", "xls": "xlsx"}
36
+
37
+
38
+ def _ext(name: str) -> str:
39
+ return PurePath(name).suffix.lower().lstrip(".")
40
+
41
+
42
+ def convert_to_markdown(name: str, raw: bytes) -> ConvertedFile:
43
+ """Return the file's content as markdown (or unchanged text). Raises
44
+ ConversionError (a ValueError) with a user-readable message on failure."""
45
+ name = name or "file"
46
+ ext = _ext(name)
47
+
48
+ def done(text: str, fmt: str, converted: bool = True) -> ConvertedFile:
49
+ text = text.replace("\r\n", "\n").strip("\n")
50
+ if not text.strip():
51
+ raise ConversionError(f"{name}: no readable text")
52
+ return ConvertedFile(name=name, text=text, source_format=fmt, converted=converted)
53
+
54
+ try:
55
+ if ext in pandoc_converter.READERS:
56
+ return done(pandoc_converter.convert(ext, raw), ext)
57
+ if ext == "pdf":
58
+ return done(pdf_converter.convert(raw), "pdf")
59
+ if ext == "pptx":
60
+ return done(pptx_converter.convert(raw), "pptx")
61
+ if ext in ("xlsx", "xlsm"):
62
+ return done(xlsx_converter.convert(raw), "xlsx")
63
+ except ConversionError as e:
64
+ raise ConversionError(f"{name}: {e}") from e
65
+
66
+ if ext in _LEGACY:
67
+ raise ConversionError(f"{name}: legacy .{ext} is not supported; save it as .{_LEGACY[ext]} and re-attach")
68
+ if b"\x00" in raw[:4096]:
69
+ raise ConversionError(
70
+ f"{name}: binary files are not supported "
71
+ f"(text, code, CSV, JSON and {', '.join('.' + e for e in CONVERTIBLE_EXTENSIONS)} only)")
72
+ return done(raw.decode("utf-8", errors="replace"), "text", converted=False)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,56 @@
1
+ """Convert a file that is already in a workspace to markdown: the converter as a tool.
2
+
3
+ Uploads are converted automatically when they arrive. This is the same converter for a file that is already in an agent's workspace
4
+ (an upload kept there, a file saved or generated earlier), asked for on demand. The full markdown is saved next to the original
5
+ under `converted/` so it can be read in parts, and an existing file is never overwritten.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from pathlib import PurePosixPath
12
+
13
+ from pal.input_converters.components.base import ConversionError
14
+ from pal.input_converters.pipeline import CONVERTIBLE_EXTENSIONS, convert_to_markdown
15
+
16
+ OUT_DIR = "converted"
17
+
18
+
19
+ @dataclass
20
+ class WorkspaceConversion:
21
+ path: str # the file that was converted
22
+ text: str # the whole markdown
23
+ source_format: str
24
+ converted: bool # False when the file was already text and passed through
25
+ saved: str # where the markdown was saved ("" if it was not)
26
+ note: str # why it was not saved, if so
27
+
28
+
29
+ def convertible(path: str) -> bool:
30
+ return PurePosixPath(path).suffix.lower().lstrip(".") in CONVERTIBLE_EXTENSIONS
31
+
32
+
33
+ def convert_workspace_file(path: str, *, read_bytes: Callable[[str], bytes], write_text: Callable[[str, str], object],
34
+ exists: Callable[[str], bool], max_chars: int = 500_000) -> WorkspaceConversion:
35
+ name = PurePosixPath(path).name
36
+ if not convertible(path):
37
+ raise ConversionError(f"{name}: only {', '.join(CONVERTIBLE_EXTENSIONS)} files can be converted "
38
+ "(text and code files need no conversion)")
39
+ res = convert_to_markdown(name, read_bytes(path))
40
+ if len(res.text) > max_chars:
41
+ raise ConversionError(f"{name}: the markdown is {len(res.text):,} characters, over the {max_chars:,} limit")
42
+ out = f"{OUT_DIR}/{name}.md"
43
+ if exists(out):
44
+ return WorkspaceConversion(path, res.text, res.source_format, res.converted, "", f"{out} already exists and was not overwritten")
45
+ write_text(out, res.text)
46
+ return WorkspaceConversion(path, res.text, res.source_format, res.converted, out, "")
47
+
48
+
49
+ def describe(r: WorkspaceConversion, preview_chars: int = 2000, indexed: str = "") -> str:
50
+ """The tool's answer: what happened, then the beginning of the markdown."""
51
+ head = f"converted {r.path} ({r.source_format} to markdown): {len(r.text):,} characters."
52
+ head += f" Saved the full text as {r.saved}." if r.saved else f" {r.note}."
53
+ if indexed:
54
+ head += " " + indexed
55
+ cut = r.text[:preview_chars]
56
+ return head + "\n\n" + cut + ("\n[preview ends; read the rest from the saved file]" if len(r.text) > preview_chars else "")
@@ -0,0 +1,2 @@
1
+ Edit input. Skip any change altering context. Critical: context must not change.
2
+ Remove: articles, filler, punctuation, pleasantries, hedging, extra whitespace. Paraphrase fewest words same meaning. Never drop not/never/no/only/except: meaning flip worse than tokens saved. Numbers, units exact. Imperative sentences. Instead of "Sure! I'd be happy to help. Issue likely caused by..." write "Bug in auth middleware". One word when one word enough. Never remove negation words. Never change user-pasted or attached file content. If original shorter than result, use original.
@@ -0,0 +1,5 @@
1
+ from pal.input_savers.components.base import SaverResult, StepResult
2
+ from pal.input_savers.components.methods import METHOD_ORDER, METHODS
3
+ from pal.input_savers.pipeline import InputSaverPipeline
4
+
5
+ __all__ = ["InputSaverPipeline", "SaverResult", "StepResult", "METHODS", "METHOD_ORDER"]
File without changes
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Method:
8
+ id: str
9
+ name: str
10
+ rule: str # the one edit the local LLM is allowed to make
11
+ system: str = "" # optional full applier system prompt (used by the rules-file mode)
12
+
13
+
14
+ @dataclass
15
+ class StepResult:
16
+ method_id: str
17
+ name: str
18
+ status: str # "kept" | "reverted" | "skipped"
19
+ reason: str
20
+ tokens_before: int
21
+ tokens_after: int # equals tokens_before when reverted/skipped
22
+ chunks_total: int = 0
23
+ chunks_kept: int = 0
24
+ chunk_notes: list = field(default_factory=list) # per reverted chunk: why
25
+ before: str = ""
26
+ after: str = ""
27
+
28
+
29
+ @dataclass
30
+ class SaverResult:
31
+ original: str
32
+ reduced: str
33
+ steps: list
34
+ tokens_original: int
35
+ tokens_reduced: int
36
+ counter: str = ""
37
+
38
+ @property
39
+ def tokens_saved(self) -> int:
40
+ return max(0, self.tokens_original - self.tokens_reduced)