pal-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. pal/__init__.py +29 -0
  2. pal/__main__.py +3 -0
  3. pal/access_manager/__init__.py +16 -0
  4. pal/access_manager/components/__init__.py +14 -0
  5. pal/access_manager/components/access_control.py +41 -0
  6. pal/access_manager/components/base.py +39 -0
  7. pal/access_manager/components/intervention.py +31 -0
  8. pal/access_manager/components/privilege_registry.py +30 -0
  9. pal/access_manager/pipeline.py +68 -0
  10. pal/access_manager/utils/__init__.py +2 -0
  11. pal/agent_constraints/__init__.py +3 -0
  12. pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
  13. pal/agent_constraints/components/__init__.py +3 -0
  14. pal/agent_constraints/components/constraints.py +22 -0
  15. pal/agent_constraints/pipeline.py +22 -0
  16. pal/agent_constraints/utils/__init__.py +2 -0
  17. pal/agents/__init__.py +12 -0
  18. pal/agents/coding.py +14 -0
  19. pal/agents/docs.py +15 -0
  20. pal/chat/__init__.py +4 -0
  21. pal/chat/demo.py +231 -0
  22. pal/chat/providers.py +23 -0
  23. pal/chat/service.py +591 -0
  24. pal/chat/services/__init__.py +26 -0
  25. pal/chat/services/base.py +142 -0
  26. pal/chat/services/data.py +142 -0
  27. pal/chat/services/media.py +123 -0
  28. pal/chat/services/web.py +60 -0
  29. pal/chat/sessions.py +108 -0
  30. pal/chat/tools.py +446 -0
  31. pal/chat/uploads.py +81 -0
  32. pal/cli.py +502 -0
  33. pal/compression/__init__.py +12 -0
  34. pal/compression/components/__init__.py +64 -0
  35. pal/compression/components/adverbs.py +20 -0
  36. pal/compression/components/hedges.py +54 -0
  37. pal/compression/components/interjections.py +20 -0
  38. pal/compression/components/minifier.py +56 -0
  39. pal/compression/components/output_compressor.py +13 -0
  40. pal/compression/components/pleonasms.py +54 -0
  41. pal/compression/components/punctuations.py +36 -0
  42. pal/compression/components/stopwords.py +62 -0
  43. pal/compression/history.py +346 -0
  44. pal/compression/pipeline.py +58 -0
  45. pal/compression/utils/__init__.py +30 -0
  46. pal/compression/utils/negations.py +18 -0
  47. pal/compression/utils/nltk_setup.py +20 -0
  48. pal/compression/utils/pronouns.py +27 -0
  49. pal/compression/utils/proselint_setup.py +9 -0
  50. pal/compression/utils/spacy_setup.py +22 -0
  51. pal/config/default.yaml +163 -0
  52. pal/config.py +504 -0
  53. pal/context_manager/__init__.py +7 -0
  54. pal/context_manager/chat_context.py +117 -0
  55. pal/context_manager/components/__init__.py +5 -0
  56. pal/context_manager/components/assembler.py +75 -0
  57. pal/context_manager/components/context_cache.py +45 -0
  58. pal/context_manager/components/kv_bridge.py +16 -0
  59. pal/context_manager/components/pager.py +48 -0
  60. pal/context_manager/components/simple_context_manager.py +56 -0
  61. pal/context_manager/components/turns.py +88 -0
  62. pal/context_manager/pipeline.py +27 -0
  63. pal/context_manager/utils/__init__.py +18 -0
  64. pal/context_manager/utils/hashing.py +5 -0
  65. pal/context_manager/utils/relevance.py +59 -0
  66. pal/context_manager/utils/storage.py +37 -0
  67. pal/documents/__init__.py +4 -0
  68. pal/documents/components/__init__.py +0 -0
  69. pal/documents/components/chunker.py +79 -0
  70. pal/documents/components/index.py +155 -0
  71. pal/documents/utils/__init__.py +1 -0
  72. pal/errors.py +40 -0
  73. pal/input_converters/__init__.py +8 -0
  74. pal/input_converters/components/__init__.py +0 -0
  75. pal/input_converters/components/base.py +38 -0
  76. pal/input_converters/components/pandoc_converter.py +87 -0
  77. pal/input_converters/components/pdf_converter.py +46 -0
  78. pal/input_converters/components/pptx_converter.py +52 -0
  79. pal/input_converters/components/xlsx_converter.py +38 -0
  80. pal/input_converters/pipeline.py +72 -0
  81. pal/input_converters/utils/__init__.py +1 -0
  82. pal/input_converters/workspace.py +56 -0
  83. pal/input_savers/INPUT_RULES.md +2 -0
  84. pal/input_savers/__init__.py +5 -0
  85. pal/input_savers/components/__init__.py +0 -0
  86. pal/input_savers/components/base.py +40 -0
  87. pal/input_savers/components/methods.py +41 -0
  88. pal/input_savers/components/verifier.py +39 -0
  89. pal/input_savers/pipeline.py +154 -0
  90. pal/input_savers/utils/__init__.py +0 -0
  91. pal/input_savers/utils/guards.py +94 -0
  92. pal/knowledge_graph/__init__.py +10 -0
  93. pal/knowledge_graph/components/__init__.py +0 -0
  94. pal/knowledge_graph/components/base.py +50 -0
  95. pal/knowledge_graph/components/builder.py +54 -0
  96. pal/knowledge_graph/components/retriever.py +100 -0
  97. pal/knowledge_graph/components/workspace.py +116 -0
  98. pal/knowledge_graph/pipeline.py +215 -0
  99. pal/knowledge_graph/utils/__init__.py +1 -0
  100. pal/latency_tracker/__init__.py +3 -0
  101. pal/latency_tracker/pipeline.py +86 -0
  102. pal/llm_scheduler/__init__.py +11 -0
  103. pal/llm_scheduler/api.py +137 -0
  104. pal/llm_scheduler/code.py +28 -0
  105. pal/llm_scheduler/core.py +92 -0
  106. pal/llm_scheduler/local.py +215 -0
  107. pal/llm_scheduler/pipeline.py +42 -0
  108. pal/llm_scheduler/types.py +92 -0
  109. pal/llm_wrapper/__init__.py +34 -0
  110. pal/llm_wrapper/adapter.py +316 -0
  111. pal/llm_wrapper/components/__init__.py +23 -0
  112. pal/llm_wrapper/components/adapter_types.py +90 -0
  113. pal/llm_wrapper/components/base.py +195 -0
  114. pal/llm_wrapper/components/features.py +104 -0
  115. pal/llm_wrapper/components/litellm_wrapper.py +215 -0
  116. pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
  117. pal/llm_wrapper/modes.py +73 -0
  118. pal/llm_wrapper/pipeline.py +61 -0
  119. pal/llm_wrapper/utils/__init__.py +4 -0
  120. pal/llm_wrapper/utils/registry.py +49 -0
  121. pal/llm_wrapper/utils/token_counting.py +10 -0
  122. pal/local_llm/__init__.py +5 -0
  123. pal/local_llm/components/__init__.py +0 -0
  124. pal/local_llm/components/huggingface_local.py +217 -0
  125. pal/local_llm/components/local_llm.py +60 -0
  126. pal/logging_setup.py +18 -0
  127. pal/memory/__init__.py +7 -0
  128. pal/memory/components/__init__.py +6 -0
  129. pal/memory/components/lru_k.py +56 -0
  130. pal/memory/components/manager.py +98 -0
  131. pal/memory/components/note.py +54 -0
  132. pal/memory/components/storage_manager.py +60 -0
  133. pal/memory/pipeline.py +122 -0
  134. pal/memory/utils/__init__.py +3 -0
  135. pal/memory/utils/keyword_overlap.py +3 -0
  136. pal/memory_tracker/__init__.py +3 -0
  137. pal/memory_tracker/components/__init__.py +0 -0
  138. pal/memory_tracker/components/process.py +56 -0
  139. pal/memory_tracker/pipeline.py +44 -0
  140. pal/metrics/__init__.py +4 -0
  141. pal/metrics/components/__init__.py +4 -0
  142. pal/metrics/components/collector.py +140 -0
  143. pal/metrics/components/exporter.py +28 -0
  144. pal/metrics/pipeline.py +49 -0
  145. pal/metrics/utils/__init__.py +2 -0
  146. pal/notes_manager/__init__.py +5 -0
  147. pal/notes_manager/components/__init__.py +0 -0
  148. pal/notes_manager/components/privacy.py +14 -0
  149. pal/notes_manager/components/recall.py +31 -0
  150. pal/notes_manager/components/tidy.py +28 -0
  151. pal/notes_manager/pipeline.py +138 -0
  152. pal/orchestrator/__init__.py +26 -0
  153. pal/orchestrator/components/__init__.py +26 -0
  154. pal/orchestrator/components/agent.py +105 -0
  155. pal/orchestrator/components/cgroup_manager.py +209 -0
  156. pal/orchestrator/components/context_interrupt.py +220 -0
  157. pal/orchestrator/components/limits.py +9 -0
  158. pal/orchestrator/components/scheduler.py +69 -0
  159. pal/orchestrator/components/syscall_scheduler.py +200 -0
  160. pal/orchestrator/pipeline.py +228 -0
  161. pal/orchestrator/utils/__init__.py +4 -0
  162. pal/orchestrator/utils/device.py +164 -0
  163. pal/orchestrator/utils/protocol.py +32 -0
  164. pal/os_kernel/__init__.py +5 -0
  165. pal/os_kernel/filesystem.py +80 -0
  166. pal/os_kernel/network.py +146 -0
  167. pal/os_kernel/pipeline.py +175 -0
  168. pal/os_kernel/sysinfo.py +36 -0
  169. pal/paths.py +59 -0
  170. pal/pipeline.py +177 -0
  171. pal/py.typed +0 -0
  172. pal/redact.py +19 -0
  173. pal/sdk/__init__.py +33 -0
  174. pal/sdk/adapters.py +43 -0
  175. pal/sdk/agent.py +99 -0
  176. pal/sdk/components/__init__.py +14 -0
  177. pal/sdk/components/autogen_adapter.py +65 -0
  178. pal/sdk/components/autogen_client.py +98 -0
  179. pal/sdk/components/framework_adapter_base.py +69 -0
  180. pal/sdk/components/metagpt_adapter.py +46 -0
  181. pal/sdk/components/open_interpreter_adapter.py +47 -0
  182. pal/sdk/components/tool_catalog.py +44 -0
  183. pal/sdk/os_api.py +84 -0
  184. pal/sdk/patterns.py +129 -0
  185. pal/sdk/pipeline.py +240 -0
  186. pal/sdk/queries.py +68 -0
  187. pal/sdk/savers.py +88 -0
  188. pal/sdk/utils/__init__.py +2 -0
  189. pal/storage_manager/__init__.py +17 -0
  190. pal/storage_manager/components/__init__.py +19 -0
  191. pal/storage_manager/components/base.py +50 -0
  192. pal/storage_manager/components/local_file_backend.py +84 -0
  193. pal/storage_manager/components/storage_queue.py +91 -0
  194. pal/storage_manager/components/vector_store_backend.py +122 -0
  195. pal/storage_manager/pipeline.py +153 -0
  196. pal/storage_manager/utils/__init__.py +4 -0
  197. pal/storage_manager/utils/memory_adapter.py +60 -0
  198. pal/storage_manager/utils/sdk.py +93 -0
  199. pal/studio/__init__.py +0 -0
  200. pal/studio/builder.py +166 -0
  201. pal/studio/catalog.py +90 -0
  202. pal/studio/checks.py +129 -0
  203. pal/studio/clients.py +6 -0
  204. pal/studio/editor.py +137 -0
  205. pal/studio/files.py +6 -0
  206. pal/studio/models.py +6 -0
  207. pal/studio/plan.py +149 -0
  208. pal/studio/planner.py +205 -0
  209. pal/studio/policy.py +69 -0
  210. pal/studio/routes.py +254 -0
  211. pal/studio/runs.py +210 -0
  212. pal/task_router/__init__.py +11 -0
  213. pal/task_router/components/__init__.py +0 -0
  214. pal/task_router/components/answerer.py +37 -0
  215. pal/task_router/components/base.py +34 -0
  216. pal/task_router/components/classifier.py +40 -0
  217. pal/task_router/components/gates.py +23 -0
  218. pal/task_router/pipeline.py +61 -0
  219. pal/task_router/utils/__init__.py +1 -0
  220. pal/terminal/__init__.py +1 -0
  221. pal/terminal/backend.py +141 -0
  222. pal/terminal/bench.py +173 -0
  223. pal/terminal/cli.py +826 -0
  224. pal/terminal/clients.py +170 -0
  225. pal/terminal/commands.py +401 -0
  226. pal/terminal/decide.py +242 -0
  227. pal/terminal/files.py +55 -0
  228. pal/terminal/flow_cli.py +439 -0
  229. pal/terminal/hardware.py +199 -0
  230. pal/terminal/history.py +88 -0
  231. pal/terminal/keys.py +83 -0
  232. pal/terminal/local_models.py +261 -0
  233. pal/terminal/logo.py +117 -0
  234. pal/terminal/models.py +99 -0
  235. pal/terminal/policy.py +54 -0
  236. pal/terminal/project.py +413 -0
  237. pal/terminal/review.py +65 -0
  238. pal/terminal/security.py +239 -0
  239. pal/terminal/session.py +503 -0
  240. pal/terminal/setup.py +354 -0
  241. pal/terminal/syntax.py +98 -0
  242. pal/terminal/theme.py +37 -0
  243. pal/terminal/ui.py +647 -0
  244. pal/terminal/voice.py +137 -0
  245. pal/terminal/workflow.py +624 -0
  246. pal/token_tracker/__init__.py +3 -0
  247. pal/token_tracker/pipeline.py +54 -0
  248. pal/tokens.py +21 -0
  249. pal/tool_catalog.py +107 -0
  250. pal/tool_manager/__init__.py +17 -0
  251. pal/tool_manager/builtin.py +82 -0
  252. pal/tool_manager/components/__init__.py +19 -0
  253. pal/tool_manager/components/base.py +53 -0
  254. pal/tool_manager/components/conflict_resolver.py +27 -0
  255. pal/tool_manager/components/param_validator.py +41 -0
  256. pal/tool_manager/components/tool_call_queue.py +111 -0
  257. pal/tool_manager/components/tool_registry.py +60 -0
  258. pal/tool_manager/pipeline.py +80 -0
  259. pal/tool_manager/utils/__init__.py +3 -0
  260. pal/web/__init__.py +3 -0
  261. pal/web/app.py +276 -0
  262. pal/web/static/editor.js +219 -0
  263. pal/web/static/fonts/LICENSE-Inter.txt +3 -0
  264. pal/web/static/fonts/inter-400.woff2 +0 -0
  265. pal/web/static/fonts/inter-500.woff2 +0 -0
  266. pal/web/static/fonts/inter-600.woff2 +0 -0
  267. pal/web/static/fonts/inter-700.woff2 +0 -0
  268. pal/web/static/inter.css +5 -0
  269. pal/web/static/pal-logo-256.png +0 -0
  270. pal/web/static/pal-logo-32.png +0 -0
  271. pal/web/static/pal-logo-64.png +0 -0
  272. pal/web/static/studio.css +306 -0
  273. pal/web/static/studio.js +533 -0
  274. pal/web/static/voice.js +76 -0
  275. pal/web/templates/index.html +368 -0
  276. pal/web/templates/studio.html +120 -0
  277. pal_code-0.1.0.dist-info/METADATA +379 -0
  278. pal_code-0.1.0.dist-info/RECORD +282 -0
  279. pal_code-0.1.0.dist-info/WHEEL +5 -0
  280. pal_code-0.1.0.dist-info/entry_points.txt +2 -0
  281. pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
  282. pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/config.py ADDED
@@ -0,0 +1,504 @@
1
+ """Typed, validated settings.
2
+
3
+ Sources, lowest to highest priority:
4
+ 1. the packaged defaults (config/default.yaml)
5
+ 2. a user file (--config path, or the PAL_CONFIG env var)
6
+ 3. environment variables: PAL__<SECTION>__<KEY>=value (e.g. PAL__SERVER__PORT=8080)
7
+ 4. explicit overrides passed in code
8
+
9
+ Unknown keys and bad values raise ConfigError at startup, so a typo fails loudly instead of being
10
+ silently ignored.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import copy
15
+ import os
16
+ from dataclasses import MISSING, dataclass, field, fields
17
+ from importlib import resources
18
+ from pathlib import Path
19
+ from typing import Any, ClassVar
20
+
21
+ import yaml
22
+
23
+ from pal.errors import ConfigError
24
+ from pal.paths import data_dir
25
+
26
+ _ENV_PREFIX = "PAL__"
27
+
28
+
29
+ def _fail(section: str, key: str, msg: str) -> None:
30
+ raise ConfigError(f"config {section}.{key}: {msg}")
31
+
32
+
33
+ @dataclass
34
+ class _Section:
35
+ """Base for one settings section: builds from a dict, rejects unknown keys, checks types."""
36
+
37
+ name: ClassVar[str] = ""
38
+
39
+ @classmethod
40
+ def coerce(cls, value: Any):
41
+ """Accept an instance, a dict, or None (= all defaults)."""
42
+ if isinstance(value, cls):
43
+ return value
44
+ if value is None:
45
+ return cls()
46
+ if not isinstance(value, dict):
47
+ raise ConfigError(f"config {cls.name}: expected a mapping, got {type(value).__name__}")
48
+ known = {f.name: f for f in fields(cls)}
49
+ for k in value:
50
+ if k not in known:
51
+ raise ConfigError(f"config {cls.name}.{k}: unknown key (valid: {', '.join(sorted(known))})")
52
+ kwargs = {}
53
+ for k, v in value.items():
54
+ default = known[k].default if known[k].default is not MISSING else None
55
+ kwargs[k] = _convert(cls.name, k, v, default)
56
+ obj = cls(**kwargs)
57
+ obj.validate()
58
+ return obj
59
+
60
+ def validate(self) -> None: # overridden
61
+ return None
62
+
63
+
64
+ def _convert(section: str, key: str, value: Any, default: Any) -> Any:
65
+ if default is None or value is None:
66
+ return value
67
+ want = type(default)
68
+ if want is bool:
69
+ if isinstance(value, bool):
70
+ return value
71
+ _fail(section, key, f"expected true/false, got {value!r}")
72
+ if want in (int, float):
73
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
74
+ _fail(section, key, f"expected a number, got {value!r}")
75
+ return want(value)
76
+ if want is str:
77
+ if not isinstance(value, str):
78
+ _fail(section, key, f"expected text, got {value!r}")
79
+ return value
80
+ return value
81
+
82
+
83
+ def _check(section: str, key: str, ok: bool, msg: str) -> None:
84
+ if not ok:
85
+ _fail(section, key, msg)
86
+
87
+
88
+ @dataclass
89
+ class TaskRoutingConfig(_Section):
90
+ name: ClassVar[str] = "task_routing"
91
+ enabled: bool = True
92
+ default: str = "auto"
93
+ max_local_chars: int = 400
94
+ max_local_lines: int = 6
95
+ history_messages: int = 6
96
+
97
+ def validate(self) -> None:
98
+ _check(self.name, "default", self.default in ("auto", "local", "api"), "must be auto, local or api")
99
+ _check(self.name, "max_local_chars", self.max_local_chars > 0, "must be > 0")
100
+ _check(self.name, "max_local_lines", self.max_local_lines > 0, "must be > 0")
101
+ _check(self.name, "history_messages", self.history_messages >= 0, "must be >= 0")
102
+
103
+
104
+ @dataclass
105
+ class KnowledgeGraphConfig(_Section):
106
+ name: ClassVar[str] = "knowledge_graph"
107
+ enabled: bool = True
108
+ dir: str | None = None
109
+ budget_tokens: int = 1500
110
+ max_slice_lines: int = 40
111
+ min_code_file_chars: int = 8000
112
+ max_zip_files: int = 3000
113
+ max_zip_mb: int = 50
114
+
115
+ def validate(self) -> None:
116
+ for k in ("budget_tokens", "max_slice_lines", "min_code_file_chars", "max_zip_files", "max_zip_mb"):
117
+ _check(self.name, k, getattr(self, k) > 0, "must be > 0")
118
+
119
+ def resolved_dir(self) -> Path:
120
+ return Path(self.dir).expanduser() if self.dir else data_dir("graphs")
121
+
122
+
123
+ @dataclass
124
+ class ContextConfig(_Section):
125
+ """Context paging: keep the last few turns, recall older turns only when relevant."""
126
+
127
+ name: ClassVar[str] = "context"
128
+ enabled: bool = True
129
+ hot_turns: int = 6
130
+ recall_turns: int = 2
131
+ recall_threshold: float = 0.35
132
+ max_history_tokens: int = 6000
133
+ total_budget_tokens: int = 10000 # one budget for history, facts, memory, document and code excerpts together
134
+ compress: bool = True # compress OLD assistant replies (no model involved)
135
+ compress_turns: int = 12 # how many turns older than the hot window are shown in compressed form
136
+ compress_min_words: int = 60 # a reply shorter than this is left alone
137
+ compress_budget_tokens: int = 400 # most tokens the compressed older turns may take together
138
+ facts_tokens: int = 250 # budget for the exact details of turns too old to show at all
139
+ facts_max_ratio: float = 0.3 # a facts block is only added if it is this much smaller than the turns it stands for
140
+
141
+ def validate(self) -> None:
142
+ _check(self.name, "total_budget_tokens", self.total_budget_tokens >= 1000, "must be >= 1000")
143
+ _check(self.name, "compress_turns", self.compress_turns >= 0, "must be >= 0")
144
+ _check(self.name, "compress_min_words", self.compress_min_words >= 1, "must be >= 1")
145
+ _check(self.name, "facts_tokens", self.facts_tokens >= 0, "must be >= 0")
146
+ _check(self.name, "compress_budget_tokens", self.compress_budget_tokens >= 0, "must be >= 0")
147
+ _check(self.name, "facts_max_ratio", 0 <= self.facts_max_ratio <= 1, "must be between 0 and 1")
148
+ _check(self.name, "hot_turns", self.hot_turns >= 1, "must be >= 1")
149
+ _check(self.name, "recall_turns", self.recall_turns >= 0, "must be >= 0")
150
+ _check(self.name, "recall_threshold", 0 <= self.recall_threshold <= 1, "must be between 0 and 1")
151
+ _check(self.name, "max_history_tokens", self.max_history_tokens >= 500, "must be >= 500")
152
+
153
+
154
+ @dataclass
155
+ class MemoryConfig(_Section):
156
+ """Cross-session memory. Stores what you type on this machine; see README for what that means."""
157
+
158
+ name: ClassVar[str] = "memory"
159
+ enabled: bool = True
160
+ dir: str | None = None
161
+ profile: str = "default"
162
+ recall_notes: int = 3
163
+ min_score: float = 0.3
164
+ budget_tokens: int = 300
165
+ min_chars: int = 25
166
+ block_limit: int = 200
167
+ tidy_every: int = 50 # after this many new notes, merge duplicates in the background (0 = never)
168
+
169
+ def validate(self) -> None:
170
+ _check(self.name, "recall_notes", self.recall_notes >= 0, "must be >= 0")
171
+ _check(self.name, "min_score", 0 <= self.min_score <= 1, "must be between 0 and 1")
172
+ _check(self.name, "budget_tokens", self.budget_tokens > 0, "must be > 0")
173
+ _check(self.name, "block_limit", self.block_limit >= 5, "must be >= 5")
174
+
175
+
176
+ @dataclass
177
+ class DocumentsConfig(_Section):
178
+ """Attached documents are indexed once; later turns get only the relevant chunks."""
179
+
180
+ name: ClassVar[str] = "documents"
181
+ enabled: bool = True
182
+ inline_max_tokens: int = 2500
183
+ preview_tokens: int = 800
184
+ chunk_tokens: int = 350
185
+ retrieve_chunks: int = 3
186
+ budget_tokens: int = 1200
187
+ min_score: float = 0.15
188
+ max_file_chars: int = 500000
189
+
190
+ def validate(self) -> None:
191
+ for k in ("inline_max_tokens", "preview_tokens", "chunk_tokens", "retrieve_chunks", "budget_tokens",
192
+ "max_file_chars"):
193
+ _check(self.name, k, getattr(self, k) > 0, "must be > 0")
194
+ _check(self.name, "min_score", 0 <= self.min_score <= 1, "must be between 0 and 1")
195
+
196
+
197
+ @dataclass
198
+ class ToolsConfig(_Section):
199
+ name: ClassVar[str] = "tools"
200
+ enabled: bool = True
201
+ max_rounds: int = 3
202
+ max_result_tokens: int = 800
203
+ direct_answers: bool = True
204
+ network: bool = False # wikipedia and arxiv lookups: what the model asks for leaves this machine, so it is opt-in
205
+ convert_max_chars: int = 500_000 # file_to_markdown: the largest markdown it will produce
206
+ save_files: bool = True # agents that list save_file may write NEW files to their workspace (overwrites ask first)
207
+ services: list = field(default_factory=list) # third-party service tools to offer, by name, or ["all"]. Off by default: a
208
+ # query leaves this machine, most need a key, and some cost money
209
+ hf_models: dict = field(default_factory=dict) # Hugging Face model for a media tool, e.g. {text_to_image: "owner/model"}
210
+ rapidapi_hosts: dict = field(default_factory=dict) # RapidAPI host for a tool, if your subscription shows a different one
211
+
212
+ def validate(self) -> None:
213
+ _check(self.name, "max_rounds", 0 <= self.max_rounds <= 8, "must be between 0 and 8")
214
+ _check(self.name, "max_result_tokens", self.max_result_tokens >= 50, "must be >= 50")
215
+ _check(self.name, "convert_max_chars", self.convert_max_chars >= 1000, "must be >= 1000")
216
+ _check(self.name, "services", isinstance(self.services, list) and all(isinstance(x, str) for x in self.services),
217
+ "must be a list of service names (or [all])")
218
+ for k in ("hf_models", "rapidapi_hosts"):
219
+ v = getattr(self, k)
220
+ _check(self.name, k, isinstance(v, dict) and all(isinstance(a, str) and isinstance(b, str) for a, b in v.items()),
221
+ "must map names to text")
222
+
223
+
224
+ @dataclass
225
+ class AgentsConfig(_Section):
226
+ """Agent application layer (coding and documentation agents on the Gemini API)."""
227
+
228
+ name: ClassVar[str] = "agents"
229
+ enabled: bool = True
230
+ model: str = "gemini-3.5-flash"
231
+
232
+ def validate(self) -> None:
233
+ _check(self.name, "model", bool(self.model.strip()), "must not be empty")
234
+
235
+
236
+ @dataclass
237
+ class OsKernelConfig(_Section):
238
+ """Non-LLM queries (files, network, system facts) go to the OS kernel through this guarded interface."""
239
+
240
+ name: ClassVar[str] = "os_kernel"
241
+ enabled: bool = True
242
+ workspace_dir: str | None = None
243
+ max_read_bytes: int = 1_000_000
244
+ max_write_bytes: int = 5_000_000
245
+ allow_private_network: bool = False # localhost, home network and cloud metadata addresses stay blocked
246
+ net_timeout_seconds: float = 10.0
247
+ net_max_bytes: int = 1_000_000
248
+ max_binary_bytes: int = 20_000_000 # images and audio, in the workspace and over the network
249
+ allowed_hosts: list = field(default_factory=list) # empty = any public host
250
+
251
+ def validate(self) -> None:
252
+ for k in ("max_read_bytes", "max_write_bytes", "net_max_bytes", "max_binary_bytes"):
253
+ _check(self.name, k, getattr(self, k) > 0, "must be > 0")
254
+ _check(self.name, "net_timeout_seconds", self.net_timeout_seconds > 0, "must be > 0")
255
+ _check(self.name, "allowed_hosts", isinstance(self.allowed_hosts, list)
256
+ and all(isinstance(h, str) for h in self.allowed_hosts), "must be a list of host names")
257
+
258
+
259
+ @dataclass
260
+ class SchedulerConfig(_Section):
261
+ """API model: first come, first served per key. Local model: preemptive priority (agent, chat, other)."""
262
+
263
+ name: ClassVar[str] = "scheduler"
264
+ enabled: bool = True
265
+ local_max_pauses: int = 2 # a local job is paused at most this many times, then it runs to the end
266
+ other_stale_seconds: float = 600.0 # a background job that waited this long is dropped
267
+ code_workers: int = 2 # file conversions and code-graph builds running at once
268
+ max_queue: int = 100
269
+ api_min_interval: dict = field(default_factory=lambda: {"gemini": 4.0}) # seconds between call starts, per provider
270
+ api_retry_429: int = 2
271
+ api_backoff_seconds: float = 5.0
272
+ free_model_interval: float = 3.0 # seconds between calls to an OpenRouter ":free" model (about 20 per minute)
273
+
274
+ def validate(self) -> None:
275
+ for k in ("local_max_pauses", "code_workers", "api_retry_429"):
276
+ _check(self.name, k, getattr(self, k) >= 0, "must be >= 0")
277
+ _check(self.name, "max_queue", self.max_queue >= 1, "must be >= 1")
278
+ _check(self.name, "other_stale_seconds", self.other_stale_seconds > 0, "must be > 0")
279
+ _check(self.name, "api_backoff_seconds", self.api_backoff_seconds >= 0, "must be >= 0")
280
+ _check(self.name, "free_model_interval", self.free_model_interval >= 0, "must be >= 0")
281
+ ok = isinstance(self.api_min_interval, dict) and all(
282
+ isinstance(k, str) and isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0
283
+ for k, v in self.api_min_interval.items())
284
+ _check(self.name, "api_min_interval", ok, "must map provider names to seconds (>= 0)")
285
+
286
+
287
+ @dataclass
288
+ class FallbackConfig(_Section):
289
+ """When your own provider is rate-limited or down, answer with a second model (by default a free OpenRouter model)."""
290
+
291
+ name: ClassVar[str] = "fallback"
292
+ enabled: bool = False
293
+ provider: str = "openrouter"
294
+ model: str = "" # e.g. an OpenRouter model ending in ":free"; empty = fallback is off
295
+ for_agents: bool = True
296
+
297
+ def validate(self) -> None:
298
+ _check(self.name, "provider", bool(self.provider.strip()), "must not be empty")
299
+
300
+
301
+ @dataclass
302
+ class MetricsConfig(_Section):
303
+ name: ClassVar[str] = "metrics"
304
+ enabled: bool = True
305
+ max_records: int = 1000
306
+
307
+ def validate(self) -> None:
308
+ _check(self.name, "max_records", self.max_records >= 10, "must be >= 10")
309
+
310
+
311
+ @dataclass
312
+ class ServerConfig(_Section):
313
+ name: ClassVar[str] = "server"
314
+ host: str = "127.0.0.1"
315
+ port: int = 5000
316
+ threads: int = 8
317
+ max_upload_mb: int = 20
318
+ max_sessions: int = 200
319
+ session_ttl_minutes: int = 240
320
+ log_level: str = "INFO"
321
+
322
+ def validate(self) -> None:
323
+ _check(self.name, "port", 0 < self.port < 65536, "must be 1-65535")
324
+ _check(self.name, "threads", self.threads >= 1, "must be >= 1")
325
+ _check(self.name, "max_upload_mb", self.max_upload_mb >= 1, "must be >= 1")
326
+ _check(self.name, "max_sessions", self.max_sessions >= 1, "must be >= 1")
327
+ _check(self.name, "session_ttl_minutes", self.session_ttl_minutes >= 1, "must be >= 1")
328
+ _check(self.name, "log_level", self.log_level.upper() in ("DEBUG", "INFO", "WARNING", "ERROR"), "bad level")
329
+
330
+
331
+ @dataclass
332
+ class StudioConfig(_Section):
333
+ """The Studio: a bot plans a product and builds it. The context window matters: Ollama's own default is small and it silently CUTS a longer prompt, so a
334
+ build would lose its earlier files. The values below are sent with every local model call. A bigger window needs more memory."""
335
+
336
+ name: ClassVar[str] = "studio"
337
+ num_ctx: int = 16384 # the builder's context window, in tokens
338
+ planner_num_ctx: int = 8192 # the bot's (its prompt is the list of agents, tools and permissions)
339
+ max_runs: int = 6 # finished builds kept on disk (older ones are deleted)
340
+
341
+ def validate(self) -> None:
342
+ _check(self.name, "num_ctx", self.num_ctx >= 2048, "must be >= 2048")
343
+ _check(self.name, "planner_num_ctx", self.planner_num_ctx >= 2048, "must be >= 2048")
344
+ _check(self.name, "max_runs", self.max_runs >= 1, "must be >= 1")
345
+
346
+
347
+ _SECTIONS: dict[str, type[_Section]] = {
348
+ "task_routing": TaskRoutingConfig, "knowledge_graph": KnowledgeGraphConfig, "context": ContextConfig,
349
+ "memory": MemoryConfig, "documents": DocumentsConfig, "tools": ToolsConfig,
350
+ "agents": AgentsConfig, "studio": StudioConfig, "scheduler": SchedulerConfig, "fallback": FallbackConfig, "os_kernel": OsKernelConfig, "metrics": MetricsConfig, "server": ServerConfig,
351
+ }
352
+ _TOP_LEVEL = {"local_llm", "verifier_llm", "input_mode", "input_rules", "input_methods", "output_config",
353
+ "base_system_prompt", "token_counter", "storage_dir", *_SECTIONS}
354
+
355
+
356
+ @dataclass
357
+ class Settings:
358
+ local_llm: dict = field(default_factory=lambda: {"backend": "ollama", "model": "llama3"})
359
+ verifier_llm: dict | None = None
360
+ input_mode: str = "rules"
361
+ input_rules: str = "input_savers/INPUT_RULES.md"
362
+ input_methods: list = field(default_factory=list)
363
+ output_config: str = "agent_constraints/components/AGENT_OUTPUT.md"
364
+ base_system_prompt: str = "You are a helpful assistant."
365
+ token_counter: str = "heuristic" # noqa: S105
366
+ storage_dir: str | None = None
367
+ task_routing: TaskRoutingConfig = field(default_factory=TaskRoutingConfig)
368
+ knowledge_graph: KnowledgeGraphConfig = field(default_factory=KnowledgeGraphConfig)
369
+ context: ContextConfig = field(default_factory=ContextConfig)
370
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
371
+ documents: DocumentsConfig = field(default_factory=DocumentsConfig)
372
+ tools: ToolsConfig = field(default_factory=ToolsConfig)
373
+ agents: AgentsConfig = field(default_factory=AgentsConfig)
374
+ studio: StudioConfig = field(default_factory=StudioConfig)
375
+ os_kernel: OsKernelConfig = field(default_factory=OsKernelConfig)
376
+ scheduler: SchedulerConfig = field(default_factory=SchedulerConfig)
377
+ fallback: FallbackConfig = field(default_factory=FallbackConfig)
378
+ metrics: MetricsConfig = field(default_factory=MetricsConfig)
379
+ server: ServerConfig = field(default_factory=ServerConfig)
380
+ config_dir: Path | None = None # folder of the user config file, for resolving relative paths
381
+
382
+ def validate(self) -> None:
383
+ if self.input_mode not in ("rules", "steps"):
384
+ raise ConfigError("config input_mode: must be 'rules' or 'steps'")
385
+ if self.token_counter not in ("heuristic", "provider"):
386
+ raise ConfigError("config token_counter: must be 'heuristic' or 'provider'")
387
+ if not isinstance(self.local_llm, dict) or "backend" not in self.local_llm:
388
+ raise ConfigError("config local_llm: needs at least a 'backend'")
389
+ from pal.llm_wrapper.components.base import WrapperConfig
390
+ allowed = {f.name for f in fields(WrapperConfig)}
391
+ if not isinstance(self.local_llm.get("enabled", True), bool):
392
+ raise ConfigError("config local_llm.enabled: must be true or false")
393
+ for label, block in (("local_llm", self.local_llm), ("verifier_llm", self.verifier_llm)):
394
+ for k in (block or {}):
395
+ if k not in allowed and not (label == "local_llm" and k == "enabled"):
396
+ raise ConfigError(f"config {label}.{k}: unknown key (valid: {', '.join(sorted(allowed))})")
397
+
398
+ def resolve_text_file(self, path: str) -> Path | None:
399
+ """Find a packaged or user-relative text file (rules, output config). Absolute paths win, then the
400
+ user config folder, then the package."""
401
+ p = Path(path).expanduser()
402
+ if p.is_absolute():
403
+ return p if p.is_file() else None
404
+ for base in (self.config_dir, Path(str(resources.files("pal")))):
405
+ if base is not None and (base / p).is_file():
406
+ return base / p
407
+ return None
408
+
409
+ def read_text_file(self, path: str) -> str:
410
+ p = self.resolve_text_file(path)
411
+ return p.read_text(encoding="utf-8").strip() if p else ""
412
+
413
+ def to_dict(self) -> dict:
414
+ """Effective settings as plain data (no secrets live here; API keys are per-request)."""
415
+ out: dict = {}
416
+ for f in fields(self):
417
+ v = getattr(self, f.name)
418
+ out[f.name] = {k.name: getattr(v, k.name) for k in fields(v)} if isinstance(v, _Section) else (
419
+ str(v) if isinstance(v, Path) else v)
420
+ return out
421
+
422
+
423
+ def _deep_merge(base: dict, extra: dict) -> dict:
424
+ out = copy.deepcopy(base)
425
+ for k, v in extra.items():
426
+ out[k] = _deep_merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) else v
427
+ return out
428
+
429
+
430
+ def _load_yaml(text: str, origin: str) -> dict:
431
+ try:
432
+ data = yaml.safe_load(text) or {}
433
+ except yaml.YAMLError as e:
434
+ raise ConfigError(f"{origin}: invalid YAML ({e})") from e
435
+ if not isinstance(data, dict):
436
+ raise ConfigError(f"{origin}: top level must be a mapping")
437
+ return data
438
+
439
+
440
+ def _env_overrides(env) -> dict:
441
+ out: dict = {}
442
+ for name, raw in env.items():
443
+ if not name.startswith(_ENV_PREFIX):
444
+ continue
445
+ parts = [p.lower() for p in name[len(_ENV_PREFIX):].split("__") if p]
446
+ if not parts:
447
+ continue
448
+ try:
449
+ value = yaml.safe_load(raw)
450
+ except yaml.YAMLError:
451
+ value = raw
452
+ cur = out
453
+ for p in parts[:-1]:
454
+ cur = cur.setdefault(p, {})
455
+ cur[parts[-1]] = value
456
+ return out
457
+
458
+
459
+ # Settings sections that were renamed with their component. An old settings file or environment variable that still uses the old name keeps working:
460
+ # its values are moved to the new section (and win over nothing: a value under the new name beats one under the old name).
461
+ RENAMED_SECTIONS = {"graph_context": "knowledge_graph"}
462
+
463
+
464
+ def migrate_renamed(raw: dict) -> dict:
465
+ """The settings with every old section name moved to its new name. Values under the new name win."""
466
+ out = dict(raw)
467
+ for old, new in RENAMED_SECTIONS.items():
468
+ if old in out:
469
+ moved = out.pop(old)
470
+ out[new] = _deep_merge(moved if isinstance(moved, dict) else {}, out.get(new) or {}) if isinstance(moved, dict) else out.get(new, moved)
471
+ return out
472
+
473
+
474
+ def load_settings(path: str | os.PathLike | None = None, env=None, overrides: dict | None = None) -> Settings:
475
+ env = os.environ if env is None else env
476
+ default_text = (resources.files("pal") / "config" / "default.yaml").read_text(encoding="utf-8")
477
+ raw = _load_yaml(default_text, "default.yaml")
478
+ user_path = path or env.get("PAL_CONFIG")
479
+ config_dir = None
480
+ if user_path:
481
+ p = Path(user_path).expanduser()
482
+ if not p.is_file():
483
+ raise ConfigError(f"config file not found: {p}")
484
+ raw = _deep_merge(raw, migrate_renamed(_load_yaml(p.read_text(encoding="utf-8"), str(p))))
485
+ config_dir = p.resolve().parent
486
+ raw = _deep_merge(raw, migrate_renamed(_env_overrides(env)))
487
+ if overrides:
488
+ raw = _deep_merge(raw, migrate_renamed(overrides))
489
+ return settings_from_dict(raw, config_dir)
490
+
491
+
492
+ def settings_from_dict(raw: dict, config_dir: Path | None = None) -> Settings:
493
+ unknown = set(raw) - _TOP_LEVEL - {"config_dir"}
494
+ if unknown:
495
+ raise ConfigError(f"config: unknown key(s): {', '.join(sorted(unknown))}")
496
+ kwargs: dict = {}
497
+ for k, v in raw.items():
498
+ if k in _SECTIONS:
499
+ kwargs[k] = _SECTIONS[k].coerce(v)
500
+ elif k != "config_dir":
501
+ kwargs[k] = v
502
+ s = Settings(config_dir=config_dir, **kwargs)
503
+ s.validate()
504
+ return s
@@ -0,0 +1,7 @@
1
+ from pal.context_manager.components.context_cache import ContextCache
2
+ from pal.context_manager.components.kv_bridge import KVBridge
3
+ from pal.context_manager.components.pager import ContextPager, Segment
4
+ from pal.context_manager.components.turns import PagedHistory, Turn, TurnPager
5
+ from pal.context_manager.pipeline import ContextManagerPipeline
6
+
7
+ __all__ = ["ContextManagerPipeline", "ContextCache", "KVBridge", "ContextPager", "Segment", "PagedHistory", "Turn", "TurnPager"]
@@ -0,0 +1,117 @@
1
+ """The context manager for chat: it decides everything that goes into a request besides the user's own message, under one budget.
2
+
3
+ What it keeps for itself: choosing which turns of the history are shown (the pager: the newest verbatim, relevant older ones recalled),
4
+ and fitting everything under `context.total_budget_tokens` with the assembler, dropping the least useful first.
5
+ What it asks other components for:
6
+ * the Compression component shortens the older replies and turns the oldest turns into facts (compression/history.py);
7
+ * the Knowledge Graph the excerpts of an uploaded project that match the question (knowledge_graph);
8
+ * the Notes Manager the notes from other sessions that match the question (notes_manager);
9
+ * the documents index the parts of attached documents that match.
10
+ It does no background work: the tidy-up of notes belongs to the Notes Manager.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from pal.compression.history import MARK, HistoryCompressor, composition
20
+ from pal.config import ContextConfig
21
+ from pal.context_manager.components.assembler import Block, ContextAssembler, ContextReport
22
+ from pal.context_manager.components.turns import PagedHistory, Turn, TurnPager
23
+ from pal.memory_tracker import MemoryTracker
24
+ from pal.token_tracker import TokenTracker
25
+ from pal.tokens import estimate_tokens
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class ContextBundle:
32
+ turns: list[Turn] # what goes into the request, chronological (some are compressed copies)
33
+ blocks: list[Block] # kept, in the order they go into the request
34
+ report: ContextReport
35
+ paged: PagedHistory | None
36
+ recall: Any
37
+ doc_info: dict | None
38
+ graph_info: dict | None
39
+ compressed: int = 0 # compressed turns that were kept
40
+ compression_saved: int = 0 # tokens saved versus showing those turns verbatim
41
+ cold_turns: int = 0 # turns shown only as facts
42
+
43
+ @property
44
+ def texts(self) -> list[str]:
45
+ return [b.text for b in self.blocks]
46
+
47
+ @property
48
+ def uses_compression(self) -> bool:
49
+ return self.compressed > 0 or any(b.source == "facts" for b in self.blocks)
50
+
51
+
52
+ class ChatContext:
53
+ def __init__(self, cfg: ContextConfig, pager: TurnPager, notes, graph,
54
+ local_inner: Callable[[], Any] | None = None, count: Callable[[str], int] = estimate_tokens,
55
+ tokens: TokenTracker | None = None, memory_tracker: MemoryTracker | None = None):
56
+ self.cfg, self.pager, self.notes, self.graph, self.count = cfg, pager, notes, graph, count
57
+ self.tokens, self.memory_tracker = tokens or TokenTracker(count), memory_tracker or MemoryTracker()
58
+ self.assembler = ContextAssembler(cfg.total_budget_tokens, count)
59
+ self.compression = HistoryCompressor(cfg, pager.turn_cost, count)
60
+
61
+ # -- gathering and fitting -------------------------------------------------------------------------------------
62
+ def build(self, sess, sid: str, message: str, new_docs: set[str], project: bool, counter, ephemeral: bool, m) -> ContextBundle:
63
+ all_turns: list[Turn] = sess.turns
64
+ paged = self.pager.select(all_turns, message) if self.cfg.enabled else None
65
+ selected = paged.turns if paged else list(all_turns)
66
+ layered = self.compression.layer(all_turns, selected, paged)
67
+ history, compact_ids, cold_idx = layered.history, layered.compact_ids, layered.cold_idx
68
+ warm_verbatim, warm_compact = layered.warm_verbatim, layered.warm_compact
69
+
70
+ blocks: list[Block] = []
71
+ facts = self.compression.facts(all_turns, cold_idx)
72
+ if facts:
73
+ blocks.append(Block("facts", facts))
74
+
75
+ recall = self.notes.recall(sid, message) if not ephemeral else None
76
+ if recall:
77
+ blocks.append(Block("memory", recall.text))
78
+ self.memory_tracker.note_hits(m, recall.hits)
79
+
80
+ doc_info = None
81
+ if sess.docs is not None and any(n not in new_docs for n in sess.docs.docs):
82
+ block, used, n_chunks = sess.docs.excerpts(message, exclude=new_docs)
83
+ earlier = sum(d.tokens for nme, d in sess.docs.docs.items() if nme not in new_docs)
84
+ self.tokens.credit(m, "documents", earlier - used)
85
+ if block:
86
+ blocks.append(Block("documents", block))
87
+ doc_info = {"chunks": n_chunks, "tokens": used, "indexed_tokens": earlier}
88
+
89
+ graph_info = None
90
+ if project:
91
+ gb = self.graph.context_block(sid, message, counter)
92
+ if gb:
93
+ blocks.append(Block("graph", gb.text))
94
+ self.tokens.credit(m, "code_graph", gb.saved_tokens)
95
+ graph_info = gb.info
96
+
97
+ turns, kept, report = self.assembler.fit(history, blocks, self.pager.turn_cost)
98
+ facts_kept = sum(b.tokens for b in kept if b.source == "facts")
99
+ kept_compact = [t for t in turns if id(t) in compact_ids]
100
+ self.tokens.credit(m, "context_paging", paged.full_tokens - sum(self.pager.turn_cost(t) for t in turns) - facts_kept if paged else 0)
101
+ m.compressed_turns = len(kept_compact)
102
+ saved = (warm_verbatim - warm_compact) if kept_compact else 0
103
+ m.compression_saved = saved
104
+ return ContextBundle(turns, kept, report, paged, recall, doc_info, graph_info, len(kept_compact), saved,
105
+ len(cold_idx) if facts_kept else 0)
106
+
107
+ def describe(self, sess, bundle: ContextBundle, m) -> dict:
108
+ r = bundle.report
109
+ return {"turns_sent": len(bundle.turns), "turns_total": len(sess.turns) - 1,
110
+ "recalled": bundle.paged.recalled if bundle.paged else 0, "tokens_saved": m.saved_paging,
111
+ "budget": r.budget, "used": r.used, "by_source": r.by_source, "dropped": r.dropped,
112
+ "compression": {"compressed_turns": bundle.compressed, "tokens_saved": bundle.compression_saved,
113
+ "facts_from_turns": bundle.cold_turns},
114
+ "history_tokens": composition(sess.turns, self.count)} # what is big in the whole conversation so far
115
+
116
+
117
+ __all__ = ["ChatContext", "ContextBundle", "MARK"]
@@ -0,0 +1,5 @@
1
+ from pal.context_manager.components.context_cache import CacheEntry, ContextCache
2
+ from pal.context_manager.components.kv_bridge import KVBridge
3
+ from pal.context_manager.components.pager import ContextPager, Segment
4
+
5
+ __all__ = ["CacheEntry", "ContextCache", "KVBridge", "ContextPager", "Segment"]