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