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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Exceptions for failed `claude:` requests, and what to tell the user about them."""
|
|
2
|
+
|
|
3
|
+
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError
|
|
4
|
+
|
|
5
|
+
MISSING_SDK = (
|
|
6
|
+
"claude: models need pcode's optional `claude` extra, which is not installed. "
|
|
7
|
+
"From a pcode checkout run `make install`, or `uv tool install --editable '.[claude]'`."
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClaudeHTTPError(ModelHTTPError):
|
|
12
|
+
"""An API error the CLI reported for a `claude:` request."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClaudeProcessError(ModelAPIError):
|
|
16
|
+
"""The CLI process ended mid-request; a retry starts another (transient)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaudeConnectionError(ModelAPIError):
|
|
20
|
+
"""The CLI's request got no HTTP response: dropped, refused or timed out (transient).
|
|
21
|
+
|
|
22
|
+
Verified against the bundled CLI: all three arrive as a `server_error` with
|
|
23
|
+
no `api_error_status`, where a real 500 or 529 carries its status. With the
|
|
24
|
+
CLI's own retries off, pcode's transient retry covers these, as it covers
|
|
25
|
+
the same failures on its other providers.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ClaudeStartError(ModelAPIError):
|
|
30
|
+
"""The CLI process could not be started or resumed."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeSDKMissing(ValueError):
|
|
34
|
+
"""pcode was installed without its `claude` extra."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def failure_hint(error: BaseException) -> str | None:
|
|
38
|
+
"""What to do about a failed `claude:` request, or None for any other provider."""
|
|
39
|
+
seen = set()
|
|
40
|
+
while error is not None and id(error) not in seen and len(seen) < 16:
|
|
41
|
+
seen.add(id(error))
|
|
42
|
+
if isinstance(error, ClaudeSDKMissing):
|
|
43
|
+
return MISSING_SDK
|
|
44
|
+
if isinstance(error, ClaudeConnectionError):
|
|
45
|
+
# The CLI's own text stays in the diagnostics log.
|
|
46
|
+
return (
|
|
47
|
+
"Claude Code got no response from Anthropic (connection dropped, refused "
|
|
48
|
+
"or timed out). Check network/proxy connectivity and retry when ready."
|
|
49
|
+
)
|
|
50
|
+
if isinstance(error, ClaudeHTTPError):
|
|
51
|
+
body = error.body if isinstance(error.body, dict) else {}
|
|
52
|
+
kind = (body.get("error") or {}).get("type")
|
|
53
|
+
if error.status_code in (401, 403) or kind == "authentication_failed":
|
|
54
|
+
return "Claude Code is not signed in, or its login expired. Run /login claude."
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(error, ClaudeStartError):
|
|
57
|
+
return f"{error.message} Check the Claude Code install, or run /login claude."
|
|
58
|
+
if isinstance(error, ClaudeProcessError):
|
|
59
|
+
return f"{error.message} Retry; pcode starts a new Claude Code process."
|
|
60
|
+
error = error.__cause__ or error.__context__
|
|
61
|
+
return None
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""History bookkeeping: normalizing, hashing and replaying Anthropic messages."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
REPLAY_INTRO = (
|
|
10
|
+
"This conversation began outside the current session, so its earlier messages are "
|
|
11
|
+
"replayed below as a transcript. Treat them as having happened here and continue "
|
|
12
|
+
"from the final message.\n\n"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _clean(value: Any) -> Any:
|
|
17
|
+
"""Drop cache markers and make binary sources plain base64 JSON.
|
|
18
|
+
|
|
19
|
+
The CLI places its own cache markers, and they never change content.
|
|
20
|
+
Pydantic AI maps image and PDF bytes to `io.BytesIO`, which the Anthropic
|
|
21
|
+
SDK encodes on send; the CLI takes JSON, and hashing needs stable values.
|
|
22
|
+
"""
|
|
23
|
+
if isinstance(value, io.BytesIO):
|
|
24
|
+
return base64.b64encode(value.getvalue()).decode("ascii")
|
|
25
|
+
if isinstance(value, dict):
|
|
26
|
+
return {k: _clean(v) for k, v in value.items() if k != "cache_control"}
|
|
27
|
+
if isinstance(value, list):
|
|
28
|
+
return [_clean(v) for v in value]
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _blocks(message: dict) -> list[dict]:
|
|
33
|
+
content = message.get("content")
|
|
34
|
+
if isinstance(content, str):
|
|
35
|
+
return [{"type": "text", "text": content}] if content else []
|
|
36
|
+
return list(content or [])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize(messages: list[dict]) -> list[dict]:
|
|
40
|
+
"""Anthropic messages without cache markers, consecutive same-role turns merged.
|
|
41
|
+
|
|
42
|
+
Pydantic AI leaves an appended reminder as its own user message; the API
|
|
43
|
+
merges consecutive turns anyway, and one user turn is one CLI input.
|
|
44
|
+
"""
|
|
45
|
+
merged: list[dict] = []
|
|
46
|
+
for message in _clean(messages):
|
|
47
|
+
if merged and merged[-1]["role"] == message["role"]:
|
|
48
|
+
merged[-1] = {
|
|
49
|
+
"role": message["role"],
|
|
50
|
+
"content": _blocks(merged[-1]) + _blocks(message),
|
|
51
|
+
}
|
|
52
|
+
else:
|
|
53
|
+
merged.append({"role": message["role"], "content": _blocks(message)})
|
|
54
|
+
return merged
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _digest(previous: str, message: dict) -> str:
|
|
58
|
+
text = json.dumps(message, sort_keys=True, separators=(",", ":"), default=str)
|
|
59
|
+
return hashlib.sha256(f"{previous}\n{text}".encode()).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def lineage(messages: list[dict]) -> list[str]:
|
|
63
|
+
"""One hash per message, each covering the whole history up to it."""
|
|
64
|
+
chain, previous = [], "pcode-claude-1"
|
|
65
|
+
for message in messages:
|
|
66
|
+
previous = _digest(previous, message)
|
|
67
|
+
chain.append(previous)
|
|
68
|
+
return chain
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tool_use_ids(message: dict) -> tuple[str, ...]:
|
|
72
|
+
return tuple(b["id"] for b in _blocks(message) if b.get("type") == "tool_use")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _result_ids(message: dict) -> set[str]:
|
|
76
|
+
return {b["tool_use_id"] for b in _blocks(message) if b.get("type") == "tool_result"}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _result_text(block: dict) -> str:
|
|
80
|
+
content = block.get("content")
|
|
81
|
+
if isinstance(content, str):
|
|
82
|
+
return content
|
|
83
|
+
return "\n".join(_render_block(item) for item in content or [])
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _render_block(block: dict) -> str:
|
|
87
|
+
kind = block.get("type")
|
|
88
|
+
if kind == "text":
|
|
89
|
+
return block.get("text", "")
|
|
90
|
+
if kind == "tool_use":
|
|
91
|
+
arguments = json.dumps(block.get("input"), ensure_ascii=False, default=str)
|
|
92
|
+
return f"[Called tool {block.get('name')} (id {block.get('id')}) with {arguments}]"
|
|
93
|
+
if kind == "tool_result":
|
|
94
|
+
status = " (error)" if block.get("is_error") else ""
|
|
95
|
+
return f"[Result of tool call {block.get('tool_use_id')}{status}]\n{_result_text(block)}"
|
|
96
|
+
if kind in ("thinking", "redacted_thinking"):
|
|
97
|
+
return ""
|
|
98
|
+
return f"[{kind or 'content'} omitted]"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def replay(messages: list[dict], open_tool_ids: tuple[str, ...] = ()) -> list[dict]:
|
|
102
|
+
"""One user turn carrying `messages`, for a CLI transcript that lacks them.
|
|
103
|
+
|
|
104
|
+
Results for the tool calls the transcript ends on (`open_tool_ids`) stay
|
|
105
|
+
structured, since the API requires them; everything else becomes text.
|
|
106
|
+
Images and documents from the final message are still attached.
|
|
107
|
+
"""
|
|
108
|
+
first = _blocks(messages[0]) if messages else []
|
|
109
|
+
head = [
|
|
110
|
+
b for b in first if b.get("type") == "tool_result" and b["tool_use_id"] in open_tool_ids
|
|
111
|
+
]
|
|
112
|
+
answered = {b["tool_use_id"] for b in head}
|
|
113
|
+
head += [
|
|
114
|
+
{
|
|
115
|
+
"type": "tool_result",
|
|
116
|
+
"tool_use_id": i,
|
|
117
|
+
"content": "[No result recorded]",
|
|
118
|
+
"is_error": True,
|
|
119
|
+
}
|
|
120
|
+
for i in open_tool_ids
|
|
121
|
+
if i not in answered
|
|
122
|
+
]
|
|
123
|
+
rest = [
|
|
124
|
+
{"role": messages[0]["role"], "content": [b for b in first if b not in head]},
|
|
125
|
+
*messages[1:],
|
|
126
|
+
]
|
|
127
|
+
turns = []
|
|
128
|
+
for message in rest:
|
|
129
|
+
text = "\n".join(filter(None, (_render_block(b) for b in _blocks(message))))
|
|
130
|
+
if text:
|
|
131
|
+
turns.append(f"[{message['role'].title()}]\n{text}")
|
|
132
|
+
attached = [b for b in _blocks(messages[-1]) if b.get("type") in ("image", "document")]
|
|
133
|
+
body = "\n\n".join(turns)
|
|
134
|
+
text = f"{REPLAY_INTRO}<conversation>\n{body}\n</conversation>" if turns else ""
|
|
135
|
+
return head + ([{"type": "text", "text": text}] if text else []) + attached
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mcp_content(block: dict) -> list[dict]:
|
|
139
|
+
"""An Anthropic tool_result's content as MCP content blocks."""
|
|
140
|
+
content = block.get("content")
|
|
141
|
+
if isinstance(content, str):
|
|
142
|
+
return [{"type": "text", "text": content}] if content else []
|
|
143
|
+
items = []
|
|
144
|
+
for item in content or []:
|
|
145
|
+
source = item.get("source") or {}
|
|
146
|
+
if item.get("type") == "text":
|
|
147
|
+
items.append({"type": "text", "text": item.get("text", "")})
|
|
148
|
+
elif item.get("type") == "image" and source.get("type") == "base64":
|
|
149
|
+
items.append(
|
|
150
|
+
{"type": "image", "data": source["data"], "mimeType": source["media_type"]}
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
items.append({"type": "text", "text": _render_block(item)})
|
|
154
|
+
return items
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""`ClaudeModel`: an `AnthropicModel` whose requests go through a CLI process."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from anthropic import AsyncAnthropic
|
|
11
|
+
from anthropic._models import construct_type
|
|
12
|
+
from anthropic.types.beta import BetaRawMessageStreamEvent
|
|
13
|
+
from pydantic_ai import RunContext
|
|
14
|
+
from pydantic_ai._utils import PeekableAsyncStream
|
|
15
|
+
from pydantic_ai.models import (
|
|
16
|
+
ModelRequestParameters,
|
|
17
|
+
StreamedResponse,
|
|
18
|
+
check_allow_model_requests,
|
|
19
|
+
)
|
|
20
|
+
from pydantic_ai.models.anthropic import AnthropicModel
|
|
21
|
+
from pydantic_ai.providers.anthropic import AnthropicProvider
|
|
22
|
+
|
|
23
|
+
from pcode.claude_sdk.errors import MISSING_SDK, ClaudeSDKMissing
|
|
24
|
+
from pcode.claude_sdk.messages import _digest, lineage, normalize
|
|
25
|
+
from pcode.claude_sdk.session import SessionConfig
|
|
26
|
+
from pcode.claude_sdk.session_pool import pool
|
|
27
|
+
from pcode.claude_sdk.workspace import CWD_SETTING
|
|
28
|
+
|
|
29
|
+
PREFIX = "claude:"
|
|
30
|
+
EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClaudeProvider(AnthropicProvider):
|
|
34
|
+
"""Names responses `claude`. Its client is never called: the CLI makes every request."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self) -> str:
|
|
38
|
+
return "claude"
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
super().__init__(
|
|
42
|
+
anthropic_client=AsyncAnthropic(
|
|
43
|
+
api_key="unused", base_url="https://claude-code.invalid", max_retries=0
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Events:
|
|
49
|
+
"""Stream events as the Anthropic SDK types `_process_streamed_response` reads."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, events: AsyncIterator[dict]) -> None:
|
|
52
|
+
self._events = events
|
|
53
|
+
|
|
54
|
+
def __aiter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __anext__(self):
|
|
58
|
+
event = await self._events.__anext__()
|
|
59
|
+
return construct_type(type_=BetaRawMessageStreamEvent, value=event)
|
|
60
|
+
|
|
61
|
+
async def close(self) -> None:
|
|
62
|
+
await self._events.aclose()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Model setting: False for a one-off request (a session title), whose process
|
|
66
|
+
# is stopped once it answers. Kept, it would be the newest idle process and
|
|
67
|
+
# evict the conversation's warm one under `claude_idle_processes`.
|
|
68
|
+
KEEP_WARM_SETTING = "pcode_claude_keep_warm"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ClaudeModel(AnthropicModel):
|
|
72
|
+
@cached_property
|
|
73
|
+
def profile(self):
|
|
74
|
+
# The CLI forwards only MCP tools, so no Anthropic server tool reaches
|
|
75
|
+
# the model and web tools fall back to local ones. Like Meridian, wire
|
|
76
|
+
# tool deferral is off: hidden tools are withheld until found.
|
|
77
|
+
from pydantic_ai.profiles import merge_profile
|
|
78
|
+
from pydantic_ai.profiles.anthropic import AnthropicModelProfile
|
|
79
|
+
|
|
80
|
+
return merge_profile(
|
|
81
|
+
super().profile,
|
|
82
|
+
AnthropicModelProfile(
|
|
83
|
+
supported_native_tools=frozenset(),
|
|
84
|
+
tool_deferral_mode=None,
|
|
85
|
+
tool_addition_mode=None,
|
|
86
|
+
# The system prompt is fixed per process; mid-conversation
|
|
87
|
+
# system text must travel as user text instead.
|
|
88
|
+
supports_inline_system_prompts=False,
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def session_config(self, system, tools, settings) -> SessionConfig:
|
|
93
|
+
if isinstance(system, str):
|
|
94
|
+
prompt = system
|
|
95
|
+
else:
|
|
96
|
+
prompt = "\n\n".join(block["text"] for block in system if block.get("text"))
|
|
97
|
+
wire_tools = [
|
|
98
|
+
{
|
|
99
|
+
"name": tool["name"],
|
|
100
|
+
"description": tool.get("description") or "",
|
|
101
|
+
"input_schema": tool["input_schema"],
|
|
102
|
+
}
|
|
103
|
+
for tool in tools
|
|
104
|
+
if "input_schema" in tool
|
|
105
|
+
]
|
|
106
|
+
effort = settings.get("anthropic_effort")
|
|
107
|
+
thinking = settings.get("anthropic_thinking")
|
|
108
|
+
max_tokens = settings.get("max_tokens")
|
|
109
|
+
return SessionConfig(
|
|
110
|
+
model=self.model_name,
|
|
111
|
+
cwd=str(settings.get(CWD_SETTING) or os.getcwd()),
|
|
112
|
+
system_prompt=prompt,
|
|
113
|
+
tools=json.dumps(wire_tools, sort_keys=True),
|
|
114
|
+
effort=effort if effort in EFFORTS else None,
|
|
115
|
+
thinking=json.dumps(thinking, sort_keys=True) if isinstance(thinking, dict) else None,
|
|
116
|
+
max_tokens=max_tokens if isinstance(max_tokens, int) and max_tokens > 0 else None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def request(self, messages, model_settings, model_request_parameters):
|
|
120
|
+
async with self.request_stream(
|
|
121
|
+
messages, model_settings, model_request_parameters
|
|
122
|
+
) as stream:
|
|
123
|
+
async for _ in stream:
|
|
124
|
+
pass
|
|
125
|
+
return stream.get()
|
|
126
|
+
|
|
127
|
+
async def count_tokens(self, messages, model_settings, model_request_parameters):
|
|
128
|
+
raise NotImplementedError("claude: models cannot count tokens ahead of a request")
|
|
129
|
+
|
|
130
|
+
@asynccontextmanager
|
|
131
|
+
async def request_stream(
|
|
132
|
+
self,
|
|
133
|
+
messages,
|
|
134
|
+
model_settings,
|
|
135
|
+
model_request_parameters: ModelRequestParameters,
|
|
136
|
+
run_context: RunContext[Any] | None = None,
|
|
137
|
+
) -> AsyncIterator[StreamedResponse]:
|
|
138
|
+
check_allow_model_requests()
|
|
139
|
+
settings, parameters = self.prepare_request(model_settings, model_request_parameters)
|
|
140
|
+
settings = dict(settings or {})
|
|
141
|
+
system, mapped = await self._map_message(messages, parameters, settings)
|
|
142
|
+
tools, _ = self._prepare_tools_and_tool_choice(settings, parameters)
|
|
143
|
+
config = self.session_config(system, tools, settings)
|
|
144
|
+
mapped = normalize(mapped)
|
|
145
|
+
chain = lineage(mapped)
|
|
146
|
+
sessions = pool()
|
|
147
|
+
checkout = await sessions.checkout(config, mapped, chain)
|
|
148
|
+
session = checkout.session
|
|
149
|
+
ok = False
|
|
150
|
+
try:
|
|
151
|
+
# `_process_streamed_response` peeks the first event before reading.
|
|
152
|
+
events = PeekableAsyncStream(_Events(session.response()))
|
|
153
|
+
stream = await self._process_streamed_response(events, parameters, settings)
|
|
154
|
+
yield stream
|
|
155
|
+
# Anything short of the whole message leaves the process mid-turn.
|
|
156
|
+
# A whole one is a fork point even if the process has since died.
|
|
157
|
+
if session.complete and session.stray_call:
|
|
158
|
+
# Resuming any of this history would show the model its own
|
|
159
|
+
# refused calls again: the next request replays it instead.
|
|
160
|
+
sessions.index.drop(chain)
|
|
161
|
+
elif session.complete and settings.get(KEEP_WARM_SETTING, True):
|
|
162
|
+
_, answer = await self._map_message([stream.get()], parameters, settings)
|
|
163
|
+
answer = normalize(answer)
|
|
164
|
+
if len(answer) == 1 and answer[0]["role"] == "assistant":
|
|
165
|
+
sessions.record(session, _digest(chain[-1], answer[0]))
|
|
166
|
+
ok = not session.dead
|
|
167
|
+
finally:
|
|
168
|
+
sessions.release(session, ok=ok)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def claude_model(model: str) -> ClaudeModel:
|
|
172
|
+
from pcode.models import claude_sdk_installed
|
|
173
|
+
|
|
174
|
+
name = model.removeprefix(PREFIX)
|
|
175
|
+
if not name.strip():
|
|
176
|
+
raise ValueError("Claude requires a model ID: claude:<model-id>")
|
|
177
|
+
if not claude_sdk_installed():
|
|
178
|
+
raise ClaudeSDKMissing(MISSING_SDK)
|
|
179
|
+
return ClaudeModel(name, provider=ClaudeProvider())
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The persisted index of fork points that lets a restarted pcode resume warm."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import NamedTemporaryFile
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
INDEX_LIMIT = 4000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ForkPoint:
|
|
17
|
+
"""Where one assistant message pcode received lives in a CLI transcript."""
|
|
18
|
+
|
|
19
|
+
session_id: str
|
|
20
|
+
uuid: str
|
|
21
|
+
cwd: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def index_path() -> Path:
|
|
25
|
+
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
|
|
26
|
+
return state / "pcode" / "claude-sessions.jsonl"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResumeIndex:
|
|
30
|
+
"""History hash -> fork point, persisted so a restarted pcode resumes warm.
|
|
31
|
+
|
|
32
|
+
Append-only lines shared by every pcode process; losing an entry only
|
|
33
|
+
costs a replay, so concurrent writers need no lock beyond `O_APPEND`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
37
|
+
self.path = path
|
|
38
|
+
self._loaded: dict[str, ForkPoint] | None = None
|
|
39
|
+
|
|
40
|
+
def _file(self) -> Path:
|
|
41
|
+
return self.path or index_path()
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def _entries(self) -> dict[str, ForkPoint]:
|
|
45
|
+
# Read once per process, synchronously: at most INDEX_LIMIT short lines,
|
|
46
|
+
# and no await in between means no second reader can interleave.
|
|
47
|
+
if self._loaded is None:
|
|
48
|
+
self._loaded = self._load()
|
|
49
|
+
return self._loaded
|
|
50
|
+
|
|
51
|
+
def _load(self) -> dict[str, ForkPoint]:
|
|
52
|
+
entries: dict[str, ForkPoint] = {}
|
|
53
|
+
try:
|
|
54
|
+
lines = self._file().read_text().splitlines()
|
|
55
|
+
except OSError:
|
|
56
|
+
return entries
|
|
57
|
+
for line in lines:
|
|
58
|
+
try:
|
|
59
|
+
row = json.loads(line)
|
|
60
|
+
if row.get("dropped"):
|
|
61
|
+
entries.pop(row["key"], None)
|
|
62
|
+
continue
|
|
63
|
+
entries[row["key"]] = ForkPoint(row["session"], row["uuid"], row["cwd"])
|
|
64
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
65
|
+
continue
|
|
66
|
+
if len(lines) > INDEX_LIMIT:
|
|
67
|
+
kept = list(entries.items())[-INDEX_LIMIT // 2 :]
|
|
68
|
+
entries = dict(kept)
|
|
69
|
+
temporary = None
|
|
70
|
+
try:
|
|
71
|
+
with NamedTemporaryFile(
|
|
72
|
+
"w", dir=self._file().parent, suffix=".tmp", delete=False
|
|
73
|
+
) as file:
|
|
74
|
+
temporary = Path(file.name)
|
|
75
|
+
file.write("".join(self._line(k, p) for k, p in kept))
|
|
76
|
+
temporary.replace(self._file())
|
|
77
|
+
except OSError:
|
|
78
|
+
if temporary is not None:
|
|
79
|
+
temporary.unlink(missing_ok=True)
|
|
80
|
+
return entries
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _line(key: str, point: ForkPoint) -> str:
|
|
84
|
+
row = {"key": key, "session": point.session_id, "uuid": point.uuid, "cwd": point.cwd}
|
|
85
|
+
return json.dumps(row) + "\n"
|
|
86
|
+
|
|
87
|
+
def get(self, key: str) -> ForkPoint | None:
|
|
88
|
+
return self._entries.get(key)
|
|
89
|
+
|
|
90
|
+
def forget(self, session_id: str) -> None:
|
|
91
|
+
"""Stop offering a transcript that could not be resumed, for this process."""
|
|
92
|
+
for key in [k for k, p in self._entries.items() if p.session_id == session_id]:
|
|
93
|
+
del self._entries[key]
|
|
94
|
+
|
|
95
|
+
def drop(self, keys: list[str]) -> None:
|
|
96
|
+
"""Never fork these histories again, in any pcode process."""
|
|
97
|
+
dropped = [key for key in keys if self._entries.pop(key, None) is not None]
|
|
98
|
+
self._append("".join(json.dumps({"key": key, "dropped": True}) + "\n" for key in dropped))
|
|
99
|
+
|
|
100
|
+
def add(self, key: str, point: ForkPoint) -> None:
|
|
101
|
+
if self._entries.get(key) == point:
|
|
102
|
+
return
|
|
103
|
+
self._entries[key] = point
|
|
104
|
+
self._append(self._line(key, point))
|
|
105
|
+
|
|
106
|
+
def _append(self, lines: str) -> None:
|
|
107
|
+
if not lines:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
self._file().parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
with self._file().open("a") as file:
|
|
112
|
+
file.write(lines)
|
|
113
|
+
except OSError:
|
|
114
|
+
logger.debug("could not update the Claude fork index", exc_info=True)
|