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.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
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
@@ -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