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/meridian.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Anthropic-compatible Meridian transport with client-owned tool execution."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
|
|
8
|
+
import httpx2
|
|
9
|
+
from anthropic import AsyncAnthropic
|
|
10
|
+
from pydantic_ai import RunContext
|
|
11
|
+
from pydantic_ai.capabilities import AbstractCapability
|
|
12
|
+
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
13
|
+
from pydantic_ai.models import ModelRequestContext
|
|
14
|
+
from pydantic_ai.models.anthropic import AnthropicModel
|
|
15
|
+
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:3456"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def meridian_base_url() -> str:
|
|
21
|
+
value = os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip() or DEFAULT_BASE_URL
|
|
22
|
+
try:
|
|
23
|
+
url = httpx2.URL(value)
|
|
24
|
+
if url.scheme not in {"http", "https"} or not url.host or url.userinfo:
|
|
25
|
+
raise ValueError
|
|
26
|
+
if url.query or url.fragment:
|
|
27
|
+
raise ValueError
|
|
28
|
+
except (ValueError, httpx2.InvalidURL):
|
|
29
|
+
raise ValueError(
|
|
30
|
+
"PCODE_MERIDIAN_BASE_URL must be an HTTP(S) URL without credentials, query, or fragment"
|
|
31
|
+
) from None
|
|
32
|
+
return value.rstrip("/")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _failed_url(error: BaseException) -> str | None:
|
|
36
|
+
"""URL of the request an SDK error came from, found along its cause chain."""
|
|
37
|
+
seen = set()
|
|
38
|
+
while error is not None and id(error) not in seen and len(seen) < 16:
|
|
39
|
+
seen.add(id(error))
|
|
40
|
+
try:
|
|
41
|
+
# httpx raises RuntimeError, not AttributeError, for an unset request.
|
|
42
|
+
url = getattr(getattr(error, "request", None), "url", None)
|
|
43
|
+
except Exception:
|
|
44
|
+
url = None
|
|
45
|
+
if url is not None:
|
|
46
|
+
return str(url)
|
|
47
|
+
error = error.__cause__ or error.__context__
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def failure_hint(error: BaseException) -> str | None:
|
|
52
|
+
"""What to do about a failed Meridian request, or None for any other provider."""
|
|
53
|
+
from pcode.diagnostics import error_details, transport_types
|
|
54
|
+
from pcode.meridian_process import managed_base_url
|
|
55
|
+
|
|
56
|
+
url = _failed_url(error)
|
|
57
|
+
if url is None:
|
|
58
|
+
return None
|
|
59
|
+
managed = managed_base_url()
|
|
60
|
+
try:
|
|
61
|
+
external = meridian_base_url()
|
|
62
|
+
except ValueError:
|
|
63
|
+
external = None
|
|
64
|
+
base = next((b for b in (managed, external) if b and url.startswith(b + "/")), None)
|
|
65
|
+
if base is None:
|
|
66
|
+
return None
|
|
67
|
+
detail = error_details(error)
|
|
68
|
+
while detail and "status" not in detail:
|
|
69
|
+
detail = detail.get("cause", detail.get("context", {}))
|
|
70
|
+
if detail.get("status") == 401:
|
|
71
|
+
if "api key" in detail.get("provider_message", "").lower():
|
|
72
|
+
return f"Meridian at {base} rejected pcode's key. Check PCODE_MERIDIAN_API_KEY."
|
|
73
|
+
return "Meridian could not use your Claude login. Run /login meridian, then retry."
|
|
74
|
+
if transport_types(error) & {"APIConnectionError", "ConnectError"}:
|
|
75
|
+
if base == managed:
|
|
76
|
+
return "pcode's Meridian stopped and is being restarted. Retry in a moment."
|
|
77
|
+
return (
|
|
78
|
+
f"Meridian is not answering at {base}. Start it with `meridian`, or restart "
|
|
79
|
+
"pcode with meridian_managed set to auto or on so it runs its own."
|
|
80
|
+
)
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def thinking_passthrough(base_url: str, api_key: str | None) -> bool | None:
|
|
85
|
+
"""Whether the proxy forwards readable thinking; None when it cannot say.
|
|
86
|
+
|
|
87
|
+
A read-only settings lookup with a short timeout: this never changes a
|
|
88
|
+
shared proxy's configuration.
|
|
89
|
+
"""
|
|
90
|
+
headers = {"x-api-key": api_key} if api_key else {}
|
|
91
|
+
try:
|
|
92
|
+
response = httpx2.get(
|
|
93
|
+
base_url.rstrip("/") + "/settings/api/features",
|
|
94
|
+
headers=headers,
|
|
95
|
+
timeout=0.5,
|
|
96
|
+
trust_env=False,
|
|
97
|
+
)
|
|
98
|
+
value = response.json().get("passthrough", {}).get("thinkingPassthrough")
|
|
99
|
+
except (httpx2.HTTPError, ValueError, AttributeError):
|
|
100
|
+
return None
|
|
101
|
+
return value if isinstance(value, bool) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class MeridianProvider(AnthropicProvider):
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
return "meridian"
|
|
108
|
+
|
|
109
|
+
def __init__(self) -> None:
|
|
110
|
+
from pcode.meridian_process import managed_endpoint
|
|
111
|
+
|
|
112
|
+
endpoint = managed_endpoint()
|
|
113
|
+
base_url = endpoint[0] if endpoint else meridian_base_url()
|
|
114
|
+
api_key = (
|
|
115
|
+
endpoint[1]
|
|
116
|
+
if endpoint
|
|
117
|
+
else os.environ.get("PCODE_MERIDIAN_API_KEY", "").strip() or "meridian-local"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Never inherit upstream Anthropic credentials or global HTTP proxies.
|
|
121
|
+
def make_client():
|
|
122
|
+
return httpx2.AsyncClient(trust_env=False, timeout=600)
|
|
123
|
+
|
|
124
|
+
client = make_client()
|
|
125
|
+
super().__init__(
|
|
126
|
+
anthropic_client=AsyncAnthropic(
|
|
127
|
+
base_url=base_url,
|
|
128
|
+
api_key=api_key,
|
|
129
|
+
auth_token="",
|
|
130
|
+
http_client=client,
|
|
131
|
+
default_headers={"x-meridian-agent": "passthrough"},
|
|
132
|
+
# The SDK default of 2 would retry 408/409/429/5xx and connection
|
|
133
|
+
# errors silently, outside the runtime's visible retry budget and
|
|
134
|
+
# against the transient-error policy in `diagnostics.py`. The
|
|
135
|
+
# runtime owns transport retries here, as it does for API keys.
|
|
136
|
+
max_retries=0,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
# Empty token prevents environment lookup during construction; None then
|
|
140
|
+
# suppresses the SDK's otherwise empty Bearer header on requests.
|
|
141
|
+
self.client.auth_token = None
|
|
142
|
+
self._own_http_client = client
|
|
143
|
+
self._http_client_factory = make_client
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class MeridianModel(AnthropicModel):
|
|
147
|
+
@cached_property
|
|
148
|
+
def profile(self):
|
|
149
|
+
# Meridian answers any Anthropic server tool (`web_search_*`, `web_fetch_*`)
|
|
150
|
+
# with a 400: the Claude Max path cannot emit server_tool_use blocks. Drop
|
|
151
|
+
# them from the profile so the web capabilities fall back to local tools.
|
|
152
|
+
#
|
|
153
|
+
# Tool search fails quietly rather than with a 400. Meridian re-registers
|
|
154
|
+
# client tools with the Agent SDK, which drops `defer_loading` and
|
|
155
|
+
# `tool_reference`: the model sees every deferred schema, but no search
|
|
156
|
+
# result it could produce counts as discovery, so Pydantic AI refuses each
|
|
157
|
+
# deferred call as "not available yet". Use the local `search_tools` and
|
|
158
|
+
# no wire deferral: hidden tools are withheld, then sent in full once found.
|
|
159
|
+
from pydantic_ai.native_tools import WebFetchTool, WebSearchTool
|
|
160
|
+
from pydantic_ai.native_tools._tool_search import ToolSearchTool
|
|
161
|
+
from pydantic_ai.profiles import SUPPORTED_NATIVE_TOOLS, merge_profile
|
|
162
|
+
from pydantic_ai.profiles.anthropic import AnthropicModelProfile
|
|
163
|
+
|
|
164
|
+
profile = super().profile
|
|
165
|
+
native = profile.get("supported_native_tools", SUPPORTED_NATIVE_TOOLS)
|
|
166
|
+
unsupported = {WebSearchTool, WebFetchTool, ToolSearchTool}
|
|
167
|
+
return merge_profile(
|
|
168
|
+
profile,
|
|
169
|
+
AnthropicModelProfile(
|
|
170
|
+
supported_native_tools=native - unsupported,
|
|
171
|
+
tool_deferral_mode=None,
|
|
172
|
+
tool_addition_mode=None,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def meridian_model(model: str) -> AnthropicModel:
|
|
178
|
+
name = model.removeprefix("meridian:")
|
|
179
|
+
if not name.strip():
|
|
180
|
+
raise ValueError("Meridian requires a model ID: meridian:<model-id>")
|
|
181
|
+
return MeridianModel(name, provider=MeridianProvider())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def compaction_summary(messages) -> str | None:
|
|
185
|
+
"""The summary text when compaction has replaced the start of `messages`."""
|
|
186
|
+
from pcode.compaction import SUMMARY_PREFIX
|
|
187
|
+
|
|
188
|
+
first = messages[0] if messages else None
|
|
189
|
+
if not isinstance(first, ModelRequest):
|
|
190
|
+
return None
|
|
191
|
+
for part in first.parts:
|
|
192
|
+
if isinstance(part, UserPromptPart) and isinstance(part.content, str):
|
|
193
|
+
if part.content.startswith(SUMMARY_PREFIX):
|
|
194
|
+
return part.content
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def session_identity(conversation_id: str, messages) -> str:
|
|
199
|
+
"""Meridian session for this history, which changes each time it is compacted.
|
|
200
|
+
|
|
201
|
+
Meridian classifies a compacted history whose recent messages still match as a
|
|
202
|
+
continuation and resumes the uncompacted transcript, so the summary never
|
|
203
|
+
reaches the model. A session keyed on the summary starts fresh instead, once
|
|
204
|
+
per compaction; an uncompacted history keeps the plain conversation ID.
|
|
205
|
+
"""
|
|
206
|
+
summary = compaction_summary(messages)
|
|
207
|
+
if summary is None:
|
|
208
|
+
return conversation_id
|
|
209
|
+
return f"{conversation_id}.{hashlib.sha256(summary.encode()).hexdigest()[:16]}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class MeridianSessionIdentity(AbstractCapability):
|
|
213
|
+
"""Bind requests, not shared clients, to the current conversation.
|
|
214
|
+
|
|
215
|
+
Harness children inherit the model but get fresh Pydantic conversation IDs.
|
|
216
|
+
This also preserves identity across saved-session resume and model switches,
|
|
217
|
+
and moves to a new session after compaction (see `session_identity`).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
async def before_model_request(
|
|
221
|
+
self, ctx: RunContext, request_context: ModelRequestContext
|
|
222
|
+
) -> ModelRequestContext:
|
|
223
|
+
if request_context.model.system != "meridian":
|
|
224
|
+
return request_context
|
|
225
|
+
settings = dict(request_context.model_settings or {})
|
|
226
|
+
headers = dict(settings.get("extra_headers") or {})
|
|
227
|
+
# Headers are case-insensitive; never leave an alternate-cased stale ID.
|
|
228
|
+
headers = {k: v for k, v in headers.items() if k.lower() != "x-litellm-session-id"}
|
|
229
|
+
headers["x-litellm-session-id"] = session_identity(
|
|
230
|
+
ctx.conversation_id, request_context.messages
|
|
231
|
+
)
|
|
232
|
+
settings["extra_headers"] = headers
|
|
233
|
+
return replace(request_context, model_settings=settings)
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Process-owned Meridian instances (not a shared daemon)."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import httpx2
|
|
17
|
+
|
|
18
|
+
from pcode.preferences import load_preferences
|
|
19
|
+
|
|
20
|
+
# Oldest release whose configuration contract this module relies on
|
|
21
|
+
# (`sdk-features.json`, `/settings/api/features`, the MERIDIAN_* variables below).
|
|
22
|
+
# Newer releases start as long as they pass the same readiness checks.
|
|
23
|
+
MINIMUM_VERSION = "1.71.1"
|
|
24
|
+
MODES = ("auto", "on", "off")
|
|
25
|
+
WATCH_INTERVAL_SECONDS = 1.0
|
|
26
|
+
# A crash loop is not worth chasing: stop after this many restarts in the window.
|
|
27
|
+
MAX_RESTARTS = 3
|
|
28
|
+
RESTART_WINDOW_SECONDS = 300.0
|
|
29
|
+
_lock = threading.Lock()
|
|
30
|
+
_instance = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def version_tuple(text: str | None) -> tuple[int, ...] | None:
|
|
34
|
+
match = re.search(r"\d+(?:\.\d+)+", text or "")
|
|
35
|
+
return tuple(int(part) for part in match.group().split(".")) if match else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def supported(version: str | None) -> bool:
|
|
39
|
+
found = version_tuple(version)
|
|
40
|
+
return found is not None and found >= version_tuple(MINIMUM_VERSION)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def meridian_config_dir() -> Path:
|
|
44
|
+
"""The user's own Meridian configuration (profiles, settings)."""
|
|
45
|
+
return Path(os.environ.get("MERIDIAN_CONFIG_DIR") or Path.home() / ".config" / "meridian")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def meridian_profiles() -> list[dict]:
|
|
49
|
+
"""Account profiles from the user's `profiles.json`; tokens are never read out."""
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads((meridian_config_dir() / "profiles.json").read_text())
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
return []
|
|
54
|
+
if not isinstance(data, list):
|
|
55
|
+
return []
|
|
56
|
+
return [p for p in data if isinstance(p, dict) and isinstance(p.get("id"), str)]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def profile_dir(profile: dict) -> str:
|
|
60
|
+
"""The Claude config directory whose login a profile uses."""
|
|
61
|
+
configured = profile.get("claudeConfigDir")
|
|
62
|
+
if isinstance(configured, str) and configured:
|
|
63
|
+
return configured
|
|
64
|
+
return str(meridian_config_dir() / "profiles" / profile["id"])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def default_profile() -> dict | None:
|
|
68
|
+
"""The profile a managed instance uses: the saved active one, else the first."""
|
|
69
|
+
profiles = meridian_profiles()
|
|
70
|
+
try:
|
|
71
|
+
settings = json.loads((meridian_config_dir() / "settings.json").read_text())
|
|
72
|
+
saved = settings.get("activeProfile") if isinstance(settings, dict) else None
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
saved = None
|
|
75
|
+
return next((p for p in profiles if p["id"] == saved), profiles[0] if profiles else None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def session_store_dir() -> Path:
|
|
79
|
+
"""Meridian's session store, kept across pcode runs so resumes stay warm.
|
|
80
|
+
|
|
81
|
+
With a temporary store a restarted Meridian no longer recognises the
|
|
82
|
+
conversation and replays its whole history as flattened text. Meridian locks
|
|
83
|
+
the store, so concurrent pcode processes can share it.
|
|
84
|
+
"""
|
|
85
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
86
|
+
return state / "pcode" / "meridian" / "sessions"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ManagedMeridian:
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.process = None
|
|
92
|
+
self.directory = None
|
|
93
|
+
self.executable = None
|
|
94
|
+
self.port = None
|
|
95
|
+
self.base_url = ""
|
|
96
|
+
self.api_key = secrets.token_urlsafe(32)
|
|
97
|
+
self.profile: str | None = None
|
|
98
|
+
self.restarts: list[float] = []
|
|
99
|
+
self.failure: str | None = None
|
|
100
|
+
self._lock = threading.RLock()
|
|
101
|
+
self._stopped = threading.Event()
|
|
102
|
+
self._watchdog = None
|
|
103
|
+
|
|
104
|
+
def start(self):
|
|
105
|
+
executable = shutil.which("meridian")
|
|
106
|
+
if not executable:
|
|
107
|
+
raise ValueError("Managed Meridian requires meridian on PATH; install it first.")
|
|
108
|
+
try:
|
|
109
|
+
version = subprocess.run(
|
|
110
|
+
[executable, "--version"], capture_output=True, text=True, timeout=10, check=True
|
|
111
|
+
).stdout.strip()
|
|
112
|
+
except (OSError, subprocess.SubprocessError):
|
|
113
|
+
raise ValueError("Could not verify the installed Meridian version.") from None
|
|
114
|
+
if not supported(version):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"Managed Meridian needs {MINIMUM_VERSION} or newer "
|
|
117
|
+
f"(found {version or 'unknown'}). Run `pcode --upgrade-meridian`."
|
|
118
|
+
)
|
|
119
|
+
self.executable = executable
|
|
120
|
+
self.directory = tempfile.TemporaryDirectory(prefix="pcode-meridian-")
|
|
121
|
+
root = Path(self.directory.name)
|
|
122
|
+
(root / "sdk-features.json").write_text(
|
|
123
|
+
json.dumps({"passthrough": {"thinkingPassthrough": True}})
|
|
124
|
+
)
|
|
125
|
+
(root / "plugins").mkdir()
|
|
126
|
+
(root / "plugins.json").write_text("[]")
|
|
127
|
+
(root / "workdir").mkdir()
|
|
128
|
+
profile = default_profile()
|
|
129
|
+
if profile is not None:
|
|
130
|
+
# 1.72 reads profiles from ~/.config/meridian whatever MERIDIAN_CONFIG_DIR
|
|
131
|
+
# says, while 1.76 reads them from the config directory. Link them in
|
|
132
|
+
# (never copy: a profile can hold a token) so every release sees the
|
|
133
|
+
# same ones, and name the profile so /login meridian targets its login.
|
|
134
|
+
for name in ("profiles.json", "profiles"):
|
|
135
|
+
source = meridian_config_dir() / name
|
|
136
|
+
if source.exists():
|
|
137
|
+
(root / name).symlink_to(source)
|
|
138
|
+
self.profile = profile["id"]
|
|
139
|
+
session_store_dir().mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
140
|
+
with socket.socket() as sock:
|
|
141
|
+
sock.bind(("127.0.0.1", 0))
|
|
142
|
+
self.port = sock.getsockname()[1]
|
|
143
|
+
# The port and key stay fixed across restarts, so clients built against
|
|
144
|
+
# this instance keep working after the watchdog replaces the process.
|
|
145
|
+
self.base_url = f"http://127.0.0.1:{self.port}"
|
|
146
|
+
try:
|
|
147
|
+
self._spawn()
|
|
148
|
+
except BaseException:
|
|
149
|
+
self.close()
|
|
150
|
+
raise
|
|
151
|
+
self._watchdog = threading.Thread(
|
|
152
|
+
target=self._watch, name="pcode-meridian-watchdog", daemon=True
|
|
153
|
+
)
|
|
154
|
+
self._watchdog.start()
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def _spawn(self):
|
|
158
|
+
root = Path(self.directory.name)
|
|
159
|
+
# Do not inherit shared proxy routing, plugins, or administrative settings.
|
|
160
|
+
# HOME and Claude credentials remain unchanged: Meridian owns upstream auth.
|
|
161
|
+
env = {
|
|
162
|
+
k: v for k, v in os.environ.items() if not k.startswith(("MERIDIAN_", "CLAUDE_PROXY_"))
|
|
163
|
+
}
|
|
164
|
+
env.update(
|
|
165
|
+
{
|
|
166
|
+
"MERIDIAN_HOST": "127.0.0.1",
|
|
167
|
+
"MERIDIAN_PORT": str(self.port),
|
|
168
|
+
"MERIDIAN_API_KEY": self.api_key,
|
|
169
|
+
"MERIDIAN_CONFIG_DIR": str(root),
|
|
170
|
+
"MERIDIAN_SESSION_DIR": str(session_store_dir()),
|
|
171
|
+
"MERIDIAN_PLUGIN_DIR": str(root / "plugins"),
|
|
172
|
+
"MERIDIAN_PLUGIN_CONFIG": str(root / "plugins.json"),
|
|
173
|
+
"MERIDIAN_DESIGN_TOKEN_PATH": str(root / "design-token.json"),
|
|
174
|
+
"MERIDIAN_NO_UPDATE_CHECK": "1",
|
|
175
|
+
"MERIDIAN_TELEMETRY_PERSIST": "0",
|
|
176
|
+
"MERIDIAN_PASSTHROUGH": "1",
|
|
177
|
+
# Claude Code counts the files under its cwd on every start
|
|
178
|
+
# (`rg --files --hidden`, skipped only for $HOME), and Meridian
|
|
179
|
+
# starts one per request. Tools run in pcode, so give it an
|
|
180
|
+
# empty directory rather than wherever pcode was launched.
|
|
181
|
+
"MERIDIAN_WORKDIR": str(root / "workdir"),
|
|
182
|
+
}
|
|
183
|
+
)
|
|
184
|
+
if self.profile:
|
|
185
|
+
env["MERIDIAN_DEFAULT_PROFILE"] = self.profile
|
|
186
|
+
# Never let server output corrupt the terminal or expose credentials.
|
|
187
|
+
self.process = subprocess.Popen(
|
|
188
|
+
[self.executable],
|
|
189
|
+
env=env,
|
|
190
|
+
cwd=root / "workdir",
|
|
191
|
+
stdin=subprocess.DEVNULL,
|
|
192
|
+
stdout=subprocess.DEVNULL,
|
|
193
|
+
stderr=subprocess.DEVNULL,
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
self.wait_ready()
|
|
197
|
+
except BaseException:
|
|
198
|
+
self._stop_process()
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
def wait_ready(self):
|
|
202
|
+
deadline = time.monotonic() + 30
|
|
203
|
+
with httpx2.Client(trust_env=False, timeout=2) as client:
|
|
204
|
+
while time.monotonic() < deadline:
|
|
205
|
+
if self.process.poll() is not None:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"Managed Meridian exited during startup (possibly a port conflict)."
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
headers = {"x-api-key": self.api_key}
|
|
211
|
+
health = client.get(self.base_url + "/health", headers=headers).json()
|
|
212
|
+
if health.get("auth", {}).get("loggedIn") is False:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
"Meridian is not logged in. Run /login meridian, then retry."
|
|
215
|
+
)
|
|
216
|
+
if health.get("status") == "healthy" and supported(health.get("version")):
|
|
217
|
+
response = client.get(
|
|
218
|
+
self.base_url + "/settings/api/features", headers=headers
|
|
219
|
+
)
|
|
220
|
+
response.raise_for_status()
|
|
221
|
+
if (
|
|
222
|
+
response.json().get("passthrough", {}).get("thinkingPassthrough")
|
|
223
|
+
is not True
|
|
224
|
+
):
|
|
225
|
+
raise ValueError(
|
|
226
|
+
"Managed Meridian did not enable thinking passthrough."
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
except (httpx2.HTTPError, json.JSONDecodeError):
|
|
230
|
+
pass
|
|
231
|
+
time.sleep(0.1)
|
|
232
|
+
raise ValueError("Managed Meridian was not ready within 30 seconds; check Claude login.")
|
|
233
|
+
|
|
234
|
+
def ensure_running(self):
|
|
235
|
+
"""Replace an exited process on the same port, within the restart budget."""
|
|
236
|
+
with self._lock:
|
|
237
|
+
if self.failure:
|
|
238
|
+
raise ValueError(self.failure)
|
|
239
|
+
if self._stopped.is_set():
|
|
240
|
+
raise ValueError("Managed Meridian was stopped. Restart pcode.")
|
|
241
|
+
if self.process is not None and self.process.poll() is None:
|
|
242
|
+
return
|
|
243
|
+
now = time.monotonic()
|
|
244
|
+
self.restarts = [t for t in self.restarts if now - t < RESTART_WINDOW_SECONDS]
|
|
245
|
+
if len(self.restarts) >= MAX_RESTARTS:
|
|
246
|
+
self.failure = (
|
|
247
|
+
"Managed Meridian keeps exiting. Run `meridian` in a terminal to see why, "
|
|
248
|
+
"then restart pcode."
|
|
249
|
+
)
|
|
250
|
+
raise ValueError(self.failure)
|
|
251
|
+
self.restarts.append(now)
|
|
252
|
+
self._spawn()
|
|
253
|
+
|
|
254
|
+
def _watch(self):
|
|
255
|
+
# Requests in flight when the process died are not replayed; the
|
|
256
|
+
# runtime's own retry, or the next prompt, reaches the replacement.
|
|
257
|
+
while not self._stopped.wait(WATCH_INTERVAL_SECONDS):
|
|
258
|
+
try:
|
|
259
|
+
self.ensure_running()
|
|
260
|
+
except ValueError:
|
|
261
|
+
if self.failure:
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
def _stop_process(self):
|
|
265
|
+
if self.process is not None:
|
|
266
|
+
if self.process.poll() is None:
|
|
267
|
+
self.process.terminate()
|
|
268
|
+
try:
|
|
269
|
+
self.process.wait(timeout=5)
|
|
270
|
+
except subprocess.TimeoutExpired:
|
|
271
|
+
self.process.kill()
|
|
272
|
+
self.process.wait(timeout=5)
|
|
273
|
+
self.process = None
|
|
274
|
+
|
|
275
|
+
def close(self):
|
|
276
|
+
self._stopped.set()
|
|
277
|
+
with self._lock:
|
|
278
|
+
self._stop_process()
|
|
279
|
+
if self.directory is not None:
|
|
280
|
+
self.directory.cleanup()
|
|
281
|
+
self.directory = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def managed_mode() -> str:
|
|
285
|
+
"""`on`, `off`, or `auto`; `PCODE_MERIDIAN_MANAGED` overrides the saved choice."""
|
|
286
|
+
override = os.environ.get("PCODE_MERIDIAN_MANAGED", "").strip()
|
|
287
|
+
if override:
|
|
288
|
+
if override not in {"0", "1"}:
|
|
289
|
+
raise ValueError("PCODE_MERIDIAN_MANAGED must be 0 or 1.")
|
|
290
|
+
return "on" if override == "1" else "off"
|
|
291
|
+
mode = load_preferences().get("meridian_managed", "auto")
|
|
292
|
+
return mode if mode in MODES else "auto"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def external_proxy_running(base_url: str) -> bool:
|
|
296
|
+
"""True when something that looks like Meridian answers at `base_url`."""
|
|
297
|
+
try:
|
|
298
|
+
response = httpx2.get(base_url + "/health", timeout=1, trust_env=False)
|
|
299
|
+
except httpx2.HTTPError:
|
|
300
|
+
return False
|
|
301
|
+
if response.status_code in (401, 403):
|
|
302
|
+
return True # A key-protected proxy still counts as the user's own.
|
|
303
|
+
try:
|
|
304
|
+
return "status" in response.json()
|
|
305
|
+
except (ValueError, AttributeError, TypeError):
|
|
306
|
+
return False
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def managed_base_url() -> str | None:
|
|
310
|
+
"""The owned instance's URL, if this process started one."""
|
|
311
|
+
instance = _instance
|
|
312
|
+
return instance.base_url if instance is not None else None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def managed_endpoint():
|
|
316
|
+
"""Return private connection details, or None to use an external proxy.
|
|
317
|
+
|
|
318
|
+
`auto` prefers a proxy already answering at the default address, then starts
|
|
319
|
+
a private instance when `meridian` is installed.
|
|
320
|
+
"""
|
|
321
|
+
if os.environ.get("PCODE_MERIDIAN_BASE_URL", "").strip():
|
|
322
|
+
return None
|
|
323
|
+
mode = managed_mode()
|
|
324
|
+
if mode == "off":
|
|
325
|
+
return None
|
|
326
|
+
global _instance
|
|
327
|
+
with _lock:
|
|
328
|
+
if _instance is None:
|
|
329
|
+
if mode == "auto":
|
|
330
|
+
from pcode.meridian import DEFAULT_BASE_URL
|
|
331
|
+
|
|
332
|
+
if shutil.which("meridian") is None or external_proxy_running(DEFAULT_BASE_URL):
|
|
333
|
+
return None
|
|
334
|
+
_instance = ManagedMeridian().start()
|
|
335
|
+
atexit.register(_instance.close)
|
|
336
|
+
_instance.ensure_running()
|
|
337
|
+
return _instance.base_url, _instance.api_key
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Durable reminder helper and append-only limit warnings for Meridian and Claude Code."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from pydantic_ai.messages import ModelRequest, UserPromptPart
|
|
8
|
+
from pydantic_ai_harness.compaction import WarnNearLimits
|
|
9
|
+
|
|
10
|
+
from pcode.model_metadata import ContextWindowError, context_window, refresh_context
|
|
11
|
+
|
|
12
|
+
LIMITS_TAG = "[WarnNearLimits]"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def last_reminder(messages, tag: str) -> str | None:
|
|
16
|
+
"""Return the most recent reminder text for `tag`, or None when none was sent.
|
|
17
|
+
|
|
18
|
+
Detection is by content, not message metadata: Pydantic AI merges consecutive
|
|
19
|
+
`ModelRequest`s when history is resumed and keeps only its own reserved
|
|
20
|
+
metadata namespace, so a marker stored there survives a run but not a resume.
|
|
21
|
+
Only a `UserPromptPart` that *starts* with the tag counts, so a tool result or
|
|
22
|
+
quoted prompt that merely mentions it is not mistaken for a sent reminder.
|
|
23
|
+
"""
|
|
24
|
+
for message in reversed(messages):
|
|
25
|
+
if not isinstance(message, ModelRequest):
|
|
26
|
+
continue
|
|
27
|
+
for part in reversed(message.parts):
|
|
28
|
+
content = getattr(part, "content", None)
|
|
29
|
+
if isinstance(part, UserPromptPart) and isinstance(content, str):
|
|
30
|
+
if content.startswith(tag):
|
|
31
|
+
return content
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def append_reminder(
|
|
36
|
+
request_context, tag: str, text: str, normalize: Callable[[str], str] | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Append `text` as durable history unless the last reminder already says it.
|
|
39
|
+
|
|
40
|
+
Appending, rather than moving a mutable tail, keeps previously sent messages
|
|
41
|
+
byte-identical so the provider's cached prefix stays reusable as the
|
|
42
|
+
conversation grows. `normalize` collapses differences that should not count
|
|
43
|
+
as a change (for example, warning percentages within the same decile).
|
|
44
|
+
"""
|
|
45
|
+
messages = request_context.messages
|
|
46
|
+
if not messages or not isinstance(messages[-1], ModelRequest):
|
|
47
|
+
return
|
|
48
|
+
previous = last_reminder(messages, tag)
|
|
49
|
+
key = normalize or (lambda value: value)
|
|
50
|
+
if previous is not None and key(previous) == key(text):
|
|
51
|
+
return
|
|
52
|
+
messages.append(ModelRequest(parts=[UserPromptPart(content=text)]))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _decile_key(text: str) -> str:
|
|
56
|
+
"""Treat percentages within the same decile as the same warning."""
|
|
57
|
+
return re.sub(r"\d+", "#", text) + str([int(p) // 10 for p in re.findall(r"(\d+)%", text)])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Providers that keep the history themselves, where editing a sent message
|
|
61
|
+
# forces a replay: Meridian's lineage check and the `claude:` CLI transcript.
|
|
62
|
+
APPEND_ONLY_SYSTEMS = frozenset({"meridian", "claude"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MeridianLimitWarnings(WarnNearLimits):
|
|
66
|
+
"""Warn against pcode's resolved window, and never remove text already sent
|
|
67
|
+
to a provider that keeps the history (`APPEND_ONLY_SYSTEMS`)."""
|
|
68
|
+
|
|
69
|
+
async def _for_model(self, model) -> WarnNearLimits:
|
|
70
|
+
"""This capability, bound to the window the footer and compaction use.
|
|
71
|
+
|
|
72
|
+
Harness resolves windows from genai-prices, which knows no `meridian:` ids
|
|
73
|
+
and silently assumes 200k (a 1M Meridian session was told it was 90% full
|
|
74
|
+
at 162k), and gives `openai-codex:` the direct API's window by model name
|
|
75
|
+
(1.05M where Codex serves 272k). Explicit windows and models pcode cannot
|
|
76
|
+
resolve keep Harness's own resolution.
|
|
77
|
+
"""
|
|
78
|
+
if self.max_context_fraction is None or self.context_window is not None:
|
|
79
|
+
return self
|
|
80
|
+
await refresh_context(model)
|
|
81
|
+
try:
|
|
82
|
+
window = context_window(model)
|
|
83
|
+
except ContextWindowError:
|
|
84
|
+
# Compaction reports the actionable configuration error.
|
|
85
|
+
window = None
|
|
86
|
+
return self if window is None else replace(self, context_window=window)
|
|
87
|
+
|
|
88
|
+
async def before_model_request(self, ctx, request_context):
|
|
89
|
+
warnings = await self._for_model(request_context.model)
|
|
90
|
+
if request_context.model.system not in APPEND_ONLY_SYSTEMS:
|
|
91
|
+
return await WarnNearLimits.before_model_request(warnings, ctx, request_context)
|
|
92
|
+
original = request_context.messages
|
|
93
|
+
if not original or not isinstance(original[-1], ModelRequest):
|
|
94
|
+
return request_context
|
|
95
|
+
# Upstream strips prior warnings and appends exactly one request when a
|
|
96
|
+
# threshold fires. Evaluate on a disposable list; retain the real history.
|
|
97
|
+
clean_count = len(self._strip_old_warnings(original))
|
|
98
|
+
candidate = await WarnNearLimits.before_model_request(
|
|
99
|
+
warnings, ctx, replace(request_context, messages=list(original))
|
|
100
|
+
)
|
|
101
|
+
if len(candidate.messages) > clean_count:
|
|
102
|
+
# Warn at percentage deciles rather than on every token increase.
|
|
103
|
+
# Keep severity and warning kinds so new limits/escalations still fire.
|
|
104
|
+
append_reminder(
|
|
105
|
+
request_context, LIMITS_TAG, candidate.messages[-1].parts[0].content, _decile_key
|
|
106
|
+
)
|
|
107
|
+
return request_context
|