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,215 @@
1
+ """The Knowledge Graph component: send the relevant slice of a project, not the project.
2
+
3
+ A zip of source code (or one large code file) attached to the chat is indexed once,
4
+ locally, with graphify (AST only: no LLM call, nothing leaves the machine). After that
5
+ each API request carries only the graph nodes and code excerpts that match the current
6
+ question, within `budget_tokens`. The excerpts are added to the request but not stored
7
+ in the chat history, so they are not resent on every later turn.
8
+
9
+ Only source code is indexed. graphify's semantic extraction for docs and images needs
10
+ an LLM and is not used here.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Callable, Optional
18
+
19
+ from pal.config import KnowledgeGraphConfig
20
+ from pal.knowledge_graph.components import builder, retriever, workspace
21
+ from pal.knowledge_graph.components.base import (
22
+ GRAPH_NOTICE,
23
+ GraphError,
24
+ GraphExcerpts,
25
+ ProjectInfo,
26
+ Runner,
27
+ )
28
+
29
+ __all__ = ["KnowledgeGraph", "GraphBlock", "GraphError", "GraphExcerpts", "ProjectInfo", "GRAPH_NOTICE"]
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class GraphBlock:
36
+ """What the knowledge graph contributes to one request."""
37
+ text: str # the excerpts, ready to add to the request
38
+ saved_tokens: int # what sending the whole project would have cost, minus these excerpts
39
+ info: dict # nodes, excerpts, tokens and project tokens, for the turn's report
40
+
41
+
42
+ class KnowledgeGraph:
43
+ def __init__(self, cfg=None, runner: Optional[Runner] = None):
44
+ self.cfg = KnowledgeGraphConfig.coerce(cfg)
45
+ self.runner: Runner = runner or builder.default_runner
46
+ self.root = self.cfg.resolved_dir()
47
+
48
+ # -- config ------------------------------------------------------------------------
49
+ @property
50
+ def enabled(self) -> bool:
51
+ return self.cfg.enabled
52
+
53
+ def _ws(self, session_id: str) -> workspace.Workspace:
54
+ return workspace.Workspace(self.root, session_id)
55
+
56
+ def available(self) -> bool:
57
+ return self.runner is not builder.default_runner or builder.graphify_available()
58
+
59
+ def wants(self, name: str, raw: bytes) -> bool:
60
+ """Should this upload be indexed instead of pasted into the chat?"""
61
+ if not self.enabled:
62
+ return False
63
+ if workspace.ext(name) == "zip":
64
+ return True # a zip can only be used as a project; fails clearly if graphify is missing
65
+ return (self.available() and workspace.is_code(name)
66
+ and len(raw) > self.cfg.min_code_file_chars)
67
+
68
+ # -- indexing --------------------------------------------------------------------------
69
+ def ingest(self, session_id: str, name: str, raw: bytes) -> dict:
70
+ """Index an upload; returns the attachment dict the chat should show the API model."""
71
+ if not self.available():
72
+ raise GraphError(f"{name}: indexing code needs graphify (pip install graphifyy)")
73
+ ws = self._ws(session_id)
74
+ try:
75
+ if workspace.ext(name) == "zip":
76
+ ws.add_zip(raw, self.cfg.max_zip_files, self.cfg.max_zip_mb * 1_000_000)
77
+ else:
78
+ ws.add_file(Path(name).name, raw)
79
+ info = builder.build(ws, Path(name).stem if workspace.ext(name) == "zip" else Path(name).name, self.runner)
80
+ except GraphError as e:
81
+ raise GraphError(f"{name}: {e}") from e
82
+ return {"name": name, "text": info.summary(), "note": "indexed as a code graph; source not included",
83
+ "kind": "project"}
84
+
85
+ def stats(self) -> dict:
86
+ """How many projects are indexed right now and how much disk they take (for the Memory Tracker)."""
87
+ from pal.memory_tracker import dir_bytes
88
+ projects = sum(1 for d in self.root.iterdir() if d.is_dir()) if self.root.is_dir() else 0
89
+ return {"enabled": self.enabled, "projects": projects, "bytes": dir_bytes(self.root) if projects else 0}
90
+
91
+ def has_project(self, session_id: str) -> bool:
92
+ return self.enabled and self._ws(session_id).exists()
93
+
94
+ # -- per-question ------------------------------------------------------------------------
95
+ def retrieve(self, session_id: str, question: str,
96
+ count_tokens: Callable[[str], int]) -> Optional[GraphExcerpts]:
97
+ if not self.has_project(session_id) or not question.strip():
98
+ return None
99
+ return retriever.retrieve(self._ws(session_id), question, self.runner, count_tokens,
100
+ self.cfg.budget_tokens, self.cfg.max_slice_lines)
101
+
102
+ def context_block(self, session_id: str, question: str, count_tokens: Callable[[str], int]) -> Optional[GraphBlock]:
103
+ """The excerpts of the loaded project that match the question, as a block for the request, with the tokens saved against sending the whole
104
+ project. None when there is no project or nothing matches. A failure is logged and gives None: the chat never fails because the graph did."""
105
+ try:
106
+ ex = self.retrieve(session_id, question, count_tokens)
107
+ except Exception: # noqa: BLE001
108
+ log.exception("graph retrieval failed")
109
+ return None
110
+ if not ex:
111
+ return None
112
+ return GraphBlock(ex.text, max(0, ex.project_tokens - ex.tokens),
113
+ {"nodes": ex.nodes, "excerpts": ex.excerpts, "tokens": ex.tokens, "project_tokens": ex.project_tokens})
114
+
115
+ def read_source(self, session_id: str, path: str, start: int = 1, end: int = 0, max_lines: int = 120) -> str:
116
+ """Read-only view of one indexed source file (used by the read_source tool)."""
117
+ from pal.errors import NotFoundError
118
+ from pal.paths import is_safe_relative_key
119
+ ws = self._ws(session_id)
120
+ rel = path.strip().replace("\\", "/").removeprefix("src/")
121
+ if not ws.exists() or not is_safe_relative_key(rel):
122
+ raise NotFoundError(f"no such file in the project: {path!r}")
123
+ target = (ws.src / rel).resolve()
124
+ if ws.src.resolve() not in target.parents or not target.is_file() or not workspace.is_code(target.name):
125
+ raise NotFoundError(f"no such file in the project: {path!r}")
126
+ lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
127
+ start = max(1, start)
128
+ end = min(len(lines), end if end >= start else start + max_lines - 1, start + max_lines - 1)
129
+ body = "\n".join(f"{i}: {lines[i - 1]}" for i in range(start, end + 1))
130
+ return f"[{rel} lines {start}-{end} of {len(lines)}]\n{body}"
131
+
132
+ # -- read-only code navigation, no model involved ---------------------------------------------------------------------
133
+ def _code_files(self, session_id: str) -> list:
134
+ ws = self._ws(session_id)
135
+ if not ws.exists():
136
+ return []
137
+ return sorted(p for p in ws.src.rglob("*") if p.is_file() and workspace.is_code(p.name))
138
+
139
+ def list_files(self, session_id: str, prefix: str = "", limit: int = 200) -> str:
140
+ """The project's source files, one per line with the line count."""
141
+ ws = self._ws(session_id)
142
+ rows = []
143
+ for p in self._code_files(session_id):
144
+ rel = p.relative_to(ws.src).as_posix()
145
+ if rel.startswith(prefix.strip().lstrip("/")):
146
+ rows.append(f"{rel} ({sum(1 for _ in p.open(encoding='utf-8', errors='replace'))} lines)")
147
+ if not rows:
148
+ return "no files match" if prefix else "no project"
149
+ more = f"\n[{len(rows) - limit} more not shown: use a prefix]" if len(rows) > limit else ""
150
+ return "\n".join(rows[:limit]) + more
151
+
152
+ def search(self, session_id: str, text: str, limit: int = 25) -> str:
153
+ """Lines containing `text` (case-insensitive), as path:line: text."""
154
+ needle = text.strip().lower()
155
+ if len(needle) < 2:
156
+ return "give at least two characters to search for"
157
+ return self._grep(session_id, lambda line: needle in line.lower(), limit, f"search for {text.strip()!r}")
158
+
159
+ def find_usages(self, session_id: str, symbol: str, limit: int = 25) -> str:
160
+ """Where a function, class or variable is defined and where its name is used (a word-boundary text match)."""
161
+ import re
162
+ sym = symbol.strip()
163
+ if not re.fullmatch(r"[A-Za-z_$][\w$.]*", sym):
164
+ return "give a plain name: letters, digits, underscore"
165
+ word = re.compile(rf"(?<![\w$]){re.escape(sym.split('.')[-1])}(?![\w$])")
166
+ define = re.compile(rf"^\s*(?:export\s+|async\s+|public\s+|private\s+|static\s+)*(?:def|class|function|func|fn|interface|struct|enum|trait|type)\s+"
167
+ rf"{re.escape(sym.split('.')[-1])}\b")
168
+ defs, uses = [], []
169
+ for p in self._code_files(session_id):
170
+ rel = p.relative_to(self._ws(session_id).src).as_posix()
171
+ for n, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
172
+ if word.search(line):
173
+ (defs if define.match(line) else uses).append(f"{rel}:{n}: {line.strip()[:160]}")
174
+ if not defs and not uses:
175
+ return f"no match for {sym!r} in the project"
176
+ files = len({u.split(':')[0] for u in defs + uses})
177
+ out = [f"{sym}: {len(defs)} definition(s), {len(uses)} other use(s), in {files} file(s)"]
178
+ out += [f"definition: {d}" for d in defs[:5]]
179
+ out += uses[:limit]
180
+ if len(uses) > limit:
181
+ out.append(f"[{len(uses) - limit} more uses not shown]")
182
+ return "\n".join(out)
183
+
184
+ def _grep(self, session_id: str, test, limit: int, what: str) -> str:
185
+ ws = self._ws(session_id)
186
+ hits = []
187
+ for p in self._code_files(session_id):
188
+ rel = p.relative_to(ws.src).as_posix()
189
+ for n, line in enumerate(p.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
190
+ if test(line):
191
+ hits.append(f"{rel}:{n}: {line.strip()[:160]}")
192
+ if not hits:
193
+ return f"no results for {what}"
194
+ extra = f"\n[{len(hits) - limit} more results not shown]" if len(hits) > limit else ""
195
+ return "\n".join(hits[:limit]) + extra
196
+
197
+ def reset(self, session_id: str) -> None:
198
+ self._ws(session_id).reset()
199
+
200
+ def prune_stale(self, max_age_seconds: float) -> int:
201
+ """Delete indexed projects nobody has touched for `max_age_seconds` (left behind by a crash or a
202
+ request that failed after the upload). Returns how many were removed."""
203
+ import shutil
204
+ import time
205
+ removed = 0
206
+ if not self.root.is_dir():
207
+ return 0
208
+ for d in self.root.iterdir():
209
+ try:
210
+ if d.is_dir() and time.time() - d.stat().st_mtime > max_age_seconds:
211
+ shutil.rmtree(d, ignore_errors=True)
212
+ removed += 1
213
+ except OSError:
214
+ continue
215
+ return removed
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,3 @@
1
+ from pal.latency_tracker.pipeline import STAGES, LatencyTracker, percentile
2
+
3
+ __all__ = ["STAGES", "LatencyTracker", "percentile"]
@@ -0,0 +1,86 @@
1
+ """The Latency Tracker: where does the time of a chat turn go?
2
+
3
+ Each turn is timed in stages, so a slow turn can be explained rather than guessed at:
4
+
5
+ direct a question answered by code (arithmetic, the time), no model
6
+ routing the local model deciding who answers
7
+ local_answer the local model answering
8
+ input_saver the local model trimming what was typed (and the check that it lost nothing)
9
+ context gathering the request: history, notes, document and code excerpts
10
+ api the API model, with its wait in the queue
11
+ tools tool rounds (a tool call and the model call that follows it)
12
+ other everything else (the turn's total minus the above)
13
+
14
+ `stage()` times a block into the turn's record; `finish()` adds the stages that were already measured elsewhere and the remainder;
15
+ `summarize()` turns a list of turns into per-route latency, queue waits, pauses, and each stage's average, 95th percentile and share of
16
+ all the time spent.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from collections.abc import Iterable
22
+ from contextlib import contextmanager
23
+
24
+ STAGES = ("direct", "routing", "local_answer", "input_saver", "context", "api", "tools", "other")
25
+
26
+
27
+ def percentile(values: list[float], p: float) -> float:
28
+ if not values:
29
+ return 0.0
30
+ v = sorted(values)
31
+ return v[min(len(v) - 1, int(round(p * (len(v) - 1))))]
32
+
33
+
34
+ class LatencyTracker:
35
+ @staticmethod
36
+ @contextmanager
37
+ def stage(turn, name: str):
38
+ """Time the block and add it to the turn's `stage_seconds[name]` (a stage can be entered more than once in a turn)."""
39
+ if name not in STAGES:
40
+ raise KeyError(f"unknown stage {name!r} (known: {', '.join(STAGES)})")
41
+ start = time.perf_counter()
42
+ try:
43
+ yield
44
+ finally:
45
+ turn.stage_seconds[name] = turn.stage_seconds.get(name, 0.0) + (time.perf_counter() - start)
46
+
47
+ @staticmethod
48
+ def finish(turn) -> None:
49
+ """Close the turn's record: the API and tool times measured elsewhere, and `other` = the total minus every named stage."""
50
+ st = turn.stage_seconds
51
+ if "api" not in st and turn.latency_api:
52
+ st["api"] = turn.latency_api
53
+ if "tools" not in st and turn.latency_tools:
54
+ st["tools"] = turn.latency_tools
55
+ named = sum(v for k, v in st.items() if k != "other")
56
+ st["other"] = max(0.0, turn.latency_total - named)
57
+ for k in list(st):
58
+ st[k] = round(st[k], 4)
59
+
60
+ @staticmethod
61
+ def summarize(turns: Iterable) -> dict:
62
+ turns = list(turns)
63
+ n = len(turns)
64
+ by_route: dict = {}
65
+ for r in ("api", "local", "tool"):
66
+ lat = [t.latency_total for t in turns if t.route == r]
67
+ by_route[r] = {"turns": len(lat), "avg_latency_sec": round(sum(lat) / len(lat), 3) if lat else 0.0,
68
+ "p50_latency_sec": round(percentile(lat, 0.5), 3), "p95_latency_sec": round(percentile(lat, 0.95), 3)}
69
+ api_turns = [t for t in turns if t.route == "api"]
70
+ total_time = sum(t.latency_total for t in turns)
71
+ stages: dict = {}
72
+ for name in STAGES:
73
+ vals = [t.stage_seconds[name] for t in turns if t.stage_seconds.get(name, 0) > 0]
74
+ if vals:
75
+ stages[name] = {"turns": len(vals), "mean_sec": round(sum(vals) / len(vals), 3), "p95_sec": round(percentile(vals, 0.95), 3),
76
+ "total_sec": round(sum(vals), 3), "share_pct": round(100.0 * sum(vals) / total_time, 1) if total_time else 0.0}
77
+ slowest = max(stages, key=lambda k: stages[k]["total_sec"]) if stages else ""
78
+ return {
79
+ "by_route": by_route,
80
+ "avg_local_overhead_sec_on_api_turns": round(sum(t.latency_local for t in api_turns) / len(api_turns), 3) if api_turns else 0.0,
81
+ "avg_wait_local_sec": round(sum(t.wait_local for t in turns) / n, 3) if n else 0.0,
82
+ "avg_wait_api_sec": round(sum(t.wait_api for t in api_turns) / len(api_turns), 3) if api_turns else 0.0,
83
+ "local_pauses": sum(t.local_pauses for t in turns),
84
+ "latency_stages": stages,
85
+ "slowest_stage": slowest,
86
+ }
@@ -0,0 +1,11 @@
1
+ """Scheduling. API model: first come, first served, one line per API key. Local model: preemptive priority (agent, then chat,
2
+ then other), first come first served inside each class. Code work: a small pool of slots beside both."""
3
+ from pal.llm_scheduler.api import ApiLine, ApiScheduler, is_rate_limit
4
+ from pal.llm_scheduler.code import CodeSlots
5
+ from pal.llm_scheduler.core import ScheduledWrapper, current_stats, task, track
6
+ from pal.llm_scheduler.local import LocalScheduler
7
+ from pal.llm_scheduler.pipeline import LLMScheduler
8
+ from pal.llm_scheduler.types import ExpiredError, QueueFullError, SchedulerError, TaskClass, Ticket, TurnStats
9
+
10
+ __all__ = ["ApiLine", "ApiScheduler", "CodeSlots", "ExpiredError", "LocalScheduler", "QueueFullError", "ScheduledWrapper",
11
+ "LLMScheduler", "SchedulerError", "TaskClass", "Ticket", "TurnStats", "current_stats", "is_rate_limit", "task", "track"]
@@ -0,0 +1,137 @@
1
+ """The API model's scheduler: first come, first served, one call at a time, per API key.
2
+
3
+ With one key there is one line, and chat, the coding agent and the documentation agent all wait in it, strictly in the order
4
+ they arrive. Two things protect a free tier: calls start at least `interval` seconds apart, and a rate-limit error (429,
5
+ quota) pauses the line and retries the same call after a growing delay instead of failing everyone behind it.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Callable
14
+ from typing import Any
15
+
16
+ from pal.llm_scheduler.types import QueueFullError, SchedulerError, Ticket
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def is_rate_limit(error: BaseException) -> bool:
22
+ for attr in ("status_code", "code"):
23
+ if getattr(error, attr, None) == 429:
24
+ return True
25
+ resp = getattr(error, "response", None)
26
+ if getattr(resp, "status_code", None) == 429:
27
+ return True
28
+ low = str(error).lower()
29
+ return any(k in low for k in ("429", "resource_exhausted", "rate limit", "quota"))
30
+
31
+
32
+ class _ApiJob:
33
+ def __init__(self, ticket: Ticket, fn: Callable[[], Any]):
34
+ self.ticket, self.fn = ticket, fn
35
+
36
+
37
+ class ApiLine:
38
+ def __init__(self, interval: float, retries: int, backoff: float, max_queue: int):
39
+ self.interval, self.retries, self.backoff, self.max_queue = interval, retries, backoff, max_queue
40
+ self._queue: deque[_ApiJob] = deque()
41
+ self._cond = threading.Condition()
42
+ self._closed = False
43
+ self._thread = threading.Thread(target=self._worker, name="pal-api-line", daemon=True)
44
+ self._thread.start()
45
+
46
+ def submit(self, fn: Callable[[], Any], label: str = "api call") -> Ticket:
47
+ t = Ticket(label)
48
+ with self._cond:
49
+ if self._closed:
50
+ raise SchedulerError("the scheduler is closed")
51
+ if len(self._queue) >= self.max_queue:
52
+ raise QueueFullError(f"too many API calls are waiting ({self.max_queue})")
53
+ self._queue.append(_ApiJob(t, fn))
54
+ self._cond.notify()
55
+ return t
56
+
57
+ def pending(self) -> int:
58
+ with self._cond:
59
+ return len(self._queue)
60
+
61
+ def _worker(self) -> None:
62
+ last_start = 0.0
63
+ while True:
64
+ with self._cond:
65
+ while not self._queue:
66
+ if self._closed:
67
+ return
68
+ self._cond.wait()
69
+ job = self._queue.popleft()
70
+ gap = self.interval - (time.monotonic() - last_start)
71
+ if gap > 0:
72
+ time.sleep(gap)
73
+ t = job.ticket
74
+ t.started = last_start = time.monotonic()
75
+ attempt = 0
76
+ while True:
77
+ began = time.monotonic()
78
+ try:
79
+ value = job.fn()
80
+ except BaseException as e: # noqa: BLE001 (the caller gets the original error)
81
+ t.run_seconds += time.monotonic() - began
82
+ if is_rate_limit(e) and attempt < self.retries and not self._closed:
83
+ delay = self.backoff * (2 ** attempt)
84
+ attempt += 1
85
+ log.warning("rate limit on the API line: waiting %.1f s, then retry %d of %d", delay, attempt, self.retries)
86
+ time.sleep(delay)
87
+ last_start = time.monotonic()
88
+ continue
89
+ t.set_error(e)
90
+ break
91
+ t.run_seconds += time.monotonic() - began
92
+ t.set_result(value)
93
+ break
94
+
95
+ def close(self) -> None:
96
+ with self._cond:
97
+ self._closed = True
98
+ dropped = list(self._queue)
99
+ self._queue.clear()
100
+ self._cond.notify_all()
101
+ for j in dropped:
102
+ j.ticket.set_error(SchedulerError("the scheduler was closed"))
103
+
104
+
105
+ class ApiScheduler:
106
+ """One ApiLine per API key (the key is part of the line name), created on first use."""
107
+
108
+ def __init__(self, retries: int = 2, backoff: float = 5.0, max_queue: int = 100, enabled: bool = True):
109
+ self.retries, self.backoff, self.max_queue, self.enabled = retries, backoff, max_queue, enabled
110
+ self._lines: dict[str, ApiLine] = {}
111
+ self._lock = threading.Lock()
112
+
113
+ def submit(self, line_key: str, fn: Callable[[], Any], interval: float = 0.0, label: str = "api call") -> Ticket:
114
+ if not self.enabled: # scheduling off: run right here, no line
115
+ t = Ticket(label)
116
+ t.started = time.monotonic()
117
+ try:
118
+ t.set_result(fn())
119
+ except BaseException as e: # noqa: BLE001
120
+ t.set_error(e)
121
+ return t
122
+ with self._lock:
123
+ line = self._lines.get(line_key)
124
+ if line is None:
125
+ line = self._lines[line_key] = ApiLine(interval, self.retries, self.backoff, self.max_queue)
126
+ line.interval = interval
127
+ return line.submit(fn, label)
128
+
129
+ def pending(self) -> dict[str, int]:
130
+ with self._lock:
131
+ return {k: ln.pending() for k, ln in self._lines.items()}
132
+
133
+ def close(self) -> None:
134
+ with self._lock:
135
+ lines, self._lines = list(self._lines.values()), {}
136
+ for ln in lines:
137
+ ln.close()
@@ -0,0 +1,28 @@
1
+ """Code work (converting files, building the code graph) runs in code, beside the LLM lines. It does not wait for the model,
2
+ but it uses the CPU the local model also needs, so only a few run at once."""
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+
10
+ class CodeSlots:
11
+ def __init__(self, workers: int = 2):
12
+ self._sem = threading.BoundedSemaphore(workers) if workers > 0 else None
13
+ self._lock = threading.Lock()
14
+ self.running = 0
15
+ self.peak = 0
16
+
17
+ def run(self, fn: Callable[[], Any]) -> Any:
18
+ if self._sem is None:
19
+ return fn()
20
+ with self._sem:
21
+ with self._lock:
22
+ self.running += 1
23
+ self.peak = max(self.peak, self.running)
24
+ try:
25
+ return fn()
26
+ finally:
27
+ with self._lock:
28
+ self.running -= 1
@@ -0,0 +1,92 @@
1
+ """ScheduledWrapper: puts the local model behind the local scheduler without changing the code that uses it.
2
+
3
+ Anything that calls `core.chat(...)` (the router, the input savers, the verifier) keeps working; the call becomes a job in the
4
+ right class. The class comes from the turn that is running (`task(...)`), set once by the chat service.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import contextvars
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+ from typing import Any
12
+
13
+ from pal.llm_scheduler.local import LocalScheduler
14
+ from pal.llm_scheduler.types import TaskClass, TurnStats
15
+ from pal.llm_wrapper.components.base import LLMResponse, LLMSyscall, LLMWrapper, WrapperCapabilities
16
+ from pal.tokens import estimate_tokens
17
+
18
+ _task: contextvars.ContextVar[tuple[TaskClass, bool]] = contextvars.ContextVar("pal_task", default=(TaskClass.CHAT, False))
19
+ _stats: contextvars.ContextVar[TurnStats | None] = contextvars.ContextVar("pal_turn_stats", default=None)
20
+
21
+
22
+ @contextmanager
23
+ def task(task_class: TaskClass, preemptible: bool = False) -> Iterator[None]:
24
+ """Everything the local model is asked inside this block belongs to `task_class`. With `preemptible`, a plain-text
25
+ generation may be paused and resumed (only for cores that really stream)."""
26
+ token = _task.set((task_class, preemptible))
27
+ try:
28
+ yield
29
+ finally:
30
+ _task.reset(token)
31
+
32
+
33
+ @contextmanager
34
+ def track() -> Iterator[TurnStats]:
35
+ stats = TurnStats()
36
+ token = _stats.set(stats)
37
+ try:
38
+ yield stats
39
+ finally:
40
+ _stats.reset(token)
41
+
42
+
43
+ def current_stats() -> TurnStats | None:
44
+ return _stats.get()
45
+
46
+
47
+ def _streams(core: LLMWrapper) -> bool:
48
+ return type(core).stream_chat is not LLMWrapper.stream_chat
49
+
50
+
51
+ class ScheduledWrapper(LLMWrapper):
52
+ name = "scheduled"
53
+
54
+ def __init__(self, core: LLMWrapper, scheduler: LocalScheduler | None):
55
+ self.core = core
56
+ self.scheduler = scheduler
57
+ self.config: Any = getattr(core, "config", None)
58
+
59
+ def chat(self, syscall: LLMSyscall) -> LLMResponse:
60
+ if self.scheduler is None:
61
+ return self.core.chat(syscall)
62
+ cls, preemptible = _task.get()
63
+ if preemptible and _streams(self.core):
64
+ ticket = self.scheduler.submit_stream(self.core, syscall, cls)
65
+ text = ticket.result()
66
+ prompt = estimate_tokens(" ".join(str(m.get("content", "")) for m in syscall.messages))
67
+ resp = LLMResponse(text=text, prompt_tokens=prompt, completion_tokens=estimate_tokens(text),
68
+ meta={"streamed": True, "pauses": ticket.pauses})
69
+ else:
70
+ ticket = self.scheduler.submit(lambda: self.core.chat(syscall), cls)
71
+ resp = ticket.result()
72
+ stats = _stats.get()
73
+ if stats is not None:
74
+ stats.add_local(ticket)
75
+ return resp
76
+
77
+ def stream_chat(self, syscall: LLMSyscall):
78
+ return self.core.stream_chat(syscall)
79
+
80
+ def token_count(self, text: str) -> int:
81
+ return self.core.token_count(text)
82
+
83
+ def embed(self, texts: list[str]) -> list[list[float]]:
84
+ return self.core.embed(texts)
85
+
86
+ def capabilities(self) -> WrapperCapabilities:
87
+ return self.core.capabilities()
88
+
89
+ def __getattr__(self, name: str) -> Any: # e.g. init_beam_search on in-process cores
90
+ if name in ("core", "scheduler", "config"):
91
+ raise AttributeError(name)
92
+ return getattr(self.core, name)