pcli-agent 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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
pcli/config/__init__.py
ADDED
|
File without changes
|
pcli/config/paths.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Cross-platform config/data/cache directory resolution for pcli."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from platformdirs import PlatformDirs
|
|
6
|
+
|
|
7
|
+
_dirs = PlatformDirs(appname="pcli", appauthor=False)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def config_dir() -> Path:
|
|
11
|
+
path = Path(_dirs.user_config_dir)
|
|
12
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
return path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def data_dir() -> Path:
|
|
17
|
+
path = Path(_dirs.user_data_dir)
|
|
18
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cache_dir() -> Path:
|
|
23
|
+
path = Path(_dirs.user_cache_dir)
|
|
24
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sessions_dir() -> Path:
|
|
29
|
+
path = data_dir() / "sessions"
|
|
30
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
return path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def toolbox_dir() -> Path:
|
|
35
|
+
path = data_dir() / "toolbox"
|
|
36
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def agent_tools_file() -> Path:
|
|
41
|
+
return data_dir() / "agent_tools.json"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def memory_file() -> Path:
|
|
45
|
+
# JSON, not TOML: written to at runtime (new entries appended, old ones
|
|
46
|
+
# evicted), same reasoning as permissions_file().
|
|
47
|
+
return data_dir() / "memory.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def config_file() -> Path:
|
|
51
|
+
return config_dir() / "config.toml"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pricing_file() -> Path:
|
|
55
|
+
return config_dir() / "pricing.toml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def context_limits_file() -> Path:
|
|
59
|
+
return config_dir() / "context_limits.toml"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def guardrails_file() -> Path:
|
|
63
|
+
return config_dir() / "guardrails.toml"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def permissions_file() -> Path:
|
|
67
|
+
# JSON, not TOML: this file is written to at runtime (new "always allow"
|
|
68
|
+
# grants are appended), and the stdlib has no TOML writer.
|
|
69
|
+
return config_dir() / "permissions.json"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cost_ledger_file() -> Path:
|
|
73
|
+
return data_dir() / "cost_ledger.jsonl"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def schedule_file() -> Path:
|
|
77
|
+
# JSON, not TOML: written to at runtime (last_run_at/next_run_at
|
|
78
|
+
# bookkeeping updates after every scheduled run) - same reasoning as
|
|
79
|
+
# memory_file().
|
|
80
|
+
return data_dir() / "schedule.json"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def browser_profiles_dir(profile: str = "default") -> Path:
|
|
84
|
+
# A dedicated Playwright persistent-context profile directory per name
|
|
85
|
+
# (see browser/session.py) - this is what makes a login/cookies survive
|
|
86
|
+
# across separate pcli invocations, not just within one running session.
|
|
87
|
+
path = data_dir() / "browser_profiles" / profile
|
|
88
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def browser_screenshots_dir() -> Path:
|
|
93
|
+
path = data_dir() / "browser_screenshots"
|
|
94
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
return path
|
pcli/config/settings.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Application settings.
|
|
2
|
+
|
|
3
|
+
Precedence (highest to lowest): explicit constructor kwargs (e.g. CLI flags) >
|
|
4
|
+
environment variables > config.toml > built-in defaults.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import tomllib
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import AliasChoices, Field
|
|
13
|
+
from pydantic_settings import (
|
|
14
|
+
BaseSettings,
|
|
15
|
+
PydanticBaseSettingsSource,
|
|
16
|
+
SettingsConfigDict,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from pcli.config.paths import config_file
|
|
20
|
+
|
|
21
|
+
# Local model inference (LM Studio, Ollama, ...) is routinely far slower than
|
|
22
|
+
# a hosted API - no batching, often CPU-bound - and pcli's own logs have
|
|
23
|
+
# caught request_timeout_s's 120s default being exceeded by perfectly normal
|
|
24
|
+
# local generation (a model streaming steadily at ~5 tokens/sec can easily
|
|
25
|
+
# take several minutes for one reply). Local-api gateways get at least this
|
|
26
|
+
# much, regardless of the configured request_timeout_s, unless the user has
|
|
27
|
+
# explicitly configured something even higher. See Settings.effective_request_timeout_s.
|
|
28
|
+
_LOCAL_API_MIN_TIMEOUT_S = 600.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _TomlFileSource(PydanticBaseSettingsSource):
|
|
32
|
+
"""Reads config.toml and flattens one level of [table] nesting to table_key."""
|
|
33
|
+
|
|
34
|
+
def get_field_value(self, field: Any, field_name: str) -> tuple[Any, str, bool]:
|
|
35
|
+
return None, field_name, False
|
|
36
|
+
|
|
37
|
+
def __call__(self) -> dict[str, Any]:
|
|
38
|
+
path = config_file()
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return {}
|
|
41
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
flat: dict[str, Any] = {}
|
|
43
|
+
for key, value in raw.items():
|
|
44
|
+
if isinstance(value, dict):
|
|
45
|
+
for sub_key, sub_value in value.items():
|
|
46
|
+
flat[f"{key}_{sub_key}"] = sub_value
|
|
47
|
+
else:
|
|
48
|
+
flat[key] = value
|
|
49
|
+
return flat
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Settings(BaseSettings):
|
|
53
|
+
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
|
|
54
|
+
|
|
55
|
+
gateway_base_url: str = Field(
|
|
56
|
+
default="",
|
|
57
|
+
validation_alias=AliasChoices("PCLI_GATEWAY_URL", "gateway_base_url"),
|
|
58
|
+
description="Base URL of the OpenAI-compatible LLM gateway, e.g. https://gateway.internal/v1",
|
|
59
|
+
)
|
|
60
|
+
gateway_api_key: str = Field(
|
|
61
|
+
default="",
|
|
62
|
+
validation_alias=AliasChoices("PCLI_GATEWAY_API_KEY", "gateway_api_key"),
|
|
63
|
+
repr=False,
|
|
64
|
+
)
|
|
65
|
+
brave_search_api_key: str = Field(
|
|
66
|
+
default="",
|
|
67
|
+
validation_alias=AliasChoices("PCLI_BRAVE_SEARCH_API_KEY", "brave_search_api_key"),
|
|
68
|
+
repr=False,
|
|
69
|
+
description="Optional Brave Search API key for the web_search tool. When unset, "
|
|
70
|
+
"web_search falls back to a best-effort, no-API-key scrape of DuckDuckGo's HTML "
|
|
71
|
+
"results page instead — works out of the box but is inherently more fragile.",
|
|
72
|
+
)
|
|
73
|
+
gateway_auth_header: str = Field(
|
|
74
|
+
default="Authorization",
|
|
75
|
+
validation_alias=AliasChoices("PCLI_GATEWAY_AUTH_HEADER", "gateway_auth_header"),
|
|
76
|
+
description="Header name used to send the API key. Value sent is 'Bearer <key>' for "
|
|
77
|
+
"'Authorization', otherwise the raw key.",
|
|
78
|
+
)
|
|
79
|
+
telegram_bot_token: str = Field(
|
|
80
|
+
default="",
|
|
81
|
+
validation_alias=AliasChoices("PCLI_TELEGRAM_BOT_TOKEN", "telegram_bot_token"),
|
|
82
|
+
repr=False,
|
|
83
|
+
description="Bot token for `pcli telegram` (from @BotFather). Env-var/config-kwarg "
|
|
84
|
+
"only, same as gateway_api_key - never written by update_config_file, so it's never "
|
|
85
|
+
"persisted to config.toml.",
|
|
86
|
+
)
|
|
87
|
+
telegram_chat_id: int = Field(
|
|
88
|
+
default=0,
|
|
89
|
+
validation_alias=AliasChoices("PCLI_TELEGRAM_CHAT_ID", "telegram_chat_id"),
|
|
90
|
+
description="The one Telegram chat `pcli telegram` will talk to - messages from any "
|
|
91
|
+
"other chat are silently ignored. This is personal automation, not a multi-user bot. "
|
|
92
|
+
"0 means unset.",
|
|
93
|
+
)
|
|
94
|
+
default_model: str = Field(
|
|
95
|
+
default="",
|
|
96
|
+
validation_alias=AliasChoices("PCLI_MODEL", "default_model"),
|
|
97
|
+
)
|
|
98
|
+
compaction_model: str | None = Field(
|
|
99
|
+
default=None,
|
|
100
|
+
validation_alias=AliasChoices("PCLI_COMPACTION_MODEL", "compaction_model"),
|
|
101
|
+
description="Model used for auto/manual-compaction summarization (agent/compaction.py) "
|
|
102
|
+
"and memory extraction (memory/extraction.py) instead of default_model, when set. Both "
|
|
103
|
+
"are mechanical, lower-stakes background calls (summarizing already-had conversation, "
|
|
104
|
+
"spotting durable facts) rather than the main reasoning loop, so a smaller/cheaper model "
|
|
105
|
+
"is usually a safe cost cut here even when default_model is a frontier model. Falls back "
|
|
106
|
+
"to default_model when unset (the previous, only behavior).",
|
|
107
|
+
)
|
|
108
|
+
max_session_cost_usd: float | None = Field(
|
|
109
|
+
default=None,
|
|
110
|
+
validation_alias=AliasChoices("PCLI_MAX_SESSION_COST_USD", "max_session_cost_usd"),
|
|
111
|
+
description="Hard cap on a single session's total spend (Session.cost.session_total_usd, "
|
|
112
|
+
"which already includes subagent/compaction/memory-extraction usage folded into it). "
|
|
113
|
+
"Checked at the start of every internal LLM round-trip (agent/loop.py's AgentLoop."
|
|
114
|
+
"run_turn), not just once per turn, so a single tool-call-heavy turn can't blow straight "
|
|
115
|
+
"through it. None (the default) means no cap - spend is tracked and reported but never "
|
|
116
|
+
"enforced, the previous, only behavior. Changeable live in the TUI with /budget; "
|
|
117
|
+
"overridable for a single headless run without touching this persisted value via "
|
|
118
|
+
"'pcli run --max-cost'.",
|
|
119
|
+
)
|
|
120
|
+
audit_mode_enabled: bool = Field(
|
|
121
|
+
default=False,
|
|
122
|
+
validation_alias=AliasChoices("PCLI_AUDIT_MODE_ENABLED", "audit_mode_enabled"),
|
|
123
|
+
description="Records a tamper-evident, hash-chained audit entry (session/audit.py) for "
|
|
124
|
+
"every permission/guardrail decision and every tool call, alongside the existing "
|
|
125
|
+
"Session.tool_invocations/permission_grants records. Off by default - real, if small, "
|
|
126
|
+
"per-tool-call overhead most users don't need. Verify a session's chain wasn't edited "
|
|
127
|
+
"after the fact with 'pcli sessions verify <id>'. Overridable for a single headless run "
|
|
128
|
+
"without touching this persisted value via 'pcli run --audit'/'pcli schedule add "
|
|
129
|
+
"--audit'.",
|
|
130
|
+
)
|
|
131
|
+
default_temperature: float | None = Field(
|
|
132
|
+
default=None,
|
|
133
|
+
validation_alias=AliasChoices("PCLI_DEFAULT_TEMPERATURE", "default_temperature"),
|
|
134
|
+
description="Sampling temperature sent with each request. Unset (default, None) means "
|
|
135
|
+
"no temperature field is sent at all, so the gateway/model's own default applies. "
|
|
136
|
+
"Changeable live with /temperature.",
|
|
137
|
+
)
|
|
138
|
+
request_timeout_s: float = Field(
|
|
139
|
+
default=120.0,
|
|
140
|
+
validation_alias=AliasChoices("PCLI_REQUEST_TIMEOUT_S", "request_timeout_s"),
|
|
141
|
+
)
|
|
142
|
+
max_retries: int = Field(
|
|
143
|
+
default=4,
|
|
144
|
+
validation_alias=AliasChoices("PCLI_MAX_RETRIES", "max_retries"),
|
|
145
|
+
)
|
|
146
|
+
max_tool_iterations: int = Field(
|
|
147
|
+
default=25,
|
|
148
|
+
validation_alias=AliasChoices("PCLI_MAX_TOOL_ITERATIONS", "max_tool_iterations"),
|
|
149
|
+
)
|
|
150
|
+
subagent_max_iterations: int = Field(
|
|
151
|
+
default=30,
|
|
152
|
+
validation_alias=AliasChoices("PCLI_SUBAGENT_MAX_ITERATIONS", "subagent_max_iterations"),
|
|
153
|
+
description="Hard ceiling on a subagent's (spawn_subagent, explore_codebase, etc.) own "
|
|
154
|
+
"tool-call iterations - a model requesting more via spawn_subagent's max_iterations "
|
|
155
|
+
"argument is still capped at this value. Always enforced, even in local-api mode where "
|
|
156
|
+
"max_tool_iterations itself is uncapped: nesting depth/runaway cost is a distinct "
|
|
157
|
+
"safety concern from the parent turn's own iteration limit.",
|
|
158
|
+
)
|
|
159
|
+
sandbox_backend: str = Field(
|
|
160
|
+
default="auto",
|
|
161
|
+
validation_alias=AliasChoices("PCLI_SANDBOX_BACKEND", "sandbox_backend"),
|
|
162
|
+
description="auto | docker | subprocess | none",
|
|
163
|
+
)
|
|
164
|
+
sandbox_cpu_limit_s: int | None = Field(
|
|
165
|
+
default=30,
|
|
166
|
+
validation_alias=AliasChoices("PCLI_SANDBOX_CPU_LIMIT_S", "sandbox_cpu_limit_s"),
|
|
167
|
+
description="RestrictedSubprocessSandbox (the 'subprocess' backend) only, POSIX only: "
|
|
168
|
+
"CPU-time limit (RLIMIT_CPU) applied to every spawned process. None disables it, "
|
|
169
|
+
"leaving the wall-clock timeout as the only cap. No effect on DockerSandbox (which "
|
|
170
|
+
"uses --cpus) or on Windows (no RLIMIT_CPU equivalent).",
|
|
171
|
+
)
|
|
172
|
+
sandbox_memory_limit_bytes: int | None = Field(
|
|
173
|
+
default=None,
|
|
174
|
+
validation_alias=AliasChoices(
|
|
175
|
+
"PCLI_SANDBOX_MEMORY_LIMIT_BYTES", "sandbox_memory_limit_bytes"
|
|
176
|
+
),
|
|
177
|
+
description="RestrictedSubprocessSandbox (the 'subprocess' backend) only, POSIX only: "
|
|
178
|
+
"virtual-address-space limit (RLIMIT_AS) applied to every spawned process. None "
|
|
179
|
+
"(the default) disables it - RLIMIT_AS bounds virtual memory, not actual usage, and "
|
|
180
|
+
"Go-based CLIs (kubectl, terraform, ...) routinely reserve far more of that than "
|
|
181
|
+
"they actually use, so a default-on limit here made ordinary tool calls fail "
|
|
182
|
+
"outright. Set an explicit byte count only if you deliberately want a memory cap "
|
|
183
|
+
"(e.g. on a constrained VM) and have confirmed your tools tolerate it. No effect on "
|
|
184
|
+
"DockerSandbox (which uses --memory, a real cgroup-enforced limit) or on Windows.",
|
|
185
|
+
)
|
|
186
|
+
ui_theme: str = Field(
|
|
187
|
+
default="textual-dark",
|
|
188
|
+
validation_alias=AliasChoices("PCLI_UI_THEME", "ui_theme"),
|
|
189
|
+
description="Textual theme name applied on startup (App.theme) - any of Textual's own "
|
|
190
|
+
"builtins (textual-dark, gruvbox, nord, dracula, monokai, ...) or one of pcli's own "
|
|
191
|
+
"vim-* themes (see tui/themes.py). Set live and persisted via the TUI's /theme "
|
|
192
|
+
"command; an unknown/stale value here is ignored rather than failing startup, "
|
|
193
|
+
"falling back to whatever App.theme already defaults to.",
|
|
194
|
+
)
|
|
195
|
+
artifact_threshold_chars: int = Field(
|
|
196
|
+
default=4000,
|
|
197
|
+
validation_alias=AliasChoices("PCLI_ARTIFACT_THRESHOLD_CHARS", "artifact_threshold_chars"),
|
|
198
|
+
description="Tool results longer than this are truncated out of the live conversation "
|
|
199
|
+
"and archived to the artifact library, retrievable via fetch_artifact.",
|
|
200
|
+
)
|
|
201
|
+
local_api_gateways: list[str] = Field(
|
|
202
|
+
default_factory=list,
|
|
203
|
+
validation_alias=AliasChoices("PCLI_LOCAL_API_GATEWAYS", "local_api_gateways"),
|
|
204
|
+
description="Gateway base URLs running in local-api mode (set via --local-api, paired "
|
|
205
|
+
"to whichever gateway is active at the time): max_tool_iterations and the "
|
|
206
|
+
"guardrails' max_tool_calls_per_turn/per_minute are uncapped, and cost is forced to "
|
|
207
|
+
"$0 rather than looked up in the pricing table (avoids a local model's name "
|
|
208
|
+
"coincidentally matching a paid builtin pricing pattern, e.g. 'llama-3*').",
|
|
209
|
+
)
|
|
210
|
+
auto_compact_enabled: bool = Field(
|
|
211
|
+
default=True,
|
|
212
|
+
validation_alias=AliasChoices("PCLI_AUTO_COMPACT_ENABLED", "auto_compact_enabled"),
|
|
213
|
+
description="Whether old conversation history is automatically summarized and "
|
|
214
|
+
"archived (see agent/compaction.py) once context usage crosses auto_compact_threshold.",
|
|
215
|
+
)
|
|
216
|
+
auto_compact_threshold: float = Field(
|
|
217
|
+
default=0.8,
|
|
218
|
+
validation_alias=AliasChoices("PCLI_AUTO_COMPACT_THRESHOLD", "auto_compact_threshold"),
|
|
219
|
+
description="Fraction of the model's context limit (see current_context_usage in "
|
|
220
|
+
"cost/context.py) at which auto-compaction triggers after a turn completes.",
|
|
221
|
+
)
|
|
222
|
+
auto_compact_keep_recent_turns: int = Field(
|
|
223
|
+
default=2,
|
|
224
|
+
validation_alias=AliasChoices(
|
|
225
|
+
"PCLI_AUTO_COMPACT_KEEP_RECENT_TURNS", "auto_compact_keep_recent_turns"
|
|
226
|
+
),
|
|
227
|
+
description="Number of most-recent user turns left untouched (verbatim) by "
|
|
228
|
+
"compaction; only older turns get summarized and archived.",
|
|
229
|
+
)
|
|
230
|
+
memory_enabled: bool = Field(
|
|
231
|
+
default=True,
|
|
232
|
+
validation_alias=AliasChoices("PCLI_MEMORY_ENABLED", "memory_enabled"),
|
|
233
|
+
description="Whether pcli maintains a global, cross-session user-memory profile "
|
|
234
|
+
"(nature of work, preferences, conversation style, recurring task patterns) - "
|
|
235
|
+
"injected into every session's system prompt and extended by the remember tool "
|
|
236
|
+
"(explicit requests) and an automatic extraction pass piggybacked on auto-compaction "
|
|
237
|
+
"(see agent/compaction.py).",
|
|
238
|
+
)
|
|
239
|
+
memory_max_entries: int = Field(
|
|
240
|
+
default=40,
|
|
241
|
+
validation_alias=AliasChoices("PCLI_MEMORY_MAX_ENTRIES", "memory_max_entries"),
|
|
242
|
+
description="Hard cap on the number of stored memory entries - the oldest "
|
|
243
|
+
"source='derived' entry is evicted first once adding a new one would exceed this; "
|
|
244
|
+
"source='explicit' entries (the user directly asked to be remembered) are never "
|
|
245
|
+
"auto-evicted.",
|
|
246
|
+
)
|
|
247
|
+
prune_tool_results_enabled: bool = Field(
|
|
248
|
+
default=True,
|
|
249
|
+
validation_alias=AliasChoices(
|
|
250
|
+
"PCLI_PRUNE_TOOL_RESULTS_ENABLED", "prune_tool_results_enabled"
|
|
251
|
+
),
|
|
252
|
+
description="Whether old tool-call results are automatically shrunk to a compact "
|
|
253
|
+
"placeholder (see agent/context_pruning.py) to save context, well before "
|
|
254
|
+
"auto-compaction's own threshold would trigger. No LLM call involved, unlike "
|
|
255
|
+
"compaction — a purely mechanical pass run every turn.",
|
|
256
|
+
)
|
|
257
|
+
prune_tool_results_keep_recent_turns: int = Field(
|
|
258
|
+
default=1,
|
|
259
|
+
validation_alias=AliasChoices(
|
|
260
|
+
"PCLI_PRUNE_TOOL_RESULTS_KEEP_RECENT_TURNS", "prune_tool_results_keep_recent_turns"
|
|
261
|
+
),
|
|
262
|
+
description="Number of most-recent turns whose tool results are left untouched "
|
|
263
|
+
"(verbatim); older ones are archived and replaced with a short placeholder. "
|
|
264
|
+
"Deliberately tighter than auto_compact_keep_recent_turns so pruning actually has "
|
|
265
|
+
"something to do before compaction's threshold is ever reached.",
|
|
266
|
+
)
|
|
267
|
+
context_limit_auto_detect_enabled: bool = Field(
|
|
268
|
+
default=True,
|
|
269
|
+
validation_alias=AliasChoices(
|
|
270
|
+
"PCLI_CONTEXT_LIMIT_AUTO_DETECT_ENABLED", "context_limit_auto_detect_enabled"
|
|
271
|
+
),
|
|
272
|
+
description="Whether pcli tries to query the gateway directly for a model's real "
|
|
273
|
+
"context window (see cost/context_detect.py) when it has no built-in or "
|
|
274
|
+
"user-configured entry for it yet. A handful of extra, short-timeout requests on "
|
|
275
|
+
"startup for an unrecognized model; set to false to skip this and always fall back "
|
|
276
|
+
"to the assumed default (correctable via /context-limit either way).",
|
|
277
|
+
)
|
|
278
|
+
max_response_tokens_enabled: bool = Field(
|
|
279
|
+
default=True,
|
|
280
|
+
validation_alias=AliasChoices(
|
|
281
|
+
"PCLI_MAX_RESPONSE_TOKENS_ENABLED", "max_response_tokens_enabled"
|
|
282
|
+
),
|
|
283
|
+
description="Whether pcli sends a dynamic max_tokens cap with each request (see "
|
|
284
|
+
"compute_max_response_tokens in cost/context.py), leaving max_response_tokens_"
|
|
285
|
+
"safety_margin tokens of headroom below the model's context limit so a single "
|
|
286
|
+
"response can't consume the entire remaining window by itself - auto-compaction "
|
|
287
|
+
"only runs between turns and can't stop a runaway response already in progress.",
|
|
288
|
+
)
|
|
289
|
+
max_response_tokens_safety_margin: int = Field(
|
|
290
|
+
default=512,
|
|
291
|
+
validation_alias=AliasChoices(
|
|
292
|
+
"PCLI_MAX_RESPONSE_TOKENS_SAFETY_MARGIN", "max_response_tokens_safety_margin"
|
|
293
|
+
),
|
|
294
|
+
description="Tokens of headroom reserved below the model's context limit when "
|
|
295
|
+
"computing the dynamic max_tokens cap (context_limit - last_known_usage - this "
|
|
296
|
+
"margin). Ignored when max_response_tokens_enabled is false.",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@classmethod
|
|
300
|
+
def settings_customise_sources(
|
|
301
|
+
cls,
|
|
302
|
+
settings_cls: type[BaseSettings],
|
|
303
|
+
init_settings: PydanticBaseSettingsSource,
|
|
304
|
+
env_settings: PydanticBaseSettingsSource,
|
|
305
|
+
dotenv_settings: PydanticBaseSettingsSource,
|
|
306
|
+
file_secret_settings: PydanticBaseSettingsSource,
|
|
307
|
+
) -> tuple[PydanticBaseSettingsSource, ...]:
|
|
308
|
+
return (
|
|
309
|
+
init_settings,
|
|
310
|
+
env_settings,
|
|
311
|
+
_TomlFileSource(settings_cls),
|
|
312
|
+
dotenv_settings,
|
|
313
|
+
file_secret_settings,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def is_configured(self) -> bool:
|
|
317
|
+
# gateway_api_key is intentionally not required here: local,
|
|
318
|
+
# unauthenticated OpenAI-compatible servers (LM Studio, Ollama, ...)
|
|
319
|
+
# don't need one, and a blank key must not block startup.
|
|
320
|
+
return bool(self.gateway_base_url)
|
|
321
|
+
|
|
322
|
+
def is_local_api(self) -> bool:
|
|
323
|
+
return bool(self.gateway_base_url) and self.gateway_base_url in self.local_api_gateways
|
|
324
|
+
|
|
325
|
+
def is_telegram_configured(self) -> bool:
|
|
326
|
+
return bool(self.telegram_bot_token) and self.telegram_chat_id != 0
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def effective_request_timeout_s(self) -> float:
|
|
330
|
+
"""The timeout GatewayClient actually applies: request_timeout_s,
|
|
331
|
+
floored to _LOCAL_API_MIN_TIMEOUT_S for local-api gateways (an
|
|
332
|
+
explicit request_timeout_s higher than the floor still wins)."""
|
|
333
|
+
if self.is_local_api():
|
|
334
|
+
return max(self.request_timeout_s, _LOCAL_API_MIN_TIMEOUT_S)
|
|
335
|
+
return self.request_timeout_s
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
_settings: Settings | None = None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_settings(**overrides: Any) -> Settings:
|
|
342
|
+
global _settings
|
|
343
|
+
if overrides or _settings is None:
|
|
344
|
+
_settings = Settings(**overrides)
|
|
345
|
+
return _settings
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _toml_scalar(value: Any) -> str:
|
|
349
|
+
if isinstance(value, bool):
|
|
350
|
+
return "true" if value else "false"
|
|
351
|
+
if isinstance(value, (int, float)):
|
|
352
|
+
return str(value)
|
|
353
|
+
if isinstance(value, list):
|
|
354
|
+
return "[" + ", ".join(_toml_scalar(item) for item in value) + "]"
|
|
355
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
356
|
+
return f'"{escaped}"'
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _dump_toml(data: dict[str, Any]) -> str:
|
|
360
|
+
"""Minimal TOML serializer for this app's own config.toml: flat scalar
|
|
361
|
+
keys plus at most one level of [table] nesting — the same shape
|
|
362
|
+
_TomlFileSource reads back. Not a general-purpose TOML writer (the
|
|
363
|
+
stdlib has none); good enough since we only ever write our own keys."""
|
|
364
|
+
lines: list[str] = []
|
|
365
|
+
tables: list[tuple[str, dict[str, Any]]] = []
|
|
366
|
+
for key, value in data.items():
|
|
367
|
+
if isinstance(value, dict):
|
|
368
|
+
tables.append((key, value))
|
|
369
|
+
else:
|
|
370
|
+
lines.append(f"{key} = {_toml_scalar(value)}")
|
|
371
|
+
for name, table in tables:
|
|
372
|
+
lines.append("")
|
|
373
|
+
lines.append(f"[{name}]")
|
|
374
|
+
lines.extend(f"{key} = {_toml_scalar(value)}" for key, value in table.items())
|
|
375
|
+
return "\n".join(lines) + "\n"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def update_config_file(**updates: Any) -> None:
|
|
379
|
+
"""Persists the given key/value pairs into config.toml, preserving any
|
|
380
|
+
other existing keys/tables. None and "" are skipped rather than written,
|
|
381
|
+
so callers can pass through optional CLI flags/selections unconditionally
|
|
382
|
+
without accidentally clearing a saved preference — but unlike a plain
|
|
383
|
+
truthy check, a real `False`/`0` value (e.g. prune_tool_results_enabled)
|
|
384
|
+
is still written, not silently dropped.
|
|
385
|
+
|
|
386
|
+
Used to remember a gateway URL or model picked via a CLI flag or a TUI
|
|
387
|
+
selection (e.g. /models), so a bare `pcli` picks them up next time.
|
|
388
|
+
"""
|
|
389
|
+
path = config_file()
|
|
390
|
+
data: dict[str, Any] = {}
|
|
391
|
+
if path.exists():
|
|
392
|
+
data = dict(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
393
|
+
data.update({key: value for key, value in updates.items() if value is not None and value != ""})
|
|
394
|
+
path.write_text(_dump_toml(data), encoding="utf-8")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def remove_config_keys(*keys: str) -> None:
|
|
398
|
+
"""Deletes the given top-level keys from config.toml, if present —
|
|
399
|
+
the counterpart to update_config_file for a setting that needs to go
|
|
400
|
+
back to "unset" rather than to some concrete value. update_config_file
|
|
401
|
+
itself can't do this: it deliberately skips a None/"" value instead of
|
|
402
|
+
writing it, so callers can pass optional CLI flags through
|
|
403
|
+
unconditionally without accidentally clearing a saved preference — that
|
|
404
|
+
same skip means it has no way to express "remove this key" (namely
|
|
405
|
+
/temperature off, restoring "no temperature sent" rather than pinning
|
|
406
|
+
it to some specific number)."""
|
|
407
|
+
path = config_file()
|
|
408
|
+
if not path.exists():
|
|
409
|
+
return
|
|
410
|
+
data = dict(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
411
|
+
changed = False
|
|
412
|
+
for key in keys:
|
|
413
|
+
if key in data:
|
|
414
|
+
del data[key]
|
|
415
|
+
changed = True
|
|
416
|
+
if changed:
|
|
417
|
+
path.write_text(_dump_toml(data), encoding="utf-8")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def add_local_api_gateway(gateway_url: str) -> None:
|
|
421
|
+
"""Appends `gateway_url` to the persisted `local_api_gateways` list
|
|
422
|
+
(rather than overwriting it, unlike update_config_file) — local-api mode
|
|
423
|
+
is opted into per-gateway, so enabling it for one gateway must not wipe
|
|
424
|
+
out any other gateway already marked local-api."""
|
|
425
|
+
if not gateway_url:
|
|
426
|
+
return
|
|
427
|
+
path = config_file()
|
|
428
|
+
data: dict[str, Any] = {}
|
|
429
|
+
if path.exists():
|
|
430
|
+
data = dict(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
431
|
+
existing = list(data.get("local_api_gateways", []))
|
|
432
|
+
if gateway_url not in existing:
|
|
433
|
+
existing.append(gateway_url)
|
|
434
|
+
data["local_api_gateways"] = existing
|
|
435
|
+
path.write_text(_dump_toml(data), encoding="utf-8")
|
pcli/cost/__init__.py
ADDED
|
File without changes
|