pcode 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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/models.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Local model suggestions for configured providers; no credential or network reads."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import get_args
|
|
9
|
+
|
|
10
|
+
# Every prefix the picker accepts, with a display label. Each provider's SDK
|
|
11
|
+
# extra must be installed (see pyproject.toml) or infer_model raises on switch.
|
|
12
|
+
PROVIDERS = {
|
|
13
|
+
"alibaba": "Alibaba",
|
|
14
|
+
"anthropic": "Anthropic",
|
|
15
|
+
"azure": "Azure OpenAI",
|
|
16
|
+
"bedrock": "Bedrock",
|
|
17
|
+
"bedrock-mantle": "Bedrock Mantle",
|
|
18
|
+
"cerebras": "Cerebras",
|
|
19
|
+
"claude": "Claude Code",
|
|
20
|
+
"crusoe": "Crusoe",
|
|
21
|
+
"deepseek": "DeepSeek",
|
|
22
|
+
"fireworks": "Fireworks",
|
|
23
|
+
"github-copilot": "GitHub Copilot",
|
|
24
|
+
"google": "Google",
|
|
25
|
+
"google-cloud": "Google Cloud",
|
|
26
|
+
"groq": "Groq",
|
|
27
|
+
"heroku": "Heroku",
|
|
28
|
+
"meridian": "Meridian",
|
|
29
|
+
"moonshotai": "Moonshot",
|
|
30
|
+
"nebius": "Nebius",
|
|
31
|
+
"ollama": "Ollama",
|
|
32
|
+
"openai": "OpenAI",
|
|
33
|
+
"openai-chat": "OpenAI (chat)",
|
|
34
|
+
"openai-codex": "OpenAI Codex",
|
|
35
|
+
"openai-responses": "OpenAI (responses)",
|
|
36
|
+
"openrouter": "OpenRouter",
|
|
37
|
+
"ovhcloud": "OVHcloud",
|
|
38
|
+
"sambanova": "SambaNova",
|
|
39
|
+
"snowflake": "Snowflake",
|
|
40
|
+
"together": "Together",
|
|
41
|
+
"vercel": "Vercel",
|
|
42
|
+
"vllm": "vLLM",
|
|
43
|
+
"xai": "xAI",
|
|
44
|
+
"zai": "Z.ai",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Providers activated by environment. Each inner tuple is one requirement
|
|
48
|
+
# satisfied by any listed variable; every requirement must hold. Only names
|
|
49
|
+
# are inspected, never values. Providers needing an endpoint as well as a key
|
|
50
|
+
# (azure, github-copilot) list both so a lone key is not a false positive.
|
|
51
|
+
ENV_PROVIDERS: dict[str, tuple[tuple[str, ...], ...]] = {
|
|
52
|
+
"alibaba": (("ALIBABA_API_KEY", "DASHSCOPE_API_KEY"),),
|
|
53
|
+
"azure": (("AZURE_OPENAI_API_KEY",), ("AZURE_OPENAI_ENDPOINT",)),
|
|
54
|
+
"bedrock": (("AWS_BEARER_TOKEN_BEDROCK", "AWS_ACCESS_KEY_ID", "AWS_PROFILE"),),
|
|
55
|
+
"bedrock-mantle": (("AWS_BEARER_TOKEN_BEDROCK",),),
|
|
56
|
+
"cerebras": (("CEREBRAS_API_KEY",),),
|
|
57
|
+
"crusoe": (("CRUSOE_API_KEY",),),
|
|
58
|
+
"deepseek": (("DEEPSEEK_API_KEY",),),
|
|
59
|
+
"fireworks": (("FIREWORKS_API_KEY",),),
|
|
60
|
+
"github-copilot": (
|
|
61
|
+
("GITHUB_COPILOT_API_KEY", "GITHUB_COPILOT_API_TOKEN", "COPILOT_GITHUB_TOKEN"),
|
|
62
|
+
("GITHUB_COPILOT_BASE_URL", "GITHUB_COPILOT_API_BASE", "COPILOT_API_URL"),
|
|
63
|
+
),
|
|
64
|
+
"google": (("GOOGLE_API_KEY", "GEMINI_API_KEY"),),
|
|
65
|
+
"google-cloud": (("GOOGLE_CLOUD_PROJECT", "GOOGLE_APPLICATION_CREDENTIALS"),),
|
|
66
|
+
"groq": (("GROQ_API_KEY",),),
|
|
67
|
+
"heroku": (("HEROKU_INFERENCE_KEY",),),
|
|
68
|
+
"moonshotai": (("MOONSHOTAI_API_KEY",),),
|
|
69
|
+
"nebius": (("NEBIUS_API_KEY",),),
|
|
70
|
+
"ollama": (("OLLAMA_BASE_URL",),),
|
|
71
|
+
"openai": (("OPENAI_API_KEY",),),
|
|
72
|
+
"openrouter": (("OPENROUTER_API_KEY",),),
|
|
73
|
+
"ovhcloud": (("OVHCLOUD_API_KEY",),),
|
|
74
|
+
"sambanova": (("SAMBANOVA_API_KEY",),),
|
|
75
|
+
"snowflake": (("SNOWFLAKE_TOKEN",), ("SNOWFLAKE_ACCOUNT",)),
|
|
76
|
+
"together": (("TOGETHER_API_KEY",),),
|
|
77
|
+
"vercel": (("VERCEL_AI_GATEWAY_API_KEY", "VERCEL_OIDC_TOKEN"),),
|
|
78
|
+
"vllm": (("VLLM_BASE_URL",),),
|
|
79
|
+
"xai": (("XAI_API_KEY",),),
|
|
80
|
+
"zai": (("ZAI_API_KEY",),),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# The subscription routes that predate `claude:` models (the Claude Agent SDK):
|
|
84
|
+
# `meridian:` models and pcode's own Anthropic browser sign-in. Off while
|
|
85
|
+
# `claude:` is tried as the way forward; True restores both unchanged.
|
|
86
|
+
# `anthropic:` models on ANTHROPIC_API_KEY work either way.
|
|
87
|
+
LEGACY_ANTHROPIC_AUTH = False
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def login_sources() -> tuple[str, ...]:
|
|
91
|
+
"""What `/login` accepts; the first is what a bare `/login` signs in to."""
|
|
92
|
+
if LEGACY_ANTHROPIC_AUTH:
|
|
93
|
+
return ("anthropic", "openai-codex", "claude", "meridian")
|
|
94
|
+
return ("claude", "openai-codex")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def anthropic_credential_hint() -> str:
|
|
98
|
+
"""How to give an `anthropic:` model that has no credential one."""
|
|
99
|
+
if LEGACY_ANTHROPIC_AUTH:
|
|
100
|
+
return "Run /login, or set ANTHROPIC_API_KEY."
|
|
101
|
+
return "Set ANTHROPIC_API_KEY, or switch to a claude: model."
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Catalog prefix -> KnownModelName prefixes whose IDs it accepts. Codex has no
|
|
105
|
+
# separate SDK catalog, so expose every OpenAI model ID and let the provider
|
|
106
|
+
# enforce account access; Meridian and Claude Code front Anthropic models.
|
|
107
|
+
CATALOG_SOURCES = {"openai-codex": "openai", "meridian": "anthropic", "claude": "anthropic"}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def claude_code_configured() -> bool:
|
|
111
|
+
"""Whether this machine has used Claude Code, whose login `claude:` models share.
|
|
112
|
+
|
|
113
|
+
The Agent SDK bundles the CLI, so an install is not required; its config
|
|
114
|
+
is the signal, checked for existence only.
|
|
115
|
+
"""
|
|
116
|
+
config = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
117
|
+
try:
|
|
118
|
+
return bool(
|
|
119
|
+
(Path(config).expanduser() if config else Path.home() / ".claude").is_dir()
|
|
120
|
+
or (Path.home() / ".claude.json").is_file()
|
|
121
|
+
or shutil.which("claude")
|
|
122
|
+
)
|
|
123
|
+
except OSError:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def claude_sdk_installed() -> bool:
|
|
128
|
+
"""Whether pcode was installed with its `claude` extra (claude-agent-sdk)."""
|
|
129
|
+
return importlib.util.find_spec("claude_agent_sdk") is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _configured(requirements: tuple[tuple[str, ...], ...]) -> bool:
|
|
133
|
+
return all(any(os.environ.get(name, "").strip() for name in group) for group in requirements)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def active_providers(current: str | None) -> set[str]:
|
|
137
|
+
active = set()
|
|
138
|
+
if current and current.partition(":")[0] in PROVIDERS:
|
|
139
|
+
active.add(current.partition(":")[0])
|
|
140
|
+
if os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip() or shutil.which("meridian"):
|
|
141
|
+
active.add("meridian")
|
|
142
|
+
if claude_sdk_installed() and claude_code_configured():
|
|
143
|
+
active.add("claude")
|
|
144
|
+
active.update(name for name, needs in ENV_PROVIDERS.items() if _configured(needs))
|
|
145
|
+
from pcode.anthropic_oauth import anthropic_auth_source
|
|
146
|
+
|
|
147
|
+
# Resolution checks for a stored login file, never its contents.
|
|
148
|
+
source = anthropic_auth_source()
|
|
149
|
+
if source == "oauth" or (
|
|
150
|
+
source == "api-key" and os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
151
|
+
):
|
|
152
|
+
active.add("anthropic")
|
|
153
|
+
from pcode.codex_login import have_credentials
|
|
154
|
+
|
|
155
|
+
# Only check existence, never read a credential to populate the picker.
|
|
156
|
+
codex_home = os.environ.get("CODEX_HOME", "").strip()
|
|
157
|
+
directory = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex"
|
|
158
|
+
try:
|
|
159
|
+
if have_credentials() or (directory / "auth.json").is_file():
|
|
160
|
+
active.add("openai-codex")
|
|
161
|
+
except OSError:
|
|
162
|
+
pass
|
|
163
|
+
from pcode.preferences import load_preferences
|
|
164
|
+
|
|
165
|
+
allowed = load_preferences().get("model_providers", "")
|
|
166
|
+
if allowed:
|
|
167
|
+
active.intersection_update(name.strip() for name in allowed.split(","))
|
|
168
|
+
if not LEGACY_ANTHROPIC_AUTH:
|
|
169
|
+
# Even when the current model is a saved `meridian:` one.
|
|
170
|
+
active.discard("meridian")
|
|
171
|
+
return active
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def model_catalog(providers: set[str], current: str | None = None) -> list[str]:
|
|
175
|
+
# Imported here so the terminal can read this module's settings without
|
|
176
|
+
# loading the agent stack. KnownModelName is a TypeAliasType on supported
|
|
177
|
+
# Pydantic versions.
|
|
178
|
+
from pydantic_ai.models import KnownModelName
|
|
179
|
+
|
|
180
|
+
known = get_args(getattr(KnownModelName, "__value__", KnownModelName))
|
|
181
|
+
by_source: dict[str, set[str]] = {}
|
|
182
|
+
for name in known:
|
|
183
|
+
if isinstance(name, str):
|
|
184
|
+
source, _, model = name.partition(":")
|
|
185
|
+
by_source.setdefault(source, set()).add(model)
|
|
186
|
+
models = set()
|
|
187
|
+
for provider in providers:
|
|
188
|
+
source = CATALOG_SOURCES.get(provider, provider)
|
|
189
|
+
# Providers without a catalog (openrouter, ollama, vllm...) take any
|
|
190
|
+
# model ID typed into the picker instead.
|
|
191
|
+
models.update(f"{provider}:{model}" for model in by_source.get(source, ()))
|
|
192
|
+
if current and current.partition(":")[0] in providers:
|
|
193
|
+
models.add(current)
|
|
194
|
+
return sorted(models, key=model_sort_key)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def model_sort_key(name: str):
|
|
198
|
+
"""Group providers/families alphabetically, then numeric versions newest first.
|
|
199
|
+
|
|
200
|
+
Compare numeric components as integers so 4.10 precedes 4.9. Undated aliases
|
|
201
|
+
sort before dated snapshots of the same version. Do not pin the current model
|
|
202
|
+
above newer suggestions; the picker marks it separately.
|
|
203
|
+
"""
|
|
204
|
+
provider, _, model = name.partition(":")
|
|
205
|
+
parts = tuple(
|
|
206
|
+
(1, -int(part)) if part.isdigit() else (0, part.casefold())
|
|
207
|
+
for part in re.split(r"(\d+)", model)
|
|
208
|
+
)
|
|
209
|
+
return provider, parts
|
pcode/native_results.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Repair a history whose native-tool results the current login cannot read.
|
|
2
|
+
|
|
3
|
+
Anthropic returns each server-side `web_search` hit as an opaque
|
|
4
|
+
`encrypted_content` blob that only the organization it was issued to can
|
|
5
|
+
decrypt. Replay is therefore credential-bound: sign in as a different account
|
|
6
|
+
-- a second `/login`, another `PCODE_CONFIG_DIR`, an API key in place of a
|
|
7
|
+
subscription -- and the request comes back as
|
|
8
|
+
`400 Invalid encrypted_content in search_result block`. The blobs live in the
|
|
9
|
+
message history, so without a repair every later request in that session fails
|
|
10
|
+
the same way and the conversation is stranded.
|
|
11
|
+
|
|
12
|
+
Dropping them is the whole repair. The page text inside a blob is unrecoverable,
|
|
13
|
+
but the titles and URLs beside it are plaintext, and the model's own reading of
|
|
14
|
+
the search sits in the same response. Thinking signatures cross accounts
|
|
15
|
+
untouched (verified against the live endpoint), so they are left alone.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
from pydantic_ai.exceptions import ModelHTTPError
|
|
21
|
+
from pydantic_ai.messages import (
|
|
22
|
+
ModelMessage,
|
|
23
|
+
ModelResponse,
|
|
24
|
+
ModelResponsePart,
|
|
25
|
+
NativeToolCallPart,
|
|
26
|
+
NativeToolReturnPart,
|
|
27
|
+
TextPart,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Enough to place the search without turning one dead result set into a wall.
|
|
31
|
+
MAX_SOURCES = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def unreadable_native_results(error: BaseException) -> bool:
|
|
35
|
+
"""Whether the provider rejected replayed results it cannot decrypt.
|
|
36
|
+
|
|
37
|
+
Deliberately narrow: a 400 naming `encrypted_content` is the provider
|
|
38
|
+
saying this history is unsendable as it stands, which no amount of retrying
|
|
39
|
+
changes. Every other 400 is a request pcode built wrong.
|
|
40
|
+
"""
|
|
41
|
+
return (
|
|
42
|
+
isinstance(error, ModelHTTPError)
|
|
43
|
+
and error.status_code == 400
|
|
44
|
+
and "encrypted_content" in str(error.body)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def drop_unreadable_results(messages: Iterable[ModelMessage]) -> int:
|
|
49
|
+
"""Replace credential-bound native results with a plain trace, in place.
|
|
50
|
+
|
|
51
|
+
Returns how many results were dropped. The part lists are rewritten through
|
|
52
|
+
a slice assignment because the same `ModelResponse` objects are shared by
|
|
53
|
+
the turn's history, the request already checkpointed, and the next
|
|
54
|
+
snapshot: replacing the messages would repair only one of them.
|
|
55
|
+
"""
|
|
56
|
+
dropped = 0
|
|
57
|
+
for message in messages:
|
|
58
|
+
if not isinstance(message, ModelResponse):
|
|
59
|
+
continue
|
|
60
|
+
stale = {
|
|
61
|
+
part.tool_call_id
|
|
62
|
+
for part in message.parts
|
|
63
|
+
if isinstance(part, NativeToolReturnPart) and _encrypted(part.content)
|
|
64
|
+
}
|
|
65
|
+
if not stale:
|
|
66
|
+
continue
|
|
67
|
+
queries = {
|
|
68
|
+
part.tool_call_id: _query(part)
|
|
69
|
+
for part in message.parts
|
|
70
|
+
if isinstance(part, NativeToolCallPart) and part.tool_call_id in stale
|
|
71
|
+
}
|
|
72
|
+
kept: list[ModelResponsePart] = []
|
|
73
|
+
for part in message.parts:
|
|
74
|
+
if isinstance(part, NativeToolCallPart) and part.tool_call_id in stale:
|
|
75
|
+
# The call cannot outlive its result: an unpaired server tool
|
|
76
|
+
# use is rejected in its own right.
|
|
77
|
+
continue
|
|
78
|
+
if isinstance(part, NativeToolReturnPart) and part.tool_call_id in stale:
|
|
79
|
+
kept.append(TextPart(content=_note(part, queries.get(part.tool_call_id))))
|
|
80
|
+
dropped += 1
|
|
81
|
+
continue
|
|
82
|
+
kept.append(part)
|
|
83
|
+
message.parts[:] = kept
|
|
84
|
+
return dropped
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _encrypted(content: object) -> bool:
|
|
88
|
+
"""Whether anything in a result carries provider-encrypted text."""
|
|
89
|
+
if isinstance(content, dict):
|
|
90
|
+
return "encrypted_content" in content or any(_encrypted(v) for v in content.values())
|
|
91
|
+
if isinstance(content, list):
|
|
92
|
+
return any(_encrypted(item) for item in content)
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _query(part: NativeToolCallPart) -> str | None:
|
|
97
|
+
try:
|
|
98
|
+
args = part.args_as_dict()
|
|
99
|
+
except Exception:
|
|
100
|
+
return None
|
|
101
|
+
query = args.get("query")
|
|
102
|
+
return query if isinstance(query, str) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _note(part: NativeToolReturnPart, query: str | None) -> str:
|
|
106
|
+
items = part.content if isinstance(part.content, list) else [part.content]
|
|
107
|
+
sources = [
|
|
108
|
+
f"{item.get('title') or item['url']}: {item['url']}"
|
|
109
|
+
for item in items
|
|
110
|
+
if isinstance(item, dict) and item.get("url")
|
|
111
|
+
]
|
|
112
|
+
searched = f" for {query!r}" if query else ""
|
|
113
|
+
listed = "".join(f"\n- {source}" for source in sources[:MAX_SOURCES])
|
|
114
|
+
found = f" Pages found:{listed}" if listed else ""
|
|
115
|
+
return (
|
|
116
|
+
f"[pcode: the {part.tool_name} results{searched} were encrypted for a different "
|
|
117
|
+
f"account and cannot be replayed, so they were dropped from this conversation. "
|
|
118
|
+
f"Search again if you still need them.{found}]"
|
|
119
|
+
)
|
pcode/oauth_pages.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Browser-facing HTML for the loopback OAuth callback.
|
|
2
|
+
|
|
3
|
+
The loopback server answers exactly one request and then shuts down, so the page
|
|
4
|
+
has to be a single self-contained document: no stylesheet fetch, no fonts, no
|
|
5
|
+
scripts that outlive the response.
|
|
6
|
+
|
|
7
|
+
Adapted from the MIT-licensed templates in kriasoft/oauth-callback
|
|
8
|
+
(https://github.com/kriasoft/oauth-callback), Copyright (c) 2025-present
|
|
9
|
+
Konstantin Tarkus, Kriasoft.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from html import escape
|
|
15
|
+
|
|
16
|
+
_STYLE = """
|
|
17
|
+
:root{color-scheme:light dark}
|
|
18
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
|
19
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Ubuntu,"Helvetica Neue",sans-serif;
|
|
20
|
+
background:linear-gradient(180deg,#fafafa 0%,#f0f0f0 100%);color:#1a1a1a}
|
|
21
|
+
.card{background:#fff;border-radius:16px;padding:48px 40px 40px;max-width:420px;width:90%;
|
|
22
|
+
text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04),0 6px 16px rgba(0,0,0,.08);
|
|
23
|
+
animation:fadeUp .4s ease-out}
|
|
24
|
+
h1{font-size:24px;font-weight:600;margin:0 0 8px;letter-spacing:-.02em;line-height:1.2}
|
|
25
|
+
p{font-size:15px;color:#666;margin:0;line-height:1.5}
|
|
26
|
+
svg{width:56px;height:56px;margin:0 auto 24px;display:block;fill:none;stroke-width:3;
|
|
27
|
+
stroke-linecap:round;stroke-linejoin:round}
|
|
28
|
+
circle{stroke-width:2;stroke-dasharray:157;stroke-dashoffset:157;
|
|
29
|
+
animation:stroke .6s cubic-bezier(.65,0,.45,1) forwards}
|
|
30
|
+
path{stroke-dasharray:60;stroke-dashoffset:60;
|
|
31
|
+
animation:stroke .3s cubic-bezier(.65,0,.45,1) .4s forwards}
|
|
32
|
+
.ok svg{stroke:#10b981}
|
|
33
|
+
.bad svg{stroke:#ef4444}
|
|
34
|
+
@keyframes stroke{to{stroke-dashoffset:0}}
|
|
35
|
+
@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
|
36
|
+
@media (prefers-color-scheme:dark){
|
|
37
|
+
body{background:linear-gradient(180deg,#0a0a0a 0%,#1a1a1a 100%);color:#fafafa}
|
|
38
|
+
.card{background:#1f1f1f;box-shadow:0 1px 3px rgba(0,0,0,.2),0 8px 24px rgba(0,0,0,.4)}
|
|
39
|
+
p{color:#999}}
|
|
40
|
+
@media (prefers-reduced-motion:reduce){*{animation:none!important}
|
|
41
|
+
circle,path{stroke-dashoffset:0}}
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
_CHECK = '<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>'
|
|
45
|
+
_CROSS = '<path d="M17 17l18 18M35 17L17 35"/>'
|
|
46
|
+
|
|
47
|
+
_PAGE = (
|
|
48
|
+
"<!doctype html><html lang=en><head><meta charset=utf-8>"
|
|
49
|
+
'<meta name=viewport content="width=device-width, initial-scale=1">'
|
|
50
|
+
"<title>{title} · pcode</title><style>{style}</style></head>"
|
|
51
|
+
'<body class="{kind}"><main class=card>'
|
|
52
|
+
'<svg viewBox="0 0 52 52" role=img aria-hidden=true>'
|
|
53
|
+
"<circle cx=26 cy=26 r=25/>{icon}</svg>"
|
|
54
|
+
"<h1>{title}</h1><p>{message}</p></main></body></html>"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def callback_page(title: str, message: str, *, ok: bool) -> str:
|
|
59
|
+
"""Render the single page the browser sees after the OAuth redirect."""
|
|
60
|
+
return _PAGE.format(
|
|
61
|
+
title=escape(title),
|
|
62
|
+
message=escape(message),
|
|
63
|
+
style=_STYLE,
|
|
64
|
+
kind="ok" if ok else "bad",
|
|
65
|
+
icon=_CHECK if ok else _CROSS,
|
|
66
|
+
)
|
pcode/output_limits.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Model-aware output defaults, separate from reasoning effort and visibility."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
6
|
+
from pydantic_ai.models.anthropic import AnthropicModel
|
|
7
|
+
|
|
8
|
+
from pcode.model_metadata import catalog, refresh_context
|
|
9
|
+
|
|
10
|
+
# Anthropic requires max_tokens. Pydantic AI before 2.52 sent 4096, which thinking
|
|
11
|
+
# or one file-writing tool call can exhaust; 2.52 asks for the model's maximum
|
|
12
|
+
# output when its own profile knows it. pcode resolves the serving route's limit
|
|
13
|
+
# (Meridian ids, Claude Code aliases) from its catalog instead, and unknown or
|
|
14
|
+
# offline routes cannot safely borrow another endpoint's model limits.
|
|
15
|
+
FALLBACK_OUTPUT_TOKENS = 16_384
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelOutputLimits(AbstractCapability):
|
|
19
|
+
"""Resolve each request's default, including model switches and subagents.
|
|
20
|
+
|
|
21
|
+
Use the serving model's advertised maximum, not a guessed family-name table.
|
|
22
|
+
A maximum is a ceiling, not a requested response length or thinking budget.
|
|
23
|
+
Other adapters retain their provider defaults; explicit settings always win.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
async def before_model_request(self, ctx, request_context):
|
|
27
|
+
if not isinstance(request_context.model, AnthropicModel):
|
|
28
|
+
return request_context
|
|
29
|
+
settings = request_context.model_settings or {}
|
|
30
|
+
if settings.get("max_tokens") is not None:
|
|
31
|
+
return request_context
|
|
32
|
+
await refresh_context(request_context.model)
|
|
33
|
+
limits = catalog.limits(request_context.model)
|
|
34
|
+
maximum = limits.output if limits else None
|
|
35
|
+
if maximum is None and request_context.model.system == "claude":
|
|
36
|
+
# The CLI's own default beats a guess below it (a `claude:opus` alias).
|
|
37
|
+
return request_context
|
|
38
|
+
return replace(
|
|
39
|
+
request_context,
|
|
40
|
+
model_settings={**settings, "max_tokens": maximum or FALLBACK_OUTPUT_TOKENS},
|
|
41
|
+
)
|
pcode/paste.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Collapse large pastes in the editor to a short preview plus a size marker.
|
|
2
|
+
|
|
3
|
+
A multi-kilobyte paste turns the prompt into a wall of text that hides the
|
|
4
|
+
question being typed around it. Pastes longer than ``PASTE_THRESHOLD`` are
|
|
5
|
+
inserted as ``<preview>…[+N chars]`` instead; the full text is kept aside and
|
|
6
|
+
substituted back into the buffer when the prompt is submitted, so the model
|
|
7
|
+
still receives everything. A placeholder the user has edited no longer matches
|
|
8
|
+
and is sent verbatim, which is deliberate: the marker is visible and the
|
|
9
|
+
transcript shows exactly what went out.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
PASTE_THRESHOLD = 100
|
|
17
|
+
PREVIEW_CHARS = 60
|
|
18
|
+
MARKER_PATTERN = re.compile(r"…\[\+[\d,]+ chars\]")
|
|
19
|
+
|
|
20
|
+
__all__ = ["MARKER_PATTERN", "PASTE_THRESHOLD", "PastedText"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PastedText:
|
|
24
|
+
"""Placeholders currently standing in for pasted text in the editor."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._full: dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
def collapse(self, text: str) -> str:
|
|
30
|
+
"""Return what to insert for ``text``: itself, or a placeholder."""
|
|
31
|
+
if len(text) <= PASTE_THRESHOLD:
|
|
32
|
+
return text
|
|
33
|
+
preview = text[:PREVIEW_CHARS].replace("\n", "⏎")
|
|
34
|
+
placeholder = f"{preview}…[+{len(text) - PREVIEW_CHARS:,} chars]"
|
|
35
|
+
self._full[placeholder] = text
|
|
36
|
+
return placeholder
|
|
37
|
+
|
|
38
|
+
def expand(self, text: str) -> str:
|
|
39
|
+
"""Substitute every intact placeholder back with its full paste."""
|
|
40
|
+
for placeholder, full in self._full.items():
|
|
41
|
+
text = text.replace(placeholder, full)
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
def clear(self) -> None:
|
|
45
|
+
self._full.clear()
|
pcode/path_completion.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Complete a filesystem path typed as a slash command's argument.
|
|
2
|
+
|
|
3
|
+
Only the directory being typed is listed, never walked, and a huge directory
|
|
4
|
+
is read only up to `SCAN_LIMIT` entries, so completion stays instant anywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from itertools import islice
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from prompt_toolkit.completion import Completion
|
|
13
|
+
|
|
14
|
+
SCAN_LIMIT = 2000
|
|
15
|
+
SHOWN = 100
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def path_fragment(argument: str) -> str:
|
|
19
|
+
"""The path being typed: the argument after any leading `--option` words."""
|
|
20
|
+
words = argument.lstrip()
|
|
21
|
+
while words.startswith("--"):
|
|
22
|
+
_option, space, words = words.partition(" ")
|
|
23
|
+
if not space: # Still typing the option itself.
|
|
24
|
+
return ""
|
|
25
|
+
words = words.lstrip()
|
|
26
|
+
return words
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def complete_paths(argument: str, workspace: Path) -> Iterator[Completion]:
|
|
30
|
+
"""Entries of the typed directory starting with the typed name, directories first.
|
|
31
|
+
|
|
32
|
+
Relative paths resolve against `workspace`. Nothing completes until
|
|
33
|
+
something is typed, and a bare `/` does not list the filesystem root.
|
|
34
|
+
"""
|
|
35
|
+
fragment = path_fragment(argument)
|
|
36
|
+
if not fragment or fragment == "/":
|
|
37
|
+
return
|
|
38
|
+
if fragment == "~":
|
|
39
|
+
yield Completion("~/", start_position=-1)
|
|
40
|
+
return
|
|
41
|
+
head, _, name = fragment.rpartition("/")
|
|
42
|
+
if "/" in fragment:
|
|
43
|
+
directory = Path(os.path.expanduser(head or "/"))
|
|
44
|
+
else:
|
|
45
|
+
directory = Path()
|
|
46
|
+
if not directory.is_absolute():
|
|
47
|
+
directory = workspace / directory
|
|
48
|
+
needle = name.casefold()
|
|
49
|
+
found: list[tuple[bool, str]] = []
|
|
50
|
+
try:
|
|
51
|
+
with os.scandir(directory) as entries:
|
|
52
|
+
for entry in islice(entries, SCAN_LIMIT):
|
|
53
|
+
if entry.name.startswith(".") and not name.startswith("."):
|
|
54
|
+
continue
|
|
55
|
+
if entry.name.casefold().startswith(needle):
|
|
56
|
+
try:
|
|
57
|
+
is_dir = entry.is_dir()
|
|
58
|
+
except OSError:
|
|
59
|
+
is_dir = False
|
|
60
|
+
found.append((not is_dir, entry.name))
|
|
61
|
+
except OSError:
|
|
62
|
+
return
|
|
63
|
+
found.sort(key=lambda item: (item[0], item[1].casefold()))
|
|
64
|
+
for is_file, entry in found[:SHOWN]:
|
|
65
|
+
suffix = "" if is_file else "/"
|
|
66
|
+
yield Completion(entry + suffix, start_position=-len(name), display=entry + suffix)
|
pcode/plan_preview.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Ephemeral task-pane projections of streamed planning calls.
|
|
2
|
+
|
|
3
|
+
These never mutate Harness's store. Complete strings/fields can be displayed while
|
|
4
|
+
JSON is still arriving; validation, IDs, and persistence belong to the real tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
from pydantic_ai.messages import (
|
|
11
|
+
FunctionToolCallEvent,
|
|
12
|
+
FunctionToolResultEvent,
|
|
13
|
+
PartDeltaEvent,
|
|
14
|
+
PartEndEvent,
|
|
15
|
+
PartStartEvent,
|
|
16
|
+
ToolCallPart,
|
|
17
|
+
ToolCallPartDelta,
|
|
18
|
+
)
|
|
19
|
+
from pydantic_core import from_json
|
|
20
|
+
|
|
21
|
+
from pcode.planning import SizedPlanItem
|
|
22
|
+
from pcode.runtime import PlanPreview
|
|
23
|
+
|
|
24
|
+
PLANNING_MUTATIONS = {
|
|
25
|
+
"write_plan",
|
|
26
|
+
"add_task",
|
|
27
|
+
"update_task_status",
|
|
28
|
+
"update_task_statuses",
|
|
29
|
+
"remove_task",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def project_plan(items: list[dict], part: ToolCallPart) -> list[dict]:
|
|
34
|
+
"""Best-effort display only; malformed/incomplete fields leave the plan alone."""
|
|
35
|
+
try:
|
|
36
|
+
args = from_json(part.args, allow_partial=True) if isinstance(part.args, str) else part.args
|
|
37
|
+
except ValueError:
|
|
38
|
+
return items
|
|
39
|
+
if not isinstance(args, dict):
|
|
40
|
+
return items
|
|
41
|
+
|
|
42
|
+
def row(value, index):
|
|
43
|
+
if not isinstance(value, dict):
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return SizedPlanItem.model_validate(
|
|
47
|
+
{"id": f"preview:{part.tool_call_id}:{index}", **value}
|
|
48
|
+
).model_dump(mode="json")
|
|
49
|
+
except ValidationError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if part.tool_name == "write_plan":
|
|
53
|
+
values = args.get("items")
|
|
54
|
+
if isinstance(values, list):
|
|
55
|
+
rows = [item for i, value in enumerate(values) if (item := row(value, i)) is not None]
|
|
56
|
+
if rows:
|
|
57
|
+
return rows
|
|
58
|
+
# An unfinished `items: [` is not an instruction to clear the pane.
|
|
59
|
+
if values == []:
|
|
60
|
+
try:
|
|
61
|
+
part.args_as_dict(raise_if_invalid=True)
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
return items
|
|
64
|
+
return []
|
|
65
|
+
elif part.tool_name == "add_task":
|
|
66
|
+
if (item := row(args, 0)) is not None:
|
|
67
|
+
return [*items, item]
|
|
68
|
+
elif part.tool_name in {"update_task_status", "update_task_statuses"}:
|
|
69
|
+
updates = args.get("updates") if part.tool_name == "update_task_statuses" else [args]
|
|
70
|
+
if isinstance(updates, list):
|
|
71
|
+
items = deepcopy(items)
|
|
72
|
+
for update in updates:
|
|
73
|
+
if not isinstance(update, dict):
|
|
74
|
+
continue
|
|
75
|
+
for i, item in enumerate(items):
|
|
76
|
+
if item["id"] == update.get("task_id"):
|
|
77
|
+
changed = row({**item, "status": update.get("status")}, i)
|
|
78
|
+
if changed is not None:
|
|
79
|
+
items[i] = changed
|
|
80
|
+
elif part.tool_name == "remove_task":
|
|
81
|
+
return [item for item in items if item["id"] != args.get("task_id")]
|
|
82
|
+
return items
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StreamingPlanPreview:
|
|
86
|
+
def __init__(self):
|
|
87
|
+
self.parts: dict[int, ToolCallPart] = {}
|
|
88
|
+
self.items: list[dict] | None = None
|
|
89
|
+
self.executing = False
|
|
90
|
+
self.confirmed: list[dict] = []
|
|
91
|
+
|
|
92
|
+
def update(self, event, confirmed: list[dict]) -> PlanPreview | None:
|
|
93
|
+
store_changed = confirmed != self.confirmed
|
|
94
|
+
self.confirmed = confirmed
|
|
95
|
+
if isinstance(event, PartStartEvent):
|
|
96
|
+
if self.executing or event.index == 0:
|
|
97
|
+
self.parts.clear()
|
|
98
|
+
self.executing = False
|
|
99
|
+
if isinstance(event.part, ToolCallPart):
|
|
100
|
+
self.parts[event.index] = event.part
|
|
101
|
+
elif isinstance(event, PartDeltaEvent):
|
|
102
|
+
if isinstance(event.delta, ToolCallPartDelta) and event.index in self.parts:
|
|
103
|
+
self.parts[event.index] = event.delta.apply(self.parts[event.index])
|
|
104
|
+
elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
|
|
105
|
+
self.parts[event.index] = event.part
|
|
106
|
+
elif isinstance(event, FunctionToolCallEvent):
|
|
107
|
+
self.executing = True
|
|
108
|
+
elif isinstance(event, FunctionToolResultEvent):
|
|
109
|
+
if store_changed:
|
|
110
|
+
# The shared store can already contain sibling mutations whose
|
|
111
|
+
# result events have not arrived. Do not replay their arguments
|
|
112
|
+
# over that snapshot (e.g. duplicating concurrent add_task calls).
|
|
113
|
+
self.parts.clear()
|
|
114
|
+
else:
|
|
115
|
+
self.parts = {
|
|
116
|
+
index: part
|
|
117
|
+
for index, part in self.parts.items()
|
|
118
|
+
if part.tool_call_id != event.tool_call_id
|
|
119
|
+
}
|
|
120
|
+
else:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
items = confirmed
|
|
124
|
+
for part in self.parts.values():
|
|
125
|
+
if part.tool_name in PLANNING_MUTATIONS:
|
|
126
|
+
items = project_plan(items, part)
|
|
127
|
+
preview = items if items != confirmed else None
|
|
128
|
+
if preview != self.items:
|
|
129
|
+
self.items = preview
|
|
130
|
+
return PlanPreview(preview)
|
|
131
|
+
return None
|