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/chat/providers.py ADDED
@@ -0,0 +1,23 @@
1
+ """API providers the chat can talk to. Local models are never chat models: they only route, reduce
2
+ and verify. API keys are used in memory for the request and never logged or stored."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from pal.errors import UserInputError
8
+ from pal.llm_wrapper.components.base import WrapperConfig
9
+ from pal.llm_wrapper.modes import PROVIDER_KEYS
10
+ from pal.llm_wrapper.utils.registry import build_wrapper
11
+
12
+ API_PROVIDERS = PROVIDER_KEYS # the table lives with the two modes (llm_wrapper/modes.py)
13
+
14
+
15
+ def build_api_core(provider: str, model: str, api_key: str | None = None):
16
+ if provider not in API_PROVIDERS:
17
+ raise UserInputError(f"unsupported provider {provider!r}; API models only: {', '.join(API_PROVIDERS)}")
18
+ if not model:
19
+ raise UserInputError("choose a model")
20
+ key = api_key or os.environ.get(API_PROVIDERS[provider])
21
+ if not key:
22
+ raise UserInputError(f"no API key: paste one in settings or set {API_PROVIDERS[provider]}")
23
+ return build_wrapper(WrapperConfig(backend=provider, model=model, api_key=key))
pal/chat/service.py ADDED
@@ -0,0 +1,591 @@
1
+ """One chat turn, end to end.
2
+
3
+ message -> [direct answer: arithmetic / time, no model]
4
+ -> [route: local LLM answers easy tasks itself]
5
+ -> API path: reduce input (local LLM) -> attach documents / project graph excerpts / memory
6
+ -> page history (last turns + relevant older ones) -> API model (+ read-only tool loop)
7
+ -> record history, memory, metrics
8
+
9
+ Everything the chat touches goes through the AgentSDK (chat, memory, storage, tools), which sits on
10
+ the storage, memory and tool managers. Extra context (documents, graph excerpts, memory) is added to the
11
+ request but never stored in the history, so it is not resent on later turns.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import logging
17
+ import os
18
+ import time
19
+ import uuid
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from pal.access_manager.pipeline import AccessManagerPipeline
25
+ from pal.chat.providers import build_api_core
26
+ from pal.chat.services import selected
27
+ from pal.chat.sessions import Session, SessionStore, validate_session_id
28
+ from pal.chat.tools import ToolBox, direct_answer, strip_tool_tags
29
+ from pal.config import Settings, load_settings
30
+ from pal.context_manager import Turn, TurnPager
31
+ from pal.context_manager.chat_context import ChatContext
32
+ from pal.documents import DocumentIndex
33
+ from pal.errors import DependencyMissingError, PalError, ProviderError, UserInputError
34
+ from pal.input_savers.components.base import SaverResult
35
+ from pal.input_savers.pipeline import InputSaverPipeline
36
+ from pal.knowledge_graph import GRAPH_NOTICE, KnowledgeGraph
37
+ from pal.llm_scheduler import LLMScheduler, TaskClass, TurnStats, current_stats, is_rate_limit
38
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, UnavailableWrapper, WrapperConfig
39
+ from pal.llm_wrapper.components.openrouter_wrapper import fetch_models, search_models
40
+ from pal.llm_wrapper.utils.registry import build_wrapper
41
+ from pal.memory.pipeline import MemoryPipeline
42
+ from pal.memory_tracker import dir_bytes
43
+ from pal.metrics import MetricsPipeline
44
+ from pal.metrics.components.collector import TurnMetric
45
+ from pal.notes_manager import NotesManager
46
+ from pal.os_kernel import OSKernel
47
+ from pal.paths import data_dir, safe_id
48
+ from pal.redact import redact
49
+ from pal.sdk.agent import AgentSpec, NativeAgent, agent_key, get_spec
50
+ from pal.sdk.pipeline import AgentSDK
51
+ from pal.storage_manager.pipeline import StorageManagerPipeline
52
+ from pal.task_router import API_NOTICE, LOCAL_TAG, RouteDecision, TaskRouter
53
+ from pal.tokens import estimate_tokens, make_counter
54
+ from pal.tool_manager.pipeline import ToolManagerPipeline
55
+
56
+ log = logging.getLogger(__name__)
57
+
58
+ DOCS_NOTICE = "Attached files are indexed. A request may end with <documents> holding the parts relevant to it."
59
+ PAGING_NOTICE = "Older turns may be omitted; the relevant ones are restored."
60
+ COMPRESS_NOTICE = ("Some older assistant replies are shown compressed ([compressed]): exact numbers, names, paths and code signatures "
61
+ "are kept; old code bodies and replaced drafts are left out. A <facts> block, when present, lists exact details "
62
+ "from older turns that are not shown.")
63
+ _PROJECT_KIND = "project"
64
+
65
+
66
+ @dataclass
67
+ class TurnResult:
68
+ reply: str
69
+ handled_by: str # "api" | "local" | "tool"
70
+ route_reason: str = ""
71
+ sent: str = "" # the last user message the API model received
72
+ input_tokens: int = 0
73
+ output_tokens: int = 0
74
+ saver: object = None
75
+ graph: Optional[dict] = None
76
+ documents: Optional[dict] = None
77
+ memory: Optional[dict] = None
78
+ context: Optional[dict] = None
79
+ tools: list = field(default_factory=list)
80
+ fallback: Optional[dict] = None # {"provider", "model", "reason"} when the fallback model answered
81
+ cost_usd: float = 0.0
82
+ files: list = field(default_factory=list) # files an agent saved to its workspace this turn
83
+ local_tokens: Optional[dict] = None
84
+ latency: dict = field(default_factory=dict)
85
+
86
+ def to_dict(self) -> dict:
87
+ return {f: getattr(self, f) for f in self.__dataclass_fields__}
88
+
89
+
90
+ def is_fallback_error(e: BaseException) -> bool:
91
+ """Rate limits, quota, timeouts, connection failures and server errors: the kinds a second model can fix.
92
+ A bad key, a bad request or an unknown model would fail the same way on any provider, so they are not retried elsewhere."""
93
+ if is_rate_limit(e) or isinstance(e, (TimeoutError, ConnectionError)):
94
+ return True
95
+ for attr in ("status_code", "code"):
96
+ code = getattr(e, attr, None)
97
+ if isinstance(code, int) and code >= 500:
98
+ return True
99
+ return any(k in type(e).__name__.lower() for k in ("timeout", "connection", "serviceunavailable", "internalserver"))
100
+
101
+
102
+ class ChatService:
103
+ def __init__(self, settings: Optional[Settings] = None, local_core=None, verifier_core=None, graph=None,
104
+ storage: Optional[StorageManagerPipeline] = None, metrics: Optional[MetricsPipeline] = None,
105
+ agents: Optional[dict[str, type[NativeAgent]]] = None):
106
+ self.settings = settings or load_settings()
107
+ if agents is None:
108
+ from pal.agents import AGENTS # the application layer is plugged in here, at composition time
109
+ agents = AGENTS
110
+ self.agents = agents
111
+ s = self.settings
112
+ self.scheduler = LLMScheduler(s.scheduler) # API: first come first served per key. Local: preemptive priority.
113
+ self._local = self.scheduler.wrap_local(local_core)
114
+ self._verifier = self.scheduler.wrap_local(verifier_core)
115
+ self.metrics = metrics or MetricsPipeline(max_records=s.metrics.max_records, enabled=s.metrics.enabled)
116
+ self.graph = graph or KnowledgeGraph(s.knowledge_graph)
117
+ self.preload_docs: list[tuple[str, str]] = [] # (name, markdown) indexed into every new session: the project folder's documents and map
118
+ home = Path(s.storage_dir).expanduser() if s.storage_dir else data_dir()
119
+ self.storage = storage or StorageManagerPipeline(root=str(home / "storage"))
120
+ self.access = AccessManagerPipeline()
121
+ self.os_kernel = OSKernel(s.os_kernel, access=self.access, root=home / "workspaces")
122
+ selected(s.tools.services) # an unknown service name stops the start-up, with the list to choose from
123
+ self._memory_pipeline = MemoryPipeline(
124
+ store_path=None, block_limit=s.memory.block_limit, agent_id=f"profile-{safe_id(s.memory.profile)}",
125
+ storage_manager=self.storage)
126
+ self._memory_sdk = self._new_sdk(f"profile-{safe_id(s.memory.profile)}", ToolManagerPipeline())
127
+ self.notes = NotesManager(self._memory_sdk, s.memory, scheduler=self.scheduler)
128
+ self.pager = TurnPager(s.context.hot_turns, s.context.recall_turns, s.context.recall_threshold,
129
+ s.context.max_history_tokens)
130
+ self.context = ChatContext(s.context, self.pager, self.notes, self.graph, tokens=self.metrics.tokens, memory_tracker=self.metrics.memory)
131
+ self.sessions = SessionStore(self._new_session, s.server.max_sessions, s.server.session_ttl_minutes * 60)
132
+ mt = self.metrics.memory # what the Memory Tracker can report on
133
+ mt.register("notes", self.notes.stats)
134
+ mt.register("sessions", lambda: {"open": len(self.sessions), "max": s.server.max_sessions})
135
+ mt.register("knowledge_graph", self.graph.stats)
136
+ mt.register("storage", lambda: {"bytes": dir_bytes(home / "storage")})
137
+ try: # projects left behind by a crash or a failed request
138
+ self.graph.prune_stale(s.server.session_ttl_minutes * 60)
139
+ self.os_kernel.prune_stale("chat-", s.server.session_ttl_minutes * 60)
140
+ except Exception: # noqa: BLE001 (housekeeping must never stop startup)
141
+ log.exception("could not prune stale graph projects")
142
+
143
+ # -- construction helpers -----------------------------------------------------------------
144
+ def _new_sdk(self, agent_id: str, tools) -> AgentSDK:
145
+ return AgentSDK(agent_id=agent_id, llm_wrapper=None, storage_manager=self.storage, tool_manager=tools,
146
+ access_manager=self.access, memory=self._memory_pipeline, os_kernel=self.os_kernel)
147
+
148
+ def _new_session(self, session_id: str) -> Session:
149
+ s = self.settings
150
+ agent_id = f"chat-{safe_id(session_id)}"
151
+ box = ToolBox(agent_id, s.tools, estimate_tokens, session_id=session_id, os_kernel=self.os_kernel, env=os.environ)
152
+ sdk = self._new_sdk(agent_id, box.manager)
153
+ docs = DocumentIndex(sdk, s.documents, estimate_tokens) if s.documents.enabled else None
154
+ if docs is not None:
155
+ for name, text in self.preload_docs:
156
+ try:
157
+ docs.add(name, text)
158
+ except Exception: # noqa: BLE001 (one unreadable document never stops a session)
159
+ log.exception("could not index %s", name)
160
+ box.bind(docs=docs, graph=self.graph if self.graph.enabled else None)
161
+ box.code_run = self.scheduler.code.run # converting and graph building run in the code slots, beside the LLM lines
162
+ sess = Session(id=session_id, agent_id=agent_id, sdk=sdk, docs=docs, toolbox=box)
163
+ sess.on_close = self._close_session
164
+ return sess
165
+
166
+ def _close_session(self, sess: Session) -> None:
167
+ sess.sdk.close()
168
+ self.graph.reset(sess.id)
169
+ self.os_kernel.drop_workspace(sess.agent_id) # uploaded and generated files do not outlive the chat
170
+
171
+ # -- cores --------------------------------------------------------------------------------------
172
+ @property
173
+ def local_off(self) -> bool:
174
+ """True on a computer without a GPU (local_llm.enabled: false): no local model at all, so routing, input trimming, checking and
175
+ summaries by the local model are off and every request goes to the API model as typed. Savers that need no model (paging,
176
+ document and code excerpts, memory) still work."""
177
+ return self.settings.local_llm.get("enabled", True) is False
178
+
179
+ def _build_local(self, cfg: dict):
180
+ cfg = {k: v for k, v in cfg.items() if k != "enabled"}
181
+ try:
182
+ return self.scheduler.wrap_local(build_wrapper(WrapperConfig(**cfg)))
183
+ except DependencyMissingError as e: # fail safe: everything that asks the local model falls back to the API
184
+ log.warning("the local model cannot be set up: %s", e)
185
+ return self.scheduler.wrap_local(UnavailableWrapper(str(e)))
186
+
187
+ def local_core(self):
188
+ if self._local is None:
189
+ self._local = self._build_local(self.settings.local_llm)
190
+ return self._local
191
+
192
+ def verifier_core(self):
193
+ if self._verifier is None:
194
+ v = self.settings.verifier_llm
195
+ self._verifier = self._build_local(v) if v else self.local_core()
196
+ return self._verifier
197
+
198
+ def api_core(self, provider: str, model: str, api_key: Optional[str] = None):
199
+ return build_api_core(provider, model, api_key)
200
+
201
+ def model_catalogue(self, text: str = "", free_only: bool = False, limit: int = 50) -> list[dict]:
202
+ """OpenRouter's models for the Explore mode: search by words in the name, optionally only free ones. `self.catalogue_fetch` replaces the
203
+ network read (for tests)."""
204
+ try:
205
+ models = fetch_models(getattr(self, "catalogue_fetch", None))
206
+ except Exception as e: # noqa: BLE001
207
+ raise ProviderError(f"could not read OpenRouter's model list ({type(e).__name__}); you can still type a model name yourself") from e
208
+ return [{**asdict(m), "label": m.label()} for m in search_models(models, text, free_only, limit)]
209
+
210
+ def router(self) -> TaskRouter:
211
+ cfg = self.settings.task_routing
212
+ if self.local_off: # no local model: nothing is answered locally, everything goes to the API model
213
+ import copy
214
+ off = copy.copy(TaskRouter(None, cfg).cfg)
215
+ off.enabled = False
216
+ return TaskRouter(None, off)
217
+ return TaskRouter(self.local_core() if cfg.enabled else None, cfg)
218
+
219
+ # -- prompt pieces ---------------------------------------------------------------------------------
220
+ def output_config(self) -> str:
221
+ return self.settings.read_text_file(self.settings.output_config)
222
+
223
+ def system_prompt(self, with_output_config: bool = True, local_turns: bool = False, graph_project: bool = False,
224
+ documents: bool = False, paged: bool = False, tools: str = "", agent_role: str = "",
225
+ compressed: bool = False) -> str:
226
+ prompt = self.settings.base_system_prompt
227
+ if agent_role:
228
+ prompt += f"\n\n{agent_role}"
229
+ cfg = self.output_config() if with_output_config else ""
230
+ if cfg:
231
+ prompt += f"\n\n{cfg}"
232
+ for on, text in ((local_turns, API_NOTICE), (graph_project, GRAPH_NOTICE), (documents, DOCS_NOTICE),
233
+ (paged, PAGING_NOTICE), (compressed, COMPRESS_NOTICE), (bool(tools), tools)):
234
+ if on:
235
+ prompt += f"\n\n{text}"
236
+ return prompt
237
+
238
+ def saver(self, counter) -> InputSaverPipeline:
239
+ rules = self.settings.read_text_file(self.settings.input_rules) or None
240
+ return InputSaverPipeline(self.local_core(), self.verifier_core(), counter, rules_text=rules)
241
+
242
+ def reduce(self, counter, text: str, mode: Optional[str], methods: Optional[list], pasted: list):
243
+ """Every chatbox input goes through the local LLM here, before any API call."""
244
+ if self.local_off: # no local model: the message goes as typed
245
+ n = counter(text) if callable(counter) else estimate_tokens(text)
246
+ return SaverResult(text, text, [], n, n, "off")
247
+ mode = mode or self.settings.input_mode
248
+ saver = self.saver(counter)
249
+ if mode == "steps":
250
+ return saver.run(text, self.enabled_methods(methods), pasted)
251
+ return saver.run_rules(text, pasted)
252
+
253
+ def enabled_methods(self, requested: Optional[list]) -> list:
254
+ return [m for m in self.settings.input_methods if requested is None or m in requested]
255
+
256
+ # -- one turn ----------------------------------------------------------------------------------------
257
+ def send(self, session_id: str, provider: str, model: str, api_key: Optional[str], message: str,
258
+ attachments: list[dict], methods: Optional[list] = None, mode: Optional[str] = None,
259
+ pasted: Optional[list] = None, route: Optional[str] = None, ephemeral: bool = False,
260
+ agent: Optional[str] = None, allow_tools: Optional[frozenset] = None, query: Optional[str] = None) -> TurnResult:
261
+ """`ephemeral` turns (the demo) read and write no memory and are not added to the metrics.
262
+ `agent` runs the turn as that agent: Gemini with the agent's own key, API route, its role and tools.
263
+ `allow_tools` limits the turn to those tools (an empty set means none); None leaves every enabled tool available.
264
+ `query` is what to look up memory, document passages and code excerpts with, when the message carries more than the person's own words
265
+ (the terminal adds the folder listing and the agent's instructions); None uses the message."""
266
+ spec = self._resolve_agent(agent)
267
+ if spec is not None:
268
+ provider, model, route = "gemini", self.settings.agents.model, "api"
269
+ api_key = agent_key(spec)
270
+ sid = validate_session_id(session_id or uuid.uuid4().hex)
271
+ if not (message or "").strip() and not attachments:
272
+ raise UserInputError("empty message")
273
+ sess = self.sessions.get_or_create(sid)
274
+ m = TurnMetric(route="api", session=sid[:8], provider=provider, model=model)
275
+ start = time.perf_counter()
276
+ cls = TaskClass.AGENT if spec is not None else TaskClass.CHAT
277
+ with sess.lock, self.scheduler.task(cls), self.scheduler.track() as stats:
278
+ try:
279
+ res = self._turn(sess, provider, model, api_key, message, attachments, methods, mode, pasted or [],
280
+ route, m, ephemeral, spec, allow_tools, query)
281
+ except Exception as e:
282
+ m.error = type(e).__name__
283
+ m.latency_total = time.perf_counter() - start
284
+ self.metrics.latency.finish(m)
285
+ self._apply_stats(m, stats)
286
+ if not ephemeral:
287
+ self.metrics.record_turn(m)
288
+ raise
289
+ self._apply_stats(m, stats)
290
+ self.notes.after_turn(ephemeral) # background: merging duplicate notes (OTHER class)
291
+ m.route = res.handled_by
292
+ m.latency_total = time.perf_counter() - start
293
+ self.metrics.latency.finish(m)
294
+ res.latency = {"total": round(m.latency_total, 3), "local": round(m.latency_local, 3),
295
+ "api": round(m.latency_api, 3), "tools": round(m.latency_tools, 3)}
296
+ if not ephemeral:
297
+ self.metrics.record_turn(m)
298
+ return res
299
+
300
+ def _summarize_text(self, text: str) -> str:
301
+ """For the summarize_text tool: a short summary of `text` by the local model."""
302
+ core = self.local_core()
303
+ system = "Summarise the text in at most 150 words. Plain text, no preface."
304
+ return core.chat(LLMSyscall(messages=[{"role": "system", "content": system}, {"role": "user", "content": text}],
305
+ temperature=0.0, max_tokens=300)).text
306
+
307
+ @staticmethod
308
+ def _apply_stats(m: TurnMetric, stats: TurnStats) -> None:
309
+ m.wait_local, m.wait_api, m.local_pauses = round(stats.wait_local, 4), round(stats.wait_api, 4), stats.local_pauses
310
+
311
+ def _resolve_agent(self, agent: Optional[str]) -> Optional[AgentSpec]:
312
+ if not agent:
313
+ return None
314
+ if not self.settings.agents.enabled:
315
+ raise UserInputError("agents are turned off in the settings")
316
+ spec = get_spec(self.agents, agent)
317
+ if not agent_key(spec):
318
+ raise UserInputError(f"the {spec.name} needs a Gemini API key: set {spec.key_env} in your .env file")
319
+ return spec
320
+
321
+ def _remember(self, sid: str, message: str, ephemeral: bool = False) -> None:
322
+ if ephemeral:
323
+ return
324
+ self.notes.remember(sid, message)
325
+
326
+ def _turn(self, sess: Session, provider, model, api_key, message, attachments, methods, mode, pasted, route,
327
+ m: TurnMetric, ephemeral: bool = False, spec: Optional[AgentSpec] = None,
328
+ allow_tools: Optional[frozenset] = None, query: Optional[str] = None) -> TurnResult:
329
+ s = self.settings
330
+ sid = sess.id
331
+ sess.toolbox.allow = spec.tools if spec else allow_tools
332
+ sess.toolbox.saved_files = []
333
+ sess.toolbox.summarizer = None if self.local_off else self._summarize_text # the local model, in this turn's priority class
334
+ history_msgs = [x for t in sess.turns for x in (
335
+ {"role": "user", "content": t.user},
336
+ {"role": "assistant", "content": (f"{LOCAL_TAG} " if t.local else "") + t.assistant})]
337
+ project = self.graph.has_project(sid)
338
+
339
+ # 1. no model needed
340
+ if s.tools.enabled and s.tools.direct_answers and not attachments and route != "api":
341
+ with self.metrics.latency.stage(m, "direct"):
342
+ direct = direct_answer(message)
343
+ if direct is not None:
344
+ sess.turns.append(Turn(message, direct, local=True))
345
+ return TurnResult(reply=direct, handled_by="tool", route_reason="answered without a model")
346
+
347
+ # 2. routing
348
+ t0 = time.perf_counter()
349
+ router = self.router()
350
+ if project and route in (None, "", "auto") and router.enabled:
351
+ decision = RouteDecision("api", "a code project is loaded", "gate")
352
+ else:
353
+ with self.metrics.latency.stage(m, "routing"):
354
+ decision = router.decide(message, attachments, route, history_msgs)
355
+ if decision.route == "local":
356
+ with self.metrics.latency.stage(m, "local_answer"), self.scheduler.task(TaskClass.AGENT if spec is not None else TaskClass.CHAT, preemptible=True):
357
+ ans = router.answer(message, history_msgs, attachments if decision.source == "override" else None)
358
+ m.latency_local += time.perf_counter() - t0
359
+ if ans.text is not None:
360
+ sess.turns.append(Turn(message, ans.text, local=True))
361
+ self._remember(sid, message, ephemeral)
362
+ m.local_tokens = ans.prompt_tokens + ans.completion_tokens
363
+ return TurnResult(reply=ans.text, handled_by="local", route_reason=decision.reason,
364
+ local_tokens={"input": ans.prompt_tokens, "output": ans.completion_tokens})
365
+ if decision.source == "override":
366
+ raise UserInputError(f"local model could not answer ({ans.reason}); switch routing to auto or API")
367
+ decision = RouteDecision("api", ans.reason, "escalated")
368
+ m.escalated = True
369
+ else:
370
+ m.latency_local += time.perf_counter() - t0
371
+
372
+ # 3. API path
373
+ api = self.api_core(provider, model, api_key)
374
+ counter = self.metrics.tokens.counter_for(s.token_counter, api)
375
+ t0 = time.perf_counter()
376
+ with self.metrics.latency.stage(m, "input_saver"):
377
+ result = self.reduce(counter, message, mode, methods, pasted)
378
+ m.latency_local += time.perf_counter() - t0
379
+ self.metrics.tokens.credit(m, "input_savers", result.tokens_saved)
380
+
381
+ # attachments: project summaries and small notes go inline; documents are indexed
382
+ file_blocks, stubs, new_docs = [], [], set()
383
+ for a in attachments:
384
+ note = f" | {a['note']}" if a.get("note") else ""
385
+ if a.get("kind") in (_PROJECT_KIND, "media") or sess.docs is None:
386
+ file_blocks.append(f"[file: {a['name']}{note}]\n{a['text']}")
387
+ stubs.append(f"[file: {a['name']}{note}]")
388
+ continue
389
+ sess.docs.add(a["name"], a["text"])
390
+ new_docs.add(a["name"])
391
+ file_blocks.append(f"[file: {a['name']}{note}]\n{sess.docs.upload_view(a['name'], a['text'], bool(s.tools.enabled))}")
392
+ stubs.append(sess.docs.stub(a["name"]))
393
+ file_block = "".join(f"\n\n{b}" for b in file_blocks)
394
+ history_user = result.reduced + "".join(f"\n{x}" for x in stubs)
395
+
396
+ # everything besides the message itself: history (compressed by age), memory, document and code excerpts, under one budget
397
+ with self.metrics.latency.stage(m, "context"):
398
+ bundle = self.context.build(sess, sid, (query or "").strip() or message, new_docs, project, counter, ephemeral, m)
399
+ turns, paged, recall = bundle.turns, bundle.paged, bundle.recall
400
+ doc_info, graph_info = bundle.doc_info, bundle.graph_info
401
+ extras = bundle.texts
402
+
403
+ request_content = result.reduced + file_block + "".join(f"\n\n{x}" for x in extras)
404
+ system = self.system_prompt(
405
+ local_turns=any(t.local for t in turns), graph_project=project,
406
+ documents=bool(sess.docs and sess.docs.docs), paged=bool(paged and paged.dropped_turns),
407
+ compressed=bundle.uses_compression,
408
+ tools=sess.toolbox.prompt(message), agent_role=spec.role if spec else "")
409
+ messages: list[dict] = [{"role": "system", "content": system}]
410
+ for t in turns:
411
+ messages += [{"role": "user", "content": t.user},
412
+ {"role": "assistant", "content": (f"{LOCAL_TAG} " if t.local else "") + t.assistant}]
413
+ messages.append({"role": "user", "content": request_content})
414
+
415
+ sess.toolbox.price_fn = lambda a=api: getattr(a, "price_per_token", lambda: None)()
416
+ key = api_key or getattr(getattr(api, "config", None), "api_key", None) or ""
417
+ line = (f"{provider}:{hashlib.sha1(key.encode()).hexdigest()[:8]}", self.scheduler.interval_for(provider, model)) # noqa: S324
418
+ reply, in_tok, out_tok, tool_log = self._call_with_tools(sess, api, messages, m, message, spec, line)
419
+ sess.turns.append(Turn(history_user, reply, files=tuple(sess.toolbox.saved_files)))
420
+ self._remember(sid, message, ephemeral)
421
+ m.input_tokens, m.output_tokens = in_tok, out_tok
422
+ return TurnResult(
423
+ reply=reply, handled_by="api", route_reason=decision.reason, sent=request_content,
424
+ input_tokens=in_tok, output_tokens=out_tok, saver=result, graph=graph_info, documents=doc_info,
425
+ memory={"notes": recall.hits, "tokens": recall.tokens} if recall else None,
426
+ context=self.context.describe(sess, bundle, m),
427
+ tools=tool_log,
428
+ fallback=({"provider": self.settings.fallback.provider, "model": self.settings.fallback.model,
429
+ "reason": m.fallback_reason} if m.fallback else None),
430
+ cost_usd=m.cost_usd, files=list(sess.toolbox.saved_files))
431
+
432
+ # -- one API call through its line, with the optional fallback -----------------------------------------------------
433
+ def _api_once(self, sess: Session, api, messages: list[dict], spec: Optional[AgentSpec], line: tuple[str, float]):
434
+ def api_call(msgs: list[dict] = messages) -> LLMResponse:
435
+ return sess.sdk.chat_response(msgs, core=api, max_tokens=spec.max_tokens if spec else 2048)
436
+
437
+ ticket = self.scheduler.api.submit(line[0], api_call, line[1], label="api call")
438
+ return ticket.result(), ticket # waits its turn in the line (strictly first come, first served)
439
+
440
+ @staticmethod
441
+ def _provider_error(api, e: BaseException) -> ProviderError:
442
+ text = redact(str(e), getattr(getattr(api, "config", None), "api_key", None))
443
+ if any(k in text.lower() for k in ("429", "resource_exhausted", "quota", "rate limit")):
444
+ return ProviderError("rate limit or quota reached for this API key (free tiers allow only a few "
445
+ "requests per minute and per day). Wait a minute and try again.")
446
+ return ProviderError(f"{type(e).__name__}: {text}")
447
+
448
+ def _fallback_for(self, e: BaseException, spec: Optional[AgentSpec]):
449
+ """(core, line) for the fallback model if this failure is the kind it is for, else None."""
450
+ fb = self.settings.fallback
451
+ if not (fb.enabled and fb.model) or (spec is not None and not fb.for_agents) or not is_fallback_error(e):
452
+ return None
453
+ try:
454
+ core = self.api_core(fb.provider, fb.model, None)
455
+ except (UserInputError, PalError) as why:
456
+ log.warning("fallback to %s/%s is not available: %s", fb.provider, fb.model, why)
457
+ return None
458
+ key = getattr(getattr(core, "config", None), "api_key", None) or ""
459
+ return core, (f"{fb.provider}:{hashlib.sha1(key.encode()).hexdigest()[:8]}", # noqa: S324
460
+ self.scheduler.interval_for(fb.provider, fb.model))
461
+
462
+ def _call_with_tools(self, sess: Session, api, messages: list[dict], m: TurnMetric, message: str,
463
+ spec: Optional[AgentSpec] = None, line: tuple[str, float] = ("api", 0.0)):
464
+ """API call, then up to tools.max_rounds read-only tool calls. Returns (reply, tokens in, out, log)."""
465
+ cfg = self.settings.tools
466
+ in_tok = out_tok = 0
467
+ log_: list[dict] = []
468
+ rounds = cfg.max_rounds if (cfg.enabled and sess.toolbox.available(message)) else 0
469
+ for i in range(rounds + 1):
470
+ try:
471
+ resp, ticket = self._api_once(sess, api, messages, spec, line)
472
+ except UserInputError:
473
+ raise
474
+ except Exception as e:
475
+ alt = None if m.fallback else self._fallback_for(e, spec)
476
+ if alt is None:
477
+ raise self._provider_error(api, e) from e
478
+ fb = self.settings.fallback
479
+ log.warning("answering with the fallback model %s/%s after %s", fb.provider, fb.model, type(e).__name__)
480
+ api, line = alt
481
+ m.fallback, m.fallback_reason = f"{fb.provider}/{fb.model}", type(e).__name__
482
+ try:
483
+ resp, ticket = self._api_once(sess, api, messages, spec, line)
484
+ except UserInputError:
485
+ raise
486
+ except Exception as e2:
487
+ raise self._provider_error(api, e) from e2 # report the original failure, not the fallback's
488
+ m.latency_api += ticket.run_seconds
489
+ m.cost_usd += float((resp.meta or {}).get("cost_usd") or 0.0)
490
+ stats = current_stats()
491
+ if stats is not None:
492
+ stats.add_api(ticket)
493
+ m.api_calls += 1
494
+ in_tok += resp.prompt_tokens or 0
495
+ out_tok += resp.completion_tokens or 0
496
+ call = sess.toolbox.parse(resp.text or "") if i < rounds else None
497
+ if call is None:
498
+ price = getattr(api, "price_per_token", lambda: None)()
499
+ if price: # dollars the savings were worth at this model's input price
500
+ m.saved_cost_usd = round(m.saved_total * price[0], 6)
501
+ return strip_tool_tags(resp.text or ""), in_tok, out_tok, log_
502
+ outcome = sess.toolbox.run(call)
503
+ m.tool_calls += 1
504
+ m.latency_tools += outcome.seconds
505
+ log_.append({"name": outcome.name, "ok": outcome.ok, "seconds": round(outcome.seconds, 3)})
506
+ messages = messages + [
507
+ {"role": "assistant", "content": resp.text},
508
+ {"role": "user", "content": f'<tool_result name="{outcome.name}">\n{outcome.text}\n</tool_result>'}]
509
+ return "", in_tok, out_tok, log_ # unreachable: the last round never parses a call
510
+
511
+ def local_status(self) -> dict:
512
+ """Is the local LLM reachable and does it have the model? {"ok": True|False|None, "detail": str}"""
513
+ local = self.settings.local_llm
514
+ if self.local_off:
515
+ return {"ok": None, "detail": "off: no GPU on this computer, so Pal uses API models only"}
516
+ if local.get("backend") != "ollama":
517
+ return {"ok": None, "detail": f"{local.get('backend')} (not checked)"}
518
+ import requests
519
+ host = (local.get("host") or "http://localhost:11434").rstrip("/")
520
+ try:
521
+ tags = requests.get(host + "/api/tags", timeout=3).json()
522
+ names = [x["name"] for x in tags.get("models", [])]
523
+ have = any(n == local["model"] or n.startswith(local["model"] + ":") for n in names)
524
+ return {"ok": have, "detail": f"{local['model']} " + (
525
+ "ready" if have else f"not pulled (ollama pull {local['model']})")}
526
+ except Exception as e: # noqa: BLE001 (any failure means "not reachable")
527
+ return {"ok": False, "detail": f"Ollama unreachable: {e.__class__.__name__}"}
528
+
529
+ # -- housekeeping ----------------------------------------------------------------------------------------
530
+ def reset(self, session_id: str) -> None:
531
+ self.sessions.remove(session_id)
532
+ try:
533
+ sid = validate_session_id(session_id)
534
+ self.graph.reset(sid) # also covers a project uploaded before any turn
535
+ self.os_kernel.drop_workspace(f"chat-{safe_id(sid)}") # and an image or audio file uploaded before any turn
536
+ except UserInputError:
537
+ pass
538
+
539
+ def clear_memory(self) -> int:
540
+ return self.notes.clear()
541
+
542
+ def memory_count(self) -> int:
543
+ return self.notes.count_notes()
544
+
545
+ def warm_up_local(self):
546
+ """A background (OTHER class) job: load the local model so the next real job starts fast. Returns its ticket."""
547
+ if self.local_off:
548
+ return None
549
+ core = self.local_core()
550
+ inner = getattr(core, "core", core)
551
+ def ping() -> LLMResponse:
552
+ return inner.chat(LLMSyscall(messages=[{"role": "user", "content": "hi"}], max_tokens=1))
553
+
554
+ if self.scheduler.local is None:
555
+ return ping()
556
+ return self.scheduler.local.submit(ping, TaskClass.OTHER, "warm up the local model")
557
+
558
+ def close(self) -> None:
559
+ self.sessions.close_all()
560
+ self.scheduler.close()
561
+
562
+ # -- experiment ---------------------------------------------------------------------------------------------
563
+ def experiment(self, text: str, methods: Optional[list], provider: str = "", model: str = "",
564
+ api_key: Optional[str] = None, call_api: bool = False, mode: Optional[str] = None,
565
+ pasted: Optional[list] = None) -> dict:
566
+ api = None
567
+ if provider and model:
568
+ try:
569
+ api = self.api_core(provider, model, api_key)
570
+ except UserInputError:
571
+ if call_api:
572
+ raise
573
+ if call_api and api is None:
574
+ raise UserInputError("choose a provider and model to compare API replies")
575
+ counter = make_counter(self.settings.token_counter, api)
576
+ result = self.reduce(counter, text, mode, methods, pasted or [])
577
+ exact = api is not None and self.settings.token_counter == "provider" # noqa: S105
578
+ label = f"{provider} token counter" if exact else "heuristic (chars/4)"
579
+ out = {"saver": result, "counter": label, "api": None}
580
+ if call_api and api is not None:
581
+ raw = api.chat(LLMSyscall(messages=[
582
+ {"role": "system", "content": self.system_prompt(with_output_config=False)},
583
+ {"role": "user", "content": text}], max_tokens=2048))
584
+ opt = api.chat(LLMSyscall(messages=[
585
+ {"role": "system", "content": self.system_prompt(with_output_config=True)},
586
+ {"role": "user", "content": result.reduced}], max_tokens=2048))
587
+ out["api"] = {
588
+ "raw": {"input_tokens": raw.prompt_tokens, "output_tokens": raw.completion_tokens, "reply": raw.text},
589
+ "optimized": {"input_tokens": opt.prompt_tokens, "output_tokens": opt.completion_tokens, "reply": opt.text},
590
+ }
591
+ return out
@@ -0,0 +1,26 @@
1
+ """Third-party service tools, the AIOS paper's Table 5 tools beyond the ones Pal already had (Wikipedia, arXiv)."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Iterable
5
+
6
+ from pal.chat.services import data, media, web
7
+ from pal.chat.services.base import ServiceCtx, ServiceSpec
8
+ from pal.errors import ConfigError
9
+
10
+ ALL_SERVICES: tuple[ServiceSpec, ...] = (*web.SERVICES, *data.SERVICES, *media.SERVICES)
11
+ SERVICE_NAMES: tuple[str, ...] = tuple(s.name for s in ALL_SERVICES)
12
+ BY_NAME = {s.name: s for s in ALL_SERVICES}
13
+
14
+
15
+ def selected(names: Iterable[str]) -> list[ServiceSpec]:
16
+ """The services a `tools.services` list switches on. "all" means every one. An unknown name is an error, not ignored."""
17
+ wanted = [str(n).strip() for n in names]
18
+ if "all" in wanted:
19
+ return list(ALL_SERVICES)
20
+ unknown = [n for n in wanted if n not in BY_NAME]
21
+ if unknown:
22
+ raise ConfigError(f"tools.services: unknown service(s) {unknown}; choose from {', '.join(SERVICE_NAMES)}, or all")
23
+ return [BY_NAME[n] for n in dict.fromkeys(wanted)]
24
+
25
+
26
+ __all__ = ["ALL_SERVICES", "BY_NAME", "SERVICE_NAMES", "ServiceCtx", "ServiceSpec", "selected"]