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/memory/pipeline.py ADDED
@@ -0,0 +1,122 @@
1
+ """
2
+ Persistent, cross-session agent memory (AIOS section 3.5) -- distinct
3
+ from context_manager's ContextPager, which only pages within ONE open
4
+ conversation and forgets everything when the process exits.
5
+
6
+ Composes MemoryManager (RAM cache + block-limit-triggered LRU-K
7
+ eviction -- see components/manager.py, components/lru_k.py) with
8
+ lightweight relevance scoring for retrieve(). Disk persistence for the
9
+ manager's swaps is provided either by a private, unscoped local store
10
+ (components/storage_manager.py, the default) or -- when this Pipeline
11
+ is given an agent_id and a shared StorageManagerPipeline -- by the
12
+ general-purpose, agent-scoped Storage Manager (AIOS section 3.6, see
13
+ pal/storage_manager/), matching "the memory manager calls the
14
+ storage manager to swap the data into the disk... based on the agent ID
15
+ passed from the memory manager."
16
+ """
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from pal.context_manager.utils.relevance import embedding_similarity, recency_score
22
+ from pal.memory.components.manager import MemoryManager
23
+ from pal.memory.components.note import MemoryNote
24
+ from pal.memory.components.storage_manager import StorageManager
25
+ from pal.memory.utils.keyword_overlap import keyword_overlap
26
+
27
+ _DEFAULT_STORE_ROOT = Path.home() / ".pal" / "memory"
28
+
29
+
30
+ class MemoryPipeline:
31
+ """
32
+ add(content) -> writes a new note through the MemoryManager (RAM
33
+ cache + durable disk persistence, LRU-K eviction once the RAM
34
+ cache nears block_limit).
35
+ retrieve(query, top_k) -> scores every persisted note (RAM- or
36
+ disk-resident) by embedding similarity (or keyword overlap if
37
+ embeddings unavailable) blended with recency. Scoring has to
38
+ inspect every note's content regardless of tier -- this is a
39
+ lightweight, non-indexed store, there's no cheaper way to rank
40
+ relevance -- but only the notes that make the returned top_k are
41
+ actually READ through the manager, which is what counts as an
42
+ access for LRU-K and may swap a disk-resident note back into RAM.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ store_path: Optional[str] = None,
48
+ block_limit: int = 200,
49
+ high_water_mark: float = 0.8,
50
+ lru_k: int = 2,
51
+ agent_id: Optional[str] = None,
52
+ storage_manager=None,
53
+ ):
54
+ if storage_manager is not None:
55
+ # Central, agent-scoped Storage Manager (AIOS 3.6) -- the
56
+ # memory manager's swaps go through the same backend an
57
+ # agent's own SDK calls would use, scoped by agent_id.
58
+ from pal.storage_manager.utils.memory_adapter import AgentScopedMemoryStorage
59
+ self.storage = AgentScopedMemoryStorage(storage_manager, agent_id or "default")
60
+ else:
61
+ # Standalone fallback -- a private, unscoped local JSON-
62
+ # per-note store. Used when this Pipeline isn't wired into
63
+ # a broader orchestrator/StorageManagerPipeline (e.g. the
64
+ # standalone webapp).
65
+ root = Path(store_path) if store_path else _DEFAULT_STORE_ROOT
66
+ self.storage = StorageManager(root)
67
+
68
+ self.manager = MemoryManager(self.storage, block_limit=block_limit, high_water_mark=high_water_mark, k=lru_k)
69
+
70
+ def add(self, content: str, session_id: Optional[str] = None, tags: Optional[list[str]] = None) -> MemoryNote:
71
+ note = MemoryNote(content=content, session_id=session_id, tags=tags)
72
+ self.manager.write(note)
73
+ return note
74
+
75
+ def retrieve(self, query: str, top_k: int = 3, min_score: float = 0.05) -> list[MemoryNote]:
76
+ all_notes = self.storage.read_all()
77
+ if not all_notes:
78
+ return []
79
+
80
+ contents = [n.content for n in all_notes]
81
+ sims = embedding_similarity(query, contents)
82
+
83
+ # embedding_similarity returns all-zero if sentence-transformers
84
+ # isn't installed -- fall back to keyword overlap in that case.
85
+ if all(s == 0.0 for s in sims):
86
+ sims = [keyword_overlap(query, c) for c in contents]
87
+
88
+ now = time.time()
89
+ scored = []
90
+ for note, sim in zip(all_notes, sims):
91
+ rec = recency_score(note.timestamp, now)
92
+ # 70% relevance, 30% recency -- relevance should dominate for
93
+ # memory retrieval (unlike hot/cold conversation paging,
94
+ # where recency matters more).
95
+ score = 0.7 * sim + 0.3 * rec
96
+ scored.append((score, note))
97
+
98
+ scored.sort(key=lambda x: x[0], reverse=True)
99
+ top_ids = [note.id for score, note in scored if score >= min_score][:top_k]
100
+
101
+ # read() (not the scored `note` objects directly) so this
102
+ # registers as a real access for LRU-K and can swap a
103
+ # disk-only note into RAM.
104
+ results = [self.manager.read(nid) for nid in top_ids]
105
+ return [r for r in results if r is not None]
106
+
107
+ def clear(self) -> None:
108
+ for note_id in list(self.storage.list_ids()):
109
+ self.manager.delete(note_id)
110
+
111
+ def stats(self) -> dict:
112
+ """RAM/disk split and cache pressure -- useful for observing the
113
+ LRU-K eviction behavior directly rather than just its effects."""
114
+ return {
115
+ "ram_count": len(self.manager.ram),
116
+ "disk_count": len(self.storage.list_ids()),
117
+ "usage_ratio": round(self.manager.usage_ratio(), 3),
118
+ "block_limit": self.manager.block_limit,
119
+ }
120
+
121
+ def __len__(self) -> int:
122
+ return len(self.storage.list_ids())
@@ -0,0 +1,3 @@
1
+ from pal.memory.utils.keyword_overlap import keyword_overlap
2
+
3
+ __all__ = ["keyword_overlap"]
@@ -0,0 +1,3 @@
1
+ from pal.context_manager.utils.relevance import keyword_overlap
2
+
3
+ __all__ = ["keyword_overlap"]
@@ -0,0 +1,3 @@
1
+ from pal.memory_tracker.pipeline import MemoryTracker, dir_bytes, process_memory
2
+
3
+ __all__ = ["MemoryTracker", "dir_bytes", "process_memory"]
File without changes
@@ -0,0 +1,56 @@
1
+ """How much memory this process uses, as best the platform allows. Never raises: anything unavailable is None."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+
6
+
7
+ def process_memory() -> dict:
8
+ """{"rss_mb", "peak_rss_mb", "source"}. Tries psutil, then the Unix `resource` module, then Windows' own API (ctypes)."""
9
+ try:
10
+ import psutil
11
+ p = psutil.Process()
12
+ rss = p.memory_info().rss
13
+ return {"rss_mb": round(rss / 1048576, 1), "peak_rss_mb": None, "source": "psutil"}
14
+ except Exception: # noqa: BLE001 (not installed, or not allowed)
15
+ pass
16
+ try:
17
+ import resource
18
+ peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
19
+ peak_mb = peak / 1048576 if sys.platform == "darwin" else peak / 1024 # macOS reports bytes, Linux kilobytes
20
+ return {"rss_mb": None, "peak_rss_mb": round(peak_mb, 1), "source": "resource"}
21
+ except Exception: # noqa: BLE001 (Windows has no `resource`)
22
+ pass
23
+ if sys.platform == "win32": # not tested outside Windows
24
+ try:
25
+ import ctypes
26
+ from ctypes import wintypes
27
+
28
+ class Counters(ctypes.Structure):
29
+ _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t),
30
+ ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t),
31
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
32
+ ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)]
33
+ c = Counters()
34
+ c.cb = ctypes.sizeof(c)
35
+ handle = ctypes.windll.kernel32.GetCurrentProcess() # type: ignore[attr-defined]
36
+ if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(c), c.cb): # type: ignore[attr-defined]
37
+ return {"rss_mb": round(c.WorkingSetSize / 1048576, 1), "peak_rss_mb": round(c.PeakWorkingSetSize / 1048576, 1), "source": "win32"}
38
+ except Exception: # noqa: BLE001
39
+ pass
40
+ return {"rss_mb": None, "peak_rss_mb": None, "source": "unavailable"}
41
+
42
+
43
+ def dir_bytes(path, limit_files: int = 20000) -> int:
44
+ """The size of a folder in bytes, counting at most `limit_files` files (a stats call must stay quick)."""
45
+ import os
46
+ total = seen = 0
47
+ for root, _dirs, files in os.walk(path):
48
+ for f in files:
49
+ try:
50
+ total += os.path.getsize(os.path.join(root, f))
51
+ except OSError:
52
+ pass
53
+ seen += 1
54
+ if seen >= limit_files:
55
+ return total
56
+ return total
@@ -0,0 +1,44 @@
1
+ """The Memory Tracker: how much memory is the system using, and where?
2
+
3
+ Components that hold something (the Notes Manager's notes, the open sessions, the knowledge graph's projects, the storage folder) register a
4
+ gauge here: a small function that reports their numbers. `snapshot()` calls them all and adds the process's own memory, so one look shows
5
+ what is using memory and how full the caches are. A gauge that fails is reported as an error, never raised: tracking must not break the chat.
6
+ It also counts how many remembered notes were recalled into each turn.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterable
11
+ from typing import Any
12
+
13
+ from pal.memory_tracker.components.process import dir_bytes, process_memory
14
+
15
+
16
+ class MemoryTracker:
17
+ def __init__(self) -> None:
18
+ self._gauges: dict[str, Callable[[], Any]] = {}
19
+
20
+ def register(self, name: str, gauge: Callable[[], Any]) -> None:
21
+ """Add (or replace) a gauge: a no-argument function returning a number or a dict of numbers."""
22
+ self._gauges[name] = gauge
23
+
24
+ def snapshot(self) -> dict:
25
+ out: dict = {}
26
+ for name, gauge in self._gauges.items():
27
+ try:
28
+ out[name] = gauge()
29
+ except Exception as e: # noqa: BLE001
30
+ out[name] = {"error": type(e).__name__}
31
+ out["process"] = process_memory()
32
+ return out
33
+
34
+ @staticmethod
35
+ def note_hits(turn, hits: int) -> None:
36
+ """Record how many remembered notes were recalled into this turn's request."""
37
+ turn.memory_hits = int(hits)
38
+
39
+ @staticmethod
40
+ def summarize(turns: Iterable) -> dict:
41
+ return {"memory_hits": sum(t.memory_hits for t in turns)}
42
+
43
+
44
+ __all__ = ["MemoryTracker", "dir_bytes", "process_memory"]
@@ -0,0 +1,4 @@
1
+ from pal.metrics.components.collector import MetricsCollector, RequestMetric, TurnMetric
2
+ from pal.metrics.pipeline import MetricsPipeline
3
+
4
+ __all__ = ["MetricsPipeline", "MetricsCollector", "RequestMetric", "TurnMetric"]
@@ -0,0 +1,4 @@
1
+ from pal.metrics.components.collector import MetricsCollector, RequestMetric, TurnMetric
2
+ from pal.metrics.components.exporter import export_json
3
+
4
+ __all__ = ["MetricsCollector", "RequestMetric", "TurnMetric", "export_json"]
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import time
5
+ from collections import deque
6
+ from dataclasses import asdict, dataclass, field
7
+
8
+ from pal.latency_tracker import LatencyTracker
9
+ from pal.memory_tracker import MemoryTracker
10
+ from pal.token_tracker import TokenTracker
11
+
12
+
13
+ @dataclass
14
+ class RequestMetric:
15
+ """Legacy per-request record used by the older Pipeline/experiments."""
16
+
17
+ timestamp: float
18
+ tokens_naive: int
19
+ tokens_optimized: int
20
+ latency: float
21
+ cache_hit: bool
22
+
23
+ @property
24
+ def tokens_saved(self) -> int:
25
+ return max(0, self.tokens_naive - self.tokens_optimized)
26
+
27
+
28
+ @dataclass
29
+ class TurnMetric:
30
+ """One chat turn. Latencies are seconds. `saved_*` are estimates (chars/4 unless the provider
31
+ counter is on): what a naive client would have sent minus what was sent."""
32
+
33
+ route: str # "api" | "local" | "tool"
34
+ session: str = ""
35
+ provider: str = ""
36
+ model: str = ""
37
+ latency_total: float = 0.0
38
+ latency_local: float = 0.0 # routing, input savers, local answer
39
+ latency_api: float = 0.0 # API calls, tool rounds included
40
+ latency_tools: float = 0.0
41
+ input_tokens: int = 0 # billed by the API (0 for local/tool turns)
42
+ output_tokens: int = 0
43
+ local_tokens: int = 0
44
+ saved_savers: int = 0
45
+ saved_paging: int = 0
46
+ saved_graph: int = 0
47
+ saved_documents: int = 0
48
+ api_calls: int = 0
49
+ tool_calls: int = 0
50
+ memory_hits: int = 0
51
+ escalated: bool = False
52
+ wait_local: float = 0.0 # seconds this turn's local jobs waited in the local scheduler
53
+ wait_api: float = 0.0 # seconds its API calls waited in the API line
54
+ local_pauses: int = 0 # times a higher-priority job paused one of its local jobs
55
+ compressed_turns: int = 0 # old assistant replies shown compressed this turn
56
+ compression_saved: int = 0 # tokens that compression saved versus showing those turns in full
57
+ cost_usd: float = 0.0 # what the API calls cost (LiteLLM's price table); 0 when the model has no known price
58
+ saved_cost_usd: float = 0.0 # estimated tokens saved x the model's input price
59
+ fallback: str = "" # "provider/model" when the fallback model answered
60
+ fallback_reason: str = "" # what failed first, e.g. RateLimitError
61
+ stage_seconds: dict = field(default_factory=dict) # where this turn's time went, by stage (see latency_tracker)
62
+ error: str = ""
63
+ timestamp: float = field(default_factory=time.time)
64
+
65
+ @property
66
+ def saved_total(self) -> int:
67
+ return self.saved_savers + self.saved_paging + self.saved_graph + self.saved_documents
68
+
69
+
70
+ class MetricsCollector:
71
+ """Thread-safe, bounded: keeps the most recent `max_records` records of each kind."""
72
+
73
+ def __init__(self, max_records: int = 1000, tokens: TokenTracker | None = None, latency: LatencyTracker | None = None,
74
+ memory: MemoryTracker | None = None):
75
+ self.tokens, self.latency, self.memory = tokens or TokenTracker(), latency or LatencyTracker(), memory or MemoryTracker()
76
+ self._lock = threading.Lock()
77
+ self.records: deque[RequestMetric] = deque(maxlen=max_records)
78
+ self.turns: deque[TurnMetric] = deque(maxlen=max_records)
79
+ self.total_turns = 0
80
+
81
+ def record(self, tokens_naive: int, tokens_optimized: int, latency: float, cache_hit: bool):
82
+ with self._lock:
83
+ self.records.append(RequestMetric(time.time(), tokens_naive, tokens_optimized, latency, cache_hit))
84
+
85
+ def record_turn(self, turn: TurnMetric) -> None:
86
+ with self._lock:
87
+ self.turns.append(turn)
88
+ self.total_turns += 1
89
+
90
+ def reset(self) -> None:
91
+ with self._lock:
92
+ self.records.clear()
93
+ self.turns.clear()
94
+ self.total_turns = 0
95
+
96
+ def snapshot(self) -> tuple[list[RequestMetric], list[TurnMetric]]:
97
+ with self._lock:
98
+ return list(self.records), list(self.turns)
99
+
100
+ def summary(self, cache_overhead_bytes: int = 0) -> dict:
101
+ records, _ = self.snapshot()
102
+ if not records:
103
+ return {"requests": 0}
104
+ total_saved = sum(r.tokens_saved for r in records)
105
+ avg_latency = sum(r.latency for r in records) / len(records)
106
+ hit_rate = sum(1 for r in records if r.cache_hit) / len(records)
107
+ # ~4 bytes per token as a rough common scale for cache overhead vs tokens saved
108
+ return {
109
+ "requests": len(records),
110
+ "total_tokens_saved": total_saved,
111
+ "avg_latency_sec": round(avg_latency, 4),
112
+ "cache_hit_rate": round(hit_rate, 4),
113
+ "cache_overhead_bytes": cache_overhead_bytes,
114
+ "cache_pays_for_itself": cache_overhead_bytes <= total_saved * 4,
115
+ }
116
+
117
+ def chat_summary(self, session: str | None = None) -> dict:
118
+ """The turns added up, by the three trackers: tokens (and savings, cost), latency (and where the time went), memory."""
119
+ _, turns = self.snapshot()
120
+ if session:
121
+ turns = [t for t in turns if t.session == session[:8]]
122
+ if not turns:
123
+ return {"turns": 0}
124
+ api_turns = [t for t in turns if t.route == "api"]
125
+ return {
126
+ "turns": len(turns),
127
+ "turns_recorded": self.total_turns,
128
+ "api_calls_avoided": len(turns) - len(api_turns),
129
+ **self.tokens.summarize(turns),
130
+ **self.latency.summarize(turns),
131
+ **self.memory.summarize(turns),
132
+ "fallbacks": sum(1 for t in turns if t.fallback),
133
+ "tool_calls": sum(t.tool_calls for t in turns),
134
+ "escalations": sum(1 for t in turns if t.escalated),
135
+ "errors": sum(1 for t in turns if t.error),
136
+ }
137
+
138
+ def turn_dicts(self) -> list[dict]:
139
+ _, turns = self.snapshot()
140
+ return [{**asdict(t), "saved_total": t.saved_total} for t in turns]
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from pal.metrics.components.collector import MetricsCollector
8
+
9
+
10
+ def export_json(collector: MetricsCollector, path: str | os.PathLike) -> None:
11
+ records, _ = collector.snapshot()
12
+ data = {
13
+ "summary": collector.summary(),
14
+ "chat": collector.chat_summary(),
15
+ "turns": collector.turn_dicts(),
16
+ "records": [
17
+ {
18
+ "timestamp": r.timestamp,
19
+ "tokens_naive": r.tokens_naive,
20
+ "tokens_optimized": r.tokens_optimized,
21
+ "tokens_saved": r.tokens_saved,
22
+ "latency": r.latency,
23
+ "cache_hit": r.cache_hit,
24
+ }
25
+ for r in records
26
+ ],
27
+ }
28
+ Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ from pal.metrics.components.collector import MetricsCollector, TurnMetric
7
+ from pal.metrics.components.exporter import export_json
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ class MetricsPipeline:
13
+ def __init__(self, max_records: int = 1000, enabled: bool = True):
14
+ self.enabled = enabled
15
+ self.collector = MetricsCollector(max_records=max_records)
16
+ self.tokens, self.latency, self.memory = self.collector.tokens, self.collector.latency, self.collector.memory # the three trackers
17
+
18
+ # legacy per-request API (older Pipeline and experiments)
19
+ def record(self, tokens_naive: int, tokens_optimized: int, latency: float, cache_hit: bool) -> None:
20
+ self.collector.record(tokens_naive, tokens_optimized, latency, cache_hit)
21
+
22
+ def record_turn(self, turn: TurnMetric) -> None:
23
+ if not self.enabled:
24
+ return
25
+ self.collector.record_turn(turn)
26
+ log.info(
27
+ "turn route=%s provider=%s model=%s total=%.2fs local=%.2fs api=%.2fs in=%d out=%d saved~%d tools=%d%s",
28
+ turn.route, turn.provider or "-", turn.model or "-", turn.latency_total, turn.latency_local,
29
+ turn.latency_api, turn.input_tokens, turn.output_tokens, turn.saved_total, turn.tool_calls,
30
+ f" error={turn.error}" if turn.error else "")
31
+
32
+ def summary(self, cache_overhead_bytes: int = 0) -> dict:
33
+ return self.collector.summary(cache_overhead_bytes=cache_overhead_bytes)
34
+
35
+ def chat_summary(self, session: str | None = None) -> dict:
36
+ return self.collector.chat_summary(session)
37
+
38
+ def turns(self) -> list[dict]:
39
+ return self.collector.turn_dicts()
40
+
41
+ def reset(self) -> None:
42
+ self.collector.reset()
43
+
44
+ def export_json(self, path: str | os.PathLike) -> None:
45
+ export_json(self.collector, path)
46
+
47
+ @property
48
+ def records(self):
49
+ return self.collector.records
@@ -0,0 +1,2 @@
1
+ """No shared utilities yet for metrics -- reserved for future
2
+ aggregation/formatting helpers shared across exporters."""
@@ -0,0 +1,5 @@
1
+ from pal.notes_manager.components.privacy import looks_secret
2
+ from pal.notes_manager.components.recall import Recall, build_recall
3
+ from pal.notes_manager.pipeline import NotesManager
4
+
5
+ __all__ = ["NotesManager", "Recall", "build_recall", "looks_secret"]
File without changes
@@ -0,0 +1,14 @@
1
+ """What is never kept as a note: anything that looks like a secret (API keys, tokens, passwords, private keys, long hashes)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ _SECRET = re.compile(
7
+ r"sk-[A-Za-z0-9_\-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}"
8
+ r"|eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----"
9
+ r"|(?i:password|passwd|secret|token|api[_ -]?key)\s*[:=]\s*\S{4,}"
10
+ r"|\b[A-Fa-f0-9]{40,}\b|\b[A-Za-z0-9+/]{48,}={0,2}(?=\s|$)")
11
+
12
+
13
+ def looks_secret(text: str) -> bool:
14
+ return bool(_SECRET.search(text))
@@ -0,0 +1,31 @@
1
+ """Recall: which notes from OTHER sessions go into one request, within a token budget."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Recall:
10
+ text: str # the <memory> block to add to the request
11
+ hits: int # how many notes are in it
12
+ tokens: int # what they cost
13
+
14
+
15
+ def build_recall(notes, session_id: str, limit: int, budget_tokens: int, count: Callable[[str], int]) -> Recall | None:
16
+ """The best matching notes written by other sessions (this session's own messages are already in the history), at most `limit` of
17
+ them and within `budget_tokens`. None when nothing fits."""
18
+ lines: list[str] = []
19
+ used = 0
20
+ for n in notes:
21
+ if n.session_id == session_id:
22
+ continue
23
+ line = f"- {n.content}"
24
+ cost = count(line)
25
+ if used + cost > budget_tokens or len(lines) >= limit:
26
+ continue
27
+ lines.append(line)
28
+ used += cost
29
+ if not lines:
30
+ return None
31
+ return Recall("<memory>\nEarlier messages from this user, from other sessions:\n" + "\n".join(lines) + "\n</memory>", len(lines), used)
@@ -0,0 +1,28 @@
1
+ """Tidy: merging duplicate and near-duplicate notes. No model is involved."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Generator
5
+ from contextlib import AbstractContextManager
6
+
7
+
8
+ def tidy_steps(memory, lock: AbstractContextManager, near: float = 0.9, batch: int = 100,
9
+ on_removed: Callable[[str], None] | None = None) -> Generator[None, None, int]:
10
+ """Merge duplicate and near-duplicate notes, keeping the one that was used most (then the newest). A generator that yields every
11
+ `batch` notes, so a scheduler can pause it between batches; it returns how many notes were removed."""
12
+ notes = sorted(memory.storage.read_all(), key=lambda n: (-n.retrieval_count, -n.timestamp))
13
+ kept: list[set] = []
14
+ removed = 0
15
+ for i, n in enumerate(notes):
16
+ words = set(" ".join(n.content.lower().split()).split())
17
+ duplicate = any(len(words | k) and len(words & k) / len(words | k) >= near for k in kept) if len(words) >= 3 else False
18
+ if duplicate:
19
+ with lock:
20
+ memory.manager.delete(n.id)
21
+ removed += 1
22
+ if on_removed:
23
+ on_removed(n.id)
24
+ else:
25
+ kept.append(words)
26
+ if (i + 1) % batch == 0:
27
+ yield
28
+ return removed