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,217 @@
1
+ """
2
+ Fully in-process local core: the model is loaded directly into this
3
+ Python process via transformers, no server (Ollama/llama.cpp/vLLM)
4
+ involved at all. This is the deployment option AIOS 3.2 calls out
5
+ explicitly -- "whether the LLM is hosted on cloud or on local device,
6
+ what hardware conditions the LLM requires" -- that a server-based local
7
+ core doesn't cover, since a server is still a separate process/host
8
+ with its own port.
9
+
10
+ Heavy deps (torch, transformers) are imported lazily inside __init__,
11
+ not at module import time, so importing pal.llm_wrapper doesn't
12
+ require them unless this specific core is actually built.
13
+
14
+ This is also the only core with direct logits access, which is what
15
+ lets it implement the beam-search step/snapshot/restore methods the
16
+ logits-based context interrupt mechanism needs (see
17
+ orchestrator/components/context_interrupt.py) -- no cloud or
18
+ local-server backend exposes raw per-step logits over its API, so
19
+ those cores are permanently limited to text-based snapshotting.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ from pal.llm_wrapper.components.base import (
28
+ DeploymentType,
29
+ LLMResponse,
30
+ LLMSyscall,
31
+ LLMWrapper,
32
+ WrapperCapabilities,
33
+ WrapperConfig,
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class BeamSearchState:
39
+ """The "intermediate search tree structure" a logits-based snapshot
40
+ preserves: every beam's full token sequence so far and its
41
+ cumulative log-probability score. Restoring from this resumes
42
+ decoding exactly where generation left off, one token at a time --
43
+ unlike a text snapshot, nothing has to be re-tokenized or
44
+ re-interpreted by the model to continue.
45
+
46
+ Deliberately does NOT snapshot the model's KV cache (past_key_values)
47
+ -- that would make resumption cheaper (no need to reprocess the
48
+ prefix) but ties the snapshot to a single model instance's live
49
+ memory, and isn't picklable/inspectable the way this plain
50
+ tensor-of-token-ids state is. The tradeoff: resuming here recomputes
51
+ the forward pass over the full sequence-so-far rather than just the
52
+ new token, which is the honest cost of keeping snapshots simple and
53
+ portable.
54
+ """
55
+
56
+ input_ids: Any # torch.Tensor, shape (beam_width, seq_len)
57
+ beam_scores: Any # torch.Tensor, shape (beam_width,)
58
+ prompt_len: int
59
+ step: int = 0
60
+ finished: bool = False
61
+
62
+
63
+ class HuggingFaceLocalLLM(LLMWrapper):
64
+ name = "huggingface_local"
65
+
66
+ def __init__(self, config: WrapperConfig):
67
+ super().__init__(config)
68
+ try:
69
+ import torch
70
+ from transformers import AutoModelForCausalLM, AutoTokenizer
71
+ except ImportError as e:
72
+ raise ImportError(
73
+ "transformers and torch are required for the in-process local "
74
+ "core. Run: pip install torch transformers"
75
+ ) from e
76
+
77
+ self._torch = torch
78
+ self.model_name = config.model
79
+ # "auto" lets accelerate/torch pick the best available device;
80
+ # explicit device (e.g. "cuda", "cpu", "mps") pins it, matching
81
+ # the "hardware conditions the LLM requires" concern from AIOS.
82
+ device = config.device or "auto"
83
+
84
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
85
+ self.model = AutoModelForCausalLM.from_pretrained(
86
+ self.model_name,
87
+ device_map=device,
88
+ torch_dtype=config.extra.get("torch_dtype", "auto"),
89
+ )
90
+
91
+ def _render_prompt(self, messages: list[dict]) -> str:
92
+ if hasattr(self.tokenizer, "apply_chat_template") and self.tokenizer.chat_template:
93
+ return self.tokenizer.apply_chat_template(
94
+ messages, tokenize=False, add_generation_prompt=True
95
+ )
96
+ # No chat template on this tokenizer/model -- fall back to a
97
+ # plain role-prefixed transcript rather than failing outright.
98
+ return "\n".join(f"{m.get('role', 'user')}: {m.get('content', '')}" for m in messages) + "\nassistant:"
99
+
100
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
101
+ prompt = self._render_prompt(syscall.messages)
102
+ inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
103
+
104
+ t0 = time.time()
105
+ with self._torch.no_grad():
106
+ output_ids = self.model.generate(
107
+ **inputs,
108
+ max_new_tokens=syscall.max_tokens or 512,
109
+ temperature=syscall.temperature if syscall.temperature is not None else 1.0,
110
+ do_sample=syscall.temperature not in (None, 0),
111
+ **syscall.params,
112
+ )
113
+ latency = time.time() - t0
114
+
115
+ prompt_tokens = inputs["input_ids"].shape[-1]
116
+ completion_tokens = output_ids.shape[-1] - prompt_tokens
117
+ text = self.tokenizer.decode(output_ids[0][prompt_tokens:], skip_special_tokens=True)
118
+
119
+ return LLMResponse(
120
+ text=text,
121
+ raw=output_ids,
122
+ prompt_tokens=prompt_tokens,
123
+ completion_tokens=completion_tokens,
124
+ meta={"latency": latency, "device": str(self.model.device)},
125
+ )
126
+
127
+ def token_count(self, text: str) -> int:
128
+ return len(self.tokenizer.encode(text))
129
+
130
+ # -- logits-based beam search: step / snapshot / restore -------------------
131
+ #
132
+ # Not part of LLMWrapper's abstract contract (see base.py's LLMWrapper
133
+ # docstring) -- only reachable via capabilities().supports_logits_
134
+ # snapshot, and only ever called by ContextInterruptController.
135
+
136
+ def init_beam_search(self, syscall: LLMSyscall, beam_width: int = 1) -> BeamSearchState:
137
+ prompt = self._render_prompt(syscall.messages)
138
+ inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
139
+ input_ids = inputs["input_ids"].repeat(beam_width, 1)
140
+ beam_scores = self._torch.zeros(beam_width, device=self.model.device)
141
+ return BeamSearchState(input_ids=input_ids, beam_scores=beam_scores, prompt_len=input_ids.shape[-1])
142
+
143
+ def beam_search_step(self, state: BeamSearchState, max_new_tokens: int = 512) -> BeamSearchState:
144
+ """Advances every beam by exactly one token. This single-token
145
+ granularity is the actual snapshot boundary: the context
146
+ interrupt mechanism can pause between ANY two calls to this
147
+ method, not just between whole streamed chunks the way the
148
+ text-based path is limited to."""
149
+ if state.finished:
150
+ return state
151
+
152
+ torch = self._torch
153
+ with torch.no_grad():
154
+ outputs = self.model(state.input_ids)
155
+ next_token_logits = outputs.logits[:, -1, :] # (beam_width, vocab)
156
+ next_token_logprobs = torch.log_softmax(next_token_logits, dim=-1)
157
+ beam_width = state.input_ids.shape[0]
158
+
159
+ if beam_width == 1:
160
+ # Beam width 1 (this project's default, matching the
161
+ # AIOS example): greedy is equivalent to beam search
162
+ # with one beam, and avoids the topk/reshape machinery
163
+ # below for the common case.
164
+ next_token_id = next_token_logprobs.argmax(dim=-1, keepdim=True)
165
+ new_scores = state.beam_scores + next_token_logprobs.gather(-1, next_token_id).squeeze(-1)
166
+ new_input_ids = torch.cat([state.input_ids, next_token_id], dim=-1)
167
+ else:
168
+ # True beam search: expand every beam by its full
169
+ # vocabulary of candidates, then keep only the top
170
+ # `beam_width` sequences overall (not top-1-per-beam --
171
+ # that's what lets a single strong beam claim more than
172
+ # one slot next step, same as standard beam search).
173
+ vocab_size = next_token_logprobs.shape[-1]
174
+ candidate_scores = (state.beam_scores.unsqueeze(-1) + next_token_logprobs).view(-1)
175
+ top_scores, top_indices = candidate_scores.topk(beam_width)
176
+ beam_indices = top_indices // vocab_size
177
+ token_indices = (top_indices % vocab_size).unsqueeze(-1)
178
+ new_input_ids = torch.cat([state.input_ids[beam_indices], token_indices], dim=-1)
179
+ new_scores = top_scores
180
+
181
+ generated_len = new_input_ids.shape[-1] - state.prompt_len
182
+ eos_id = self.tokenizer.eos_token_id
183
+ finished = generated_len >= max_new_tokens or (
184
+ eos_id is not None and bool((new_input_ids[:, -1] == eos_id).all())
185
+ )
186
+
187
+ return BeamSearchState(
188
+ input_ids=new_input_ids,
189
+ beam_scores=new_scores,
190
+ prompt_len=state.prompt_len,
191
+ step=state.step + 1,
192
+ finished=finished,
193
+ )
194
+
195
+ def decode_beam_search(self, state: BeamSearchState) -> str:
196
+ """Decodes the current best beam's generated tokens. Called both
197
+ mid-generation (to snapshot a human-readable partial answer
198
+ alongside the raw state) and at completion."""
199
+ best_idx = int(state.beam_scores.argmax())
200
+ generated = state.input_ids[best_idx, state.prompt_len:]
201
+ return self.tokenizer.decode(generated, skip_special_tokens=True)
202
+
203
+ def capabilities(self) -> WrapperCapabilities:
204
+ return WrapperCapabilities(
205
+ deployment_type=DeploymentType.LOCAL_IN_PROCESS,
206
+ # The KV cache lives in this same process's memory for the
207
+ # lifetime of self.model -- there's no cross-request reuse
208
+ # concept to expose since nothing is reprocessed for a
209
+ # repeated turn within one generate() call.
210
+ supports_kv_cache=False,
211
+ supports_embeddings=False,
212
+ supports_streaming=False,
213
+ billed_per_token=False,
214
+ # Direct logits access via self.model(...) -- the only core
215
+ # in this project that can offer this.
216
+ supports_logits_snapshot=True,
217
+ )
@@ -0,0 +1,60 @@
1
+ """The Local LLM component: models that run on this machine, behind a local server (Ollama, vLLM, LM Studio, llama.cpp).
2
+
3
+ It is the same LiteLLM wrapper underneath, so a local model has the same interface, token and error format as a cloud one, but it differs
4
+ where it matters: there is no key to protect (the OpenAI-style local servers get a placeholder, never a real key from the environment),
5
+ a model's first call may load it (a longer time-out), it costs nothing per token, and it can tell you which models it has installed.
6
+
7
+ The in-process Hugging Face model is the other local kind (huggingface_local.py): LiteLLM calls model servers and cannot load a model into
8
+ this process.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from pal.llm_wrapper.components.litellm_wrapper import LOCAL_PREFIX, LiteLLMWrapper
15
+
16
+ DEFAULT_URLS = {"ollama": "http://localhost:11434", "vllm": "http://localhost:8000/v1", "lmstudio": "http://localhost:1234/v1", "llamacpp": "http://localhost:8080/v1"}
17
+ LOCAL_BACKENDS = tuple(LOCAL_PREFIX)
18
+
19
+
20
+ class LocalLLM(LiteLLMWrapper):
21
+ name = "local_llm"
22
+ IS_LOCAL = True
23
+
24
+ def __init__(self, config):
25
+ if config.backend not in LOCAL_PREFIX:
26
+ raise ValueError(f"{config.backend!r} is not a local runtime (local runtimes: {', '.join(LOCAL_BACKENDS)})")
27
+ super().__init__(config)
28
+
29
+ @property
30
+ def url(self) -> str:
31
+ return (self.config.base_url or self.config.host or DEFAULT_URLS[self.config.backend]).rstrip("/")
32
+
33
+ def installed_models(self, timeout: float = 3.0, get: Any = None) -> list[str]:
34
+ """The models this server has, as it names them: Ollama's /api/tags, or the OpenAI-style /models of the others. An empty list when the
35
+ server cannot be reached (see `is_running`). `get` is for tests; it defaults to `requests.get`."""
36
+ if get is None:
37
+ import requests
38
+ get = requests.get
39
+ try:
40
+ if self.config.backend == "ollama":
41
+ r = get(f"{self.url}/api/tags", timeout=timeout)
42
+ r.raise_for_status()
43
+ return sorted(str(m.get("name") or m.get("model")) for m in r.json().get("models", []) if m.get("name") or m.get("model"))
44
+ r = get(f"{self.url}/models", timeout=timeout)
45
+ r.raise_for_status()
46
+ return sorted(str(m["id"]) for m in r.json().get("data", []) if m.get("id"))
47
+ except Exception: # noqa: BLE001 (not running, not reachable, or not the API we expected)
48
+ return []
49
+
50
+ def is_running(self, timeout: float = 3.0, get: Any = None) -> bool:
51
+ """Is the server up? (It answers a models request, even with no models yet.)"""
52
+ if get is None:
53
+ import requests
54
+ get = requests.get
55
+ try:
56
+ path = "/api/tags" if self.config.backend == "ollama" else "/models"
57
+ get(f"{self.url}{path}", timeout=timeout).raise_for_status()
58
+ return True
59
+ except Exception: # noqa: BLE001
60
+ return False
pal/logging_setup.py ADDED
@@ -0,0 +1,18 @@
1
+ """Logging setup for entry points. Library code only does `logging.getLogger(__name__)`; only the
2
+ CLI/server calls configure_logging(). Secrets are never logged: API keys never enter a log call."""
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+
9
+ def configure_logging(level: str = "INFO") -> None:
10
+ root = logging.getLogger("pal")
11
+ if root.handlers:
12
+ root.setLevel(level.upper())
13
+ return
14
+ handler = logging.StreamHandler(sys.stderr)
15
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
16
+ root.addHandler(handler)
17
+ root.setLevel(level.upper())
18
+ root.propagate = False
pal/memory/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from pal.memory.components.lru_k import LRUKTracker
2
+ from pal.memory.components.manager import MemoryManager
3
+ from pal.memory.components.note import MemoryNote
4
+ from pal.memory.components.storage_manager import StorageManager
5
+ from pal.memory.pipeline import MemoryPipeline
6
+
7
+ __all__ = ["MemoryPipeline", "MemoryManager", "MemoryNote", "StorageManager", "LRUKTracker"]
@@ -0,0 +1,6 @@
1
+ from pal.memory.components.lru_k import LRUKTracker
2
+ from pal.memory.components.manager import MemoryManager
3
+ from pal.memory.components.note import MemoryNote
4
+ from pal.memory.components.storage_manager import StorageManager
5
+
6
+ __all__ = ["LRUKTracker", "MemoryManager", "MemoryNote", "StorageManager"]
@@ -0,0 +1,56 @@
1
+ """
2
+ LRU-K tracking (O'Neil, O'Neil & Weikum, 1993) -- the eviction policy
3
+ AIOS section 3.5 specifies for its memory manager, used here to decide
4
+ which RAM-cached MemoryNotes get swapped to disk once the cache nears
5
+ capacity.
6
+
7
+ Plain LRU only looks at the single most recent access, so a note that
8
+ was accessed once, long ago, and a note accessed constantly until just
9
+ a moment ago can look identically "recently used" the instant both get
10
+ touched again -- LRU can't tell a one-off touch from a genuinely hot
11
+ item. LRU-K's backward K-distance (how long ago an item's K-th most
12
+ recent access happened, not just its most recent one) is what "at
13
+ least K times recently" in the spec actually cashes out to: an item
14
+ needs a real access HISTORY, not just a single recent touch, to count
15
+ as hot.
16
+ """
17
+ import math
18
+ import time
19
+ from collections import deque
20
+
21
+
22
+ class LRUKTracker:
23
+ def __init__(self, k: int = 2):
24
+ self.k = k
25
+ self._history: dict[str, deque] = {}
26
+
27
+ def record_access(self, item_id: str, when: float = None) -> None:
28
+ when = when if when is not None else time.time()
29
+ hist = self._history.setdefault(item_id, deque(maxlen=self.k))
30
+ hist.append(when)
31
+
32
+ def backward_k_distance(self, item_id: str, now: float = None) -> float:
33
+ """How long ago this item's K-th most recent access happened.
34
+ Items with fewer than K recorded accesses ever get infinite
35
+ distance -- they haven't earned "accessed at least K times
36
+ recently" status yet, so they're always evicted ahead of
37
+ anything that has."""
38
+ now = now if now is not None else time.time()
39
+ hist = self._history.get(item_id)
40
+ if not hist or len(hist) < self.k:
41
+ return math.inf
42
+ return now - hist[0] # oldest of the last K accesses
43
+
44
+ def forget(self, item_id: str) -> None:
45
+ self._history.pop(item_id, None)
46
+
47
+ def eviction_order(self, candidate_ids: list[str], now: float = None) -> list[str]:
48
+ """Returns candidate_ids ordered worst-to-best for retention --
49
+ the FIRST id in the result is the one to evict first (largest
50
+ backward K-distance = coldest). Python's sort is stable, so
51
+ ties (most commonly: several items all still below K accesses,
52
+ all at infinite distance) keep their relative order from
53
+ candidate_ids -- in practice, whichever of them was inserted
54
+ into the RAM cache earliest."""
55
+ now = now if now is not None else time.time()
56
+ return sorted(candidate_ids, key=lambda i: self.backward_k_distance(i, now), reverse=True)
@@ -0,0 +1,98 @@
1
+ """
2
+ Runtime memory manager for agent interaction history (AIOS section
3
+ 3.5). Unlike a traditional OS memory manager, which manages physical
4
+ RAM pages, this one manages MemoryNote objects -- conversation turns,
5
+ tool-call results, and similar agent history -- between an in-process
6
+ RAM cache and on-disk persistence via StorageManager.
7
+
8
+ Every write is persisted to disk immediately: this project has no
9
+ concept of "unsaved" agent memory, durability isn't optional here, only
10
+ RAM residency is. The RAM cache exists purely as a bounded-size fast
11
+ path so repeated access to hot notes doesn't round-trip through disk
12
+ every time. When the cache's size crosses `high_water_mark` (default
13
+ 80%) of `block_limit`, the manager evicts the coldest RAM-resident
14
+ notes by LRU-K backward-distance (components/lru_k.py) until it's back
15
+ under the mark. "Evicted" only means dropped from the RAM cache --
16
+ nothing is lost, since it was already durably written to disk on
17
+ admission; the next read() for that id reloads it from disk and
18
+ re-admits it to RAM (a swap-in).
19
+ """
20
+ import time
21
+ from typing import Optional
22
+
23
+ from pal.memory.components.lru_k import LRUKTracker
24
+ from pal.memory.components.note import MemoryNote
25
+ from pal.memory.components.storage_manager import StorageManager
26
+
27
+
28
+ class MemoryManager:
29
+ def __init__(
30
+ self,
31
+ storage: StorageManager,
32
+ block_limit: int = 200,
33
+ high_water_mark: float = 0.8,
34
+ k: int = 2,
35
+ ):
36
+ self.storage = storage
37
+ self.block_limit = block_limit
38
+ self.high_water_mark = high_water_mark
39
+ self.ram: dict[str, MemoryNote] = {}
40
+ self.lru_k = LRUKTracker(k=k)
41
+
42
+ # Warm the RAM cache from whatever's already durably persisted,
43
+ # most-recently-touched first, up to the block limit -- so a
44
+ # fresh process (a new session against an existing memory
45
+ # store) doesn't start with a cold cache and immediately thrash
46
+ # on its first few reads.
47
+ for note in sorted(self.storage.read_all(), key=lambda n: n.last_accessed, reverse=True)[: self.block_limit]:
48
+ self.ram[note.id] = note
49
+ self.lru_k.record_access(note.id, note.last_accessed)
50
+
51
+ def usage_ratio(self) -> float:
52
+ return len(self.ram) / self.block_limit if self.block_limit else 0.0
53
+
54
+ def write(self, note: MemoryNote) -> None:
55
+ """Allocates (or updates) a note: persisted to disk immediately,
56
+ cached in RAM, and recorded as an access. May trigger eviction
57
+ if this pushes the RAM cache over its high-water mark."""
58
+ self.storage.write(note)
59
+ self.ram[note.id] = note
60
+ self.lru_k.record_access(note.id)
61
+ self._maybe_evict()
62
+
63
+ def read(self, note_id: str) -> Optional[MemoryNote]:
64
+ """Reads a note by id -- from RAM if resident, else swaps it in
65
+ from disk. Returns None if the id doesn't exist anywhere."""
66
+ note = self.ram.get(note_id)
67
+ if note is None:
68
+ note = self.storage.read(note_id)
69
+ if note is None:
70
+ return None
71
+ self.ram[note_id] = note # swap-in
72
+
73
+ note.retrieval_count += 1
74
+ note.last_accessed = time.time()
75
+ self.lru_k.record_access(note_id, note.last_accessed)
76
+ self.storage.write(note) # persist the updated access metadata
77
+ self._maybe_evict()
78
+ return note
79
+
80
+ def delete(self, note_id: str) -> None:
81
+ self.ram.pop(note_id, None)
82
+ self.storage.delete(note_id)
83
+ self.lru_k.forget(note_id)
84
+
85
+ def all_ids(self) -> list[str]:
86
+ """Every note id known to the system, RAM-resident or swapped
87
+ to disk."""
88
+ return self.storage.list_ids()
89
+
90
+ def _maybe_evict(self) -> None:
91
+ while self.usage_ratio() >= self.high_water_mark and self.ram:
92
+ victim_id = self.lru_k.eviction_order(list(self.ram.keys()))[0]
93
+ # Already durable on disk from write() -- eviction here
94
+ # only drops the RAM cache entry, nothing more needs to be
95
+ # written. LRU-K access history is deliberately kept (not
96
+ # forgotten) for the evicted item, so if it's swapped back
97
+ # in later its recency history isn't reset to zero.
98
+ del self.ram[victim_id]
@@ -0,0 +1,54 @@
1
+ """
2
+ A single persistent memory unit. Design adapted from AIOS
3
+ (github.com/agiresearch/AIOS aios/memory/note.py) but trimmed to what
4
+ Pal actually needs -- no evolution_history/links graph, since that
5
+ requires the heavier retrieval stack (chromadb/qdrant) AIOS uses, which
6
+ this lightweight module deliberately avoids.
7
+ """
8
+ import time
9
+ import uuid
10
+ from typing import Optional
11
+
12
+
13
+ class MemoryNote:
14
+ def __init__(
15
+ self,
16
+ content: str,
17
+ id: Optional[str] = None,
18
+ session_id: Optional[str] = None,
19
+ timestamp: Optional[float] = None,
20
+ last_accessed: Optional[float] = None,
21
+ retrieval_count: int = 0,
22
+ tags: Optional[list[str]] = None,
23
+ ):
24
+ self.id = id or str(uuid.uuid4())
25
+ self.content = content
26
+ self.session_id = session_id
27
+ now = time.time()
28
+ self.timestamp = timestamp if timestamp is not None else now
29
+ self.last_accessed = last_accessed if last_accessed is not None else now
30
+ self.retrieval_count = retrieval_count
31
+ self.tags = tags or []
32
+
33
+ def to_dict(self) -> dict:
34
+ return {
35
+ "id": self.id,
36
+ "content": self.content,
37
+ "session_id": self.session_id,
38
+ "timestamp": self.timestamp,
39
+ "last_accessed": self.last_accessed,
40
+ "retrieval_count": self.retrieval_count,
41
+ "tags": self.tags,
42
+ }
43
+
44
+ @classmethod
45
+ def from_dict(cls, d: dict) -> "MemoryNote":
46
+ return cls(
47
+ content=d["content"],
48
+ id=d.get("id"),
49
+ session_id=d.get("session_id"),
50
+ timestamp=d.get("timestamp"),
51
+ last_accessed=d.get("last_accessed"),
52
+ retrieval_count=d.get("retrieval_count", 0),
53
+ tags=d.get("tags", []),
54
+ )
@@ -0,0 +1,60 @@
1
+ """
2
+ Minimal disk-backed persistence for MemoryNotes: one JSON file per note
3
+ under `root`, keyed by note id.
4
+
5
+ Stands in for the full Storage Manager AIOS section 3.6 describes (a
6
+ general read/write file-access layer for agents) -- this project
7
+ doesn't implement that broader module yet, only the narrow slice the
8
+ memory manager's RAM<->disk swapping needs: durably persist a note,
9
+ load it back by id, delete it, and enumerate every persisted id.
10
+ """
11
+ import json
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from pal.memory.components.note import MemoryNote
16
+
17
+
18
+ class StorageManager:
19
+ def __init__(self, root: Path):
20
+ self.root = Path(root)
21
+ self.root.mkdir(parents=True, exist_ok=True)
22
+
23
+ def _path(self, note_id: str) -> Path:
24
+ return self.root / f"{note_id}.json"
25
+
26
+ def write(self, note: MemoryNote) -> None:
27
+ with open(self._path(note.id), "w", encoding="utf-8") as f:
28
+ json.dump(note.to_dict(), f)
29
+
30
+ def read(self, note_id: str) -> Optional[MemoryNote]:
31
+ path = self._path(note_id)
32
+ if not path.exists():
33
+ return None
34
+ try:
35
+ with open(path, encoding="utf-8") as f:
36
+ return MemoryNote.from_dict(json.load(f))
37
+ except (json.JSONDecodeError, KeyError):
38
+ # Corrupt/partial file (e.g. killed mid-write) -- treat as
39
+ # missing rather than raising, same tolerance the old
40
+ # single-file store had for a corrupt memory.json.
41
+ return None
42
+
43
+ def delete(self, note_id: str) -> None:
44
+ path = self._path(note_id)
45
+ if path.exists():
46
+ path.unlink()
47
+
48
+ def exists(self, note_id: str) -> bool:
49
+ return self._path(note_id).exists()
50
+
51
+ def list_ids(self) -> list[str]:
52
+ return [p.stem for p in self.root.glob("*.json")]
53
+
54
+ def read_all(self) -> list[MemoryNote]:
55
+ notes = []
56
+ for note_id in self.list_ids():
57
+ note = self.read(note_id)
58
+ if note is not None:
59
+ notes.append(note)
60
+ return notes