pcode 0.1.4__tar.gz → 0.1.5__tar.gz
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-0.1.4 → pcode-0.1.5}/PKG-INFO +1 -1
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside_ui.py +15 -5
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/messages.py +5 -6
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/web.py +52 -17
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/config_ui.py +1 -1
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/controller.py +33 -2
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_transcript.py +76 -9
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_ui.py +3 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/ext.py +3 -1
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extension_guide.md +2 -2
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/plan_mode.py +35 -15
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/web_research.py +30 -6
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/inspector_ui.py +9 -3
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/jobs_ui.py +7 -1
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/popup_ui.py +70 -15
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/preferences.py +10 -4
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/session_ui.py +15 -2
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stream_events.py +3 -2
- pcode-0.1.5/src/pcode/text_selection.py +365 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_display.py +55 -16
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tree_ui.py +7 -1
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/worker_ui.py +9 -1
- {pcode-0.1.4 → pcode-0.1.5}/.gitignore +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/LICENSE +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/README.md +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/pyproject.toml +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/agent.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/app.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/appearance.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/auth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/block.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/browser.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/errors.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/model.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/session.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/commands.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/compaction.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/completion.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/config.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/conversation_tree.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/copy_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/delegation.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/delta.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/diagnostics.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edit_preview.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/edits.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/error_report.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/file_refs.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/filesystem.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/frame.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/gateway.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/git_diff.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/inspection.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/jobs.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/keymap.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/links.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/live.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/logins.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_reminders.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/models.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/native_results.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/paste.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/planning.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/profiling.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/prompt_keys.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/retries.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/rpc.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/runtime.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/sessions.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/skills.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stall_log.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/steering.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/syntax.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/theme.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_output_limits.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/tool_panel.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/turn.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/ui.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/update_check.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/usage.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workers.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workspace.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/workspace_filesystem.py +0 -0
- {pcode-0.1.4 → pcode-0.1.5}/src/pcode/worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
|
|
5
5
|
Project-URL: Homepage, https://github.com/cruxwell/pcode
|
|
6
6
|
Project-URL: Documentation, https://cruxwell.github.io/pcode/
|
|
@@ -51,6 +51,7 @@ from pcode.popup_ui import (
|
|
|
51
51
|
from pcode.prefix_keys import PrefixKeys
|
|
52
52
|
from pcode.session_ui import literal
|
|
53
53
|
from pcode.task_prompt import TaskPrompt
|
|
54
|
+
from pcode.text_selection import copy_drags
|
|
54
55
|
from pcode.tool_display import plain
|
|
55
56
|
from pcode.ui import StatusLine, status_parts, status_spinner
|
|
56
57
|
|
|
@@ -164,15 +165,22 @@ class AsideBrowser:
|
|
|
164
165
|
self._refreshing = False
|
|
165
166
|
# Enter on the list reads the selected thread full width; Esc brings
|
|
166
167
|
# the list back. A viewer opened on a lone thread starts reading, so
|
|
167
|
-
# one that arrives meanwhile does not pull the list in beside it.
|
|
168
|
-
|
|
168
|
+
# one that arrives meanwhile does not pull the list in beside it. One
|
|
169
|
+
# opened to type in (`edit`) starts reading the newest thread too, with
|
|
170
|
+
# the cursor in its editor; Esc shows the list.
|
|
171
|
+
self.reading = len(self.threads) <= 1 or self.edit
|
|
169
172
|
self._ids: list[str] = []
|
|
170
173
|
self.notice = ""
|
|
171
174
|
# The copy or link picker over the viewer, while one is open.
|
|
172
175
|
self.picker: CopyPicker | LinkPicker | None = None
|
|
173
176
|
self.list = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
|
|
177
|
+
copy_drags(self.list, lambda text: setattr(self, "notice", text))
|
|
174
178
|
self.list.window.cursorline = Always()
|
|
175
|
-
self.detail = RichPane(
|
|
179
|
+
self.detail = RichPane(
|
|
180
|
+
theme=rich_theme,
|
|
181
|
+
color_system=color_system,
|
|
182
|
+
notify=lambda text: setattr(self, "notice", text),
|
|
183
|
+
)
|
|
176
184
|
self.list.buffer.on_cursor_position_changed += lambda _: self.select()
|
|
177
185
|
self.prefix_keys = shortcuts = PrefixKeys(key_prefix)
|
|
178
186
|
shortcuts.set_help(self.help)
|
|
@@ -243,7 +251,7 @@ class AsideBrowser:
|
|
|
243
251
|
submit=lambda text: self.finish(Bridge(thread, "summary", text)),
|
|
244
252
|
)
|
|
245
253
|
|
|
246
|
-
@shortcuts.add("t", "Merge
|
|
254
|
+
@shortcuts.add("t", "Merge into conversation", filter=idle)
|
|
247
255
|
def merge(event):
|
|
248
256
|
if self.bridgeable():
|
|
249
257
|
self.finish(Bridge(self.selected, "merge"))
|
|
@@ -447,7 +455,9 @@ class AsideBrowser:
|
|
|
447
455
|
lambda focus: bridge("summary", focus),
|
|
448
456
|
argument="[focus]",
|
|
449
457
|
),
|
|
450
|
-
PopupCommand(
|
|
458
|
+
PopupCommand(
|
|
459
|
+
"/merge", "Merge into the conversation", lambda _: bridge("merge", "")
|
|
460
|
+
),
|
|
451
461
|
]
|
|
452
462
|
commands.append(PopupCommand("/stop", "Stop running answers", lambda _: self.stop()))
|
|
453
463
|
return commands
|
|
@@ -106,15 +106,14 @@ def _render_block(block: dict) -> str:
|
|
|
106
106
|
def _web_result_text(content: Any) -> str:
|
|
107
107
|
"""A web search's hits or a fetched page, as `web.result_block` shaped them."""
|
|
108
108
|
if isinstance(content, list):
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
109
|
+
hits = [item for item in content if isinstance(item, dict)]
|
|
110
|
+
lines = [f"{h.get('title') or h['url']}: {h['url']}" for h in hits if h.get("url")]
|
|
111
|
+
summary = next((hit["summary"] for hit in hits if hit.get("summary")), "")
|
|
112
|
+
return "\n\n".join(filter(None, ("\n".join(lines), summary)))
|
|
114
113
|
if not isinstance(content, dict):
|
|
115
114
|
return ""
|
|
116
115
|
if "error_code" in content:
|
|
117
|
-
return f"(error: {content['error_code']})"
|
|
116
|
+
return f"(error: {content.get('message') or content['error_code']})"
|
|
118
117
|
document = content.get("content")
|
|
119
118
|
source = document.get("source") if isinstance(document, dict) else None
|
|
120
119
|
return str(source.get("data", "")) if isinstance(source, dict) else ""
|
|
@@ -8,10 +8,20 @@ The blocks are pcode's record only; the model reads the CLI's own transcript.
|
|
|
8
8
|
They are tagged with the `claude` provider, so no other model is ever sent
|
|
9
9
|
them, and they are used just for display and for a fresh replay
|
|
10
10
|
(`messages.replay`).
|
|
11
|
+
|
|
12
|
+
What the CLI returns has no slot in Anthropic's shapes, so it rides in extra
|
|
13
|
+
fields, which Pydantic AI keeps: a search's summary on its first hit
|
|
14
|
+
(`summary`), and a failure's full text beside its code (`message`). Pydantic
|
|
15
|
+
AI also serializes these blocks against the SDK types, so `error_code` must
|
|
16
|
+
stay one of Anthropic's codes or every result logs a serializer warning.
|
|
11
17
|
"""
|
|
12
18
|
|
|
13
19
|
import json
|
|
20
|
+
import logging
|
|
14
21
|
import re
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
15
25
|
|
|
16
26
|
# CLI tool name -> Anthropic server tool name (also the native tool's `kind`).
|
|
17
27
|
CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
|
|
@@ -19,6 +29,9 @@ CLI_WEB_TOOLS = {"WebSearch": "web_search", "WebFetch": "web_fetch"}
|
|
|
19
29
|
# The CLI's search result opens with its query, then this JSON list of hits,
|
|
20
30
|
# then a summary its own sub-request wrote.
|
|
21
31
|
_LINKS = re.compile(r"^Links: (\[.*\])$", re.MULTILINE)
|
|
32
|
+
_QUERY = re.compile(r"\AWeb search results for query: .*$", re.MULTILINE)
|
|
33
|
+
# The CLI's closing instruction to its model, not part of the summary.
|
|
34
|
+
_REMINDER = re.compile(r"\n*REMINDER: You MUST include the sources.*\Z", re.DOTALL)
|
|
22
35
|
|
|
23
36
|
|
|
24
37
|
def cli_tool_names(native_tools) -> tuple[str, ...]:
|
|
@@ -40,24 +53,44 @@ def _text(content: object) -> str:
|
|
|
40
53
|
return ""
|
|
41
54
|
|
|
42
55
|
|
|
56
|
+
def _hit(title: str, url: str) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"type": "web_search_result",
|
|
59
|
+
"title": title,
|
|
60
|
+
"url": url,
|
|
61
|
+
# Pydantic AI's result model requires both; the CLI has neither.
|
|
62
|
+
"encrypted_content": "",
|
|
63
|
+
"page_age": None,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
43
67
|
def _hits(text: str) -> list[dict]:
|
|
68
|
+
"""The hits in the CLI's search text, the first carrying its summary.
|
|
69
|
+
|
|
70
|
+
Text with no parseable `Links:` line means the CLI changed its format (an
|
|
71
|
+
empty search still prints `Links: []`), so that is logged rather than
|
|
72
|
+
shown as a quiet "0 results"; the text is kept on a URL-less hit.
|
|
73
|
+
"""
|
|
44
74
|
match = _LINKS.search(text)
|
|
45
75
|
try:
|
|
46
|
-
links = json.loads(match.group(1)) if match else
|
|
76
|
+
links = json.loads(match.group(1)) if match else None
|
|
47
77
|
except ValueError:
|
|
48
|
-
links =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for link in links
|
|
78
|
+
links = None
|
|
79
|
+
if links is None:
|
|
80
|
+
logger.warning("Claude Code's WebSearch result has no parseable Links line: %.200r", text)
|
|
81
|
+
summary = _QUERY.sub("", text)
|
|
82
|
+
else:
|
|
83
|
+
summary = text[match.end() :] if match else ""
|
|
84
|
+
summary = _REMINDER.sub("", summary).strip()
|
|
85
|
+
hits = [
|
|
86
|
+
_hit(str(link.get("title") or ""), str(link["url"]))
|
|
87
|
+
for link in links or []
|
|
59
88
|
if isinstance(link, dict) and link.get("url")
|
|
60
89
|
]
|
|
90
|
+
if summary:
|
|
91
|
+
hits = hits or [_hit("", "")]
|
|
92
|
+
hits[0]["summary"] = summary
|
|
93
|
+
return hits
|
|
61
94
|
|
|
62
95
|
|
|
63
96
|
def result_block(name: str, tool_use_id: str, args: dict, content: object, is_error: bool) -> dict:
|
|
@@ -65,17 +98,19 @@ def result_block(name: str, tool_use_id: str, args: dict, content: object, is_er
|
|
|
65
98
|
server = CLI_WEB_TOOLS[name]
|
|
66
99
|
text = _text(content)
|
|
67
100
|
if is_error:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
body: object = {
|
|
102
|
+
"type": f"{server}_tool_result_error",
|
|
103
|
+
"error_code": "unavailable",
|
|
104
|
+
"message": text.strip(),
|
|
105
|
+
}
|
|
72
106
|
elif server == "web_search":
|
|
73
107
|
body = _hits(text)
|
|
74
108
|
else:
|
|
75
109
|
body = {
|
|
76
110
|
"type": "web_fetch_result",
|
|
77
111
|
"url": str(args.get("url") or ""),
|
|
78
|
-
|
|
112
|
+
# When the CLI handed the answer over, which is when it fetched.
|
|
113
|
+
"retrieved_at": datetime.now(timezone.utc).isoformat(),
|
|
79
114
|
"content": {
|
|
80
115
|
"type": "document",
|
|
81
116
|
"source": {"type": "text", "media_type": "text/plain", "data": text},
|
|
@@ -516,6 +516,10 @@ class SessionController:
|
|
|
516
516
|
# under a system row, a skill's prompt, an extension reload, a sign-in.
|
|
517
517
|
self.job_requested: tuple[str, str, Callable[[], list[str]]] | None = None
|
|
518
518
|
self.skill_requested: str | None = None
|
|
519
|
+
# An extension command's prompt waits for its own turn: steered into a
|
|
520
|
+
# running one, it would miss what the command set up (plan mode's
|
|
521
|
+
# reminder only joins a turn's opening request).
|
|
522
|
+
self.skill_queued = False
|
|
519
523
|
self.skill_mcp_requested: tuple[str, tuple[str, ...]] | None = None
|
|
520
524
|
self.reload_requested = False
|
|
521
525
|
self.reload_pending = False # Asked for mid-turn; `consume` runs it between turns.
|
|
@@ -1204,9 +1208,11 @@ class SessionController:
|
|
|
1204
1208
|
# for it on mcp_idle.
|
|
1205
1209
|
self.start_skill_mcp(skill, names)
|
|
1206
1210
|
prompt, self.skill_requested = self.skill_requested, None
|
|
1211
|
+
queued, self.skill_queued = self.skill_queued, False
|
|
1207
1212
|
# Queued like a typed message so send mode, steering, and
|
|
1208
1213
|
# cancellation keep their usual meaning.
|
|
1209
|
-
|
|
1214
|
+
mode = "queue" if queued else load_preferences().get("send_mode", "steering")
|
|
1215
|
+
self.prompts.put(prompt, mode)
|
|
1210
1216
|
self.activity.busy = True
|
|
1211
1217
|
if self.mcp_defaults_requested:
|
|
1212
1218
|
self.start_mcp_defaults()
|
|
@@ -2254,10 +2260,35 @@ class SessionController:
|
|
|
2254
2260
|
f"Extension {extension.name}: {', '.join(taken)} already exists; skipped."
|
|
2255
2261
|
)
|
|
2256
2262
|
continue
|
|
2257
|
-
self.registry.register(command)
|
|
2263
|
+
self.registry.register(replace(command, handler=self.prompting(command)))
|
|
2258
2264
|
self.extension_command_names.append(command.name)
|
|
2259
2265
|
self.view.commands_changed()
|
|
2260
2266
|
|
|
2267
|
+
def prompting(self, command):
|
|
2268
|
+
"""Send the text an extension command returns as a prompt, like a skill.
|
|
2269
|
+
|
|
2270
|
+
A sync handler stays sync, so callers that never await still run it whole.
|
|
2271
|
+
"""
|
|
2272
|
+
|
|
2273
|
+
def request(result) -> None:
|
|
2274
|
+
if isinstance(result, str) and result.strip():
|
|
2275
|
+
if not self.model:
|
|
2276
|
+
raise ValueError(f"{command.name} requires a live model session.")
|
|
2277
|
+
self.skill_requested = result
|
|
2278
|
+
self.skill_queued = True
|
|
2279
|
+
|
|
2280
|
+
def run(argument: str):
|
|
2281
|
+
result = command.handler(argument)
|
|
2282
|
+
if not inspect.isawaitable(result):
|
|
2283
|
+
return request(result)
|
|
2284
|
+
|
|
2285
|
+
async def finish() -> None:
|
|
2286
|
+
request(await result)
|
|
2287
|
+
|
|
2288
|
+
return finish()
|
|
2289
|
+
|
|
2290
|
+
return run
|
|
2291
|
+
|
|
2261
2292
|
def extension_arguments(self) -> tuple[str, ...]:
|
|
2262
2293
|
"""Complete `on`/`off` against the extensions this workspace discovered."""
|
|
2263
2294
|
found = self.extensions.extensions if self.extensions else ()
|
|
@@ -5,10 +5,12 @@ from collections.abc import Callable
|
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from functools import cache
|
|
7
7
|
|
|
8
|
+
from markdown_it import MarkdownIt
|
|
8
9
|
from prompt_toolkit.formatted_text import ANSI, to_formatted_text
|
|
9
10
|
from prompt_toolkit.lexers import Lexer
|
|
10
11
|
from pygments.token import Generic, Token
|
|
11
12
|
from rich.console import Console, ConsoleOptions, RenderResult
|
|
13
|
+
from rich.markdown import Markdown
|
|
12
14
|
from rich.segment import Segment
|
|
13
15
|
from rich.style import Style
|
|
14
16
|
from rich.syntax import Syntax
|
|
@@ -19,6 +21,7 @@ from pcode.delta import Delta, preview_patch
|
|
|
19
21
|
from pcode.edits import edit_text, patch_text
|
|
20
22
|
from pcode.runtime import EditCompleted
|
|
21
23
|
from pcode.syntax import transparent_theme
|
|
24
|
+
from pcode.terminal_text import safe_text
|
|
22
25
|
from pcode.tool_display import command_text
|
|
23
26
|
|
|
24
27
|
|
|
@@ -40,29 +43,93 @@ class EditTranscript:
|
|
|
40
43
|
)
|
|
41
44
|
)
|
|
42
45
|
if change.patch:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
if (source := created_markdown(change)) is not None:
|
|
47
|
+
# Link targets print beside their text: a file's URLs are part
|
|
48
|
+
# of what was written, not something to hide in a hyperlink.
|
|
49
|
+
patch = Markdown(safe_text(source), code_theme=self.code_theme, hyperlinks=False)
|
|
50
|
+
omitted = "… rest of file omitted"
|
|
51
|
+
else:
|
|
52
|
+
omitted = "… additional diff rows omitted"
|
|
53
|
+
text = patch_text(change.patch, dedent=self.dedent)
|
|
54
|
+
lines = self.delta.render(text, options.max_width) if self.delta else None
|
|
55
|
+
patch = (
|
|
56
|
+
Text("\n").join(lines)
|
|
57
|
+
if lines is not None
|
|
58
|
+
else Syntax(
|
|
59
|
+
text, "diff", theme=transparent_theme(self.code_theme), word_wrap=True
|
|
60
|
+
)
|
|
61
|
+
)
|
|
50
62
|
rows = console.render_lines(patch, options, pad=False)
|
|
51
63
|
for row in rows[: self.max_rows]:
|
|
52
64
|
yield from row
|
|
53
65
|
yield Segment.line()
|
|
54
66
|
if len(rows) > self.max_rows or change.truncated:
|
|
55
|
-
yield Text(
|
|
67
|
+
yield Text(omitted, style="pcode.muted")
|
|
56
68
|
if change.omitted:
|
|
57
69
|
yield Text(f"Diff unavailable: {edit_text(change.omitted)}", style="pcode.muted")
|
|
58
70
|
yield block_rule()
|
|
59
71
|
|
|
60
72
|
|
|
73
|
+
MARKDOWN_SUFFIXES = (".md", ".markdown")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def created_markdown(change: EditCompleted) -> str | None:
|
|
77
|
+
"""A new Markdown file's redacted source, to show rendered rather than as a diff.
|
|
78
|
+
|
|
79
|
+
Every line of a created file is an addition, so the `+` column says
|
|
80
|
+
nothing and the raw source buries the document. The patch is the only
|
|
81
|
+
copy of the content the transcript keeps; each `+` line after the hunk
|
|
82
|
+
header is a line of the file; a truncated patch's last one may be cut
|
|
83
|
+
short, so it is dropped. None keeps the diff, including for a document
|
|
84
|
+
whose rendering would hide some of what was written (see `_hides_text`).
|
|
85
|
+
"""
|
|
86
|
+
if (
|
|
87
|
+
change.operation != "created"
|
|
88
|
+
or change.removed
|
|
89
|
+
or not change.patch
|
|
90
|
+
or not change.path.lower().endswith(MARKDOWN_SUFFIXES)
|
|
91
|
+
):
|
|
92
|
+
return None
|
|
93
|
+
lines = patch_text(change.patch, dedent=False).split("\n")
|
|
94
|
+
start = next((i for i, line in enumerate(lines) if line.startswith("@@")), len(lines))
|
|
95
|
+
body = [line[1:] for line in lines[start + 1 :] if line.startswith("+")]
|
|
96
|
+
if change.truncated:
|
|
97
|
+
body = body[:-1]
|
|
98
|
+
source = "\n".join(body)
|
|
99
|
+
return None if _hides_text(source) else source
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_MARKDOWN = MarkdownIt("commonmark").enable(["strikethrough", "table"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _hides_text(source: str) -> bool:
|
|
106
|
+
"""Whether rendering drops written text: HTML, comments, or link definitions.
|
|
107
|
+
|
|
108
|
+
Rich skips HTML blocks and inline tags and never shows a reference
|
|
109
|
+
definition, so an instruction in an `<!-- -->` comment would vanish from
|
|
110
|
+
the record of the write. Such a file shows as its diff instead.
|
|
111
|
+
"""
|
|
112
|
+
env: dict = {}
|
|
113
|
+
tokens = _MARKDOWN.parse(source, env)
|
|
114
|
+
if env.get("references"):
|
|
115
|
+
return True
|
|
116
|
+
return any(
|
|
117
|
+
token.type == "html_block"
|
|
118
|
+
or any(child.type == "html_inline" for child in token.children or ())
|
|
119
|
+
for token in tokens
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
61
123
|
def prefetch_edits(objects, width: int) -> None:
|
|
62
124
|
"""Render every delta edit block in `objects` through one delta process each."""
|
|
63
125
|
patches: dict[Delta, list[str]] = {}
|
|
64
126
|
for obj in objects:
|
|
65
|
-
if
|
|
127
|
+
if (
|
|
128
|
+
isinstance(obj, EditTranscript)
|
|
129
|
+
and obj.delta is not None
|
|
130
|
+
and obj.change.patch
|
|
131
|
+
and created_markdown(obj.change) is None
|
|
132
|
+
):
|
|
66
133
|
text = patch_text(obj.change.patch, dedent=obj.dedent)
|
|
67
134
|
patches.setdefault(obj.delta, []).append(text)
|
|
68
135
|
for delta, texts in patches.items():
|
|
@@ -44,6 +44,7 @@ from pcode.popup_ui import (
|
|
|
44
44
|
)
|
|
45
45
|
from pcode.prefix_keys import PrefixKeys
|
|
46
46
|
from pcode.runtime import EditCompleted
|
|
47
|
+
from pcode.text_selection import copy_drags
|
|
47
48
|
|
|
48
49
|
NO_MATCH = "No matching files."
|
|
49
50
|
PROMPTS = {"paths": "Search paths: ", "diffs": "Search diff lines: "}
|
|
@@ -398,9 +399,11 @@ class DiffBrowser:
|
|
|
398
399
|
self.query = TextArea(height=1, prompt=lambda: PROMPTS[self.scope], multiline=False)
|
|
399
400
|
self.note = TextArea(height=1, prompt=self.note_prompt, multiline=False)
|
|
400
401
|
self.files = TextArea(read_only=True, wrap_lines=False, scrollbar=True)
|
|
402
|
+
copy_drags(self.files, lambda text: setattr(self, "notice", text))
|
|
401
403
|
self.files.window.cursorline = Always()
|
|
402
404
|
self.lexer = DiffLexer(code_theme)
|
|
403
405
|
self.diff = TextArea(read_only=True, wrap_lines=False, scrollbar=True, lexer=self.lexer)
|
|
406
|
+
copy_drags(self.diff, lambda text: setattr(self, "notice", text))
|
|
404
407
|
self.diff.window.cursorline = Always()
|
|
405
408
|
self.query.buffer.on_text_changed += lambda _: self.refresh(first_match=True)
|
|
406
409
|
self.files.buffer.on_cursor_position_changed += lambda _: self.follow_files()
|
|
@@ -245,7 +245,7 @@ class ExtensionAPI:
|
|
|
245
245
|
self,
|
|
246
246
|
name: str,
|
|
247
247
|
description: str,
|
|
248
|
-
handler: Callable[[str], None],
|
|
248
|
+
handler: Callable[[str], str | None],
|
|
249
249
|
*,
|
|
250
250
|
arguments: tuple[str, ...] = (),
|
|
251
251
|
aliases: tuple[str, ...] = (),
|
|
@@ -255,6 +255,8 @@ class ExtensionAPI:
|
|
|
255
255
|
) -> None:
|
|
256
256
|
"""Add a slash command. `handler(argument)` runs on the terminal's event loop.
|
|
257
257
|
|
|
258
|
+
A handler that returns text sends it to the model as a prompt.
|
|
259
|
+
|
|
258
260
|
Without `arguments`, any text is accepted; with them, only those values
|
|
259
261
|
unless `free_arguments=True` keeps them as completions for free text.
|
|
260
262
|
`argument_descriptions` labels each in the completion menu.
|
|
@@ -153,8 +153,8 @@ Pass `complete_paths=True` to Tab-complete the argument (after any leading
|
|
|
153
153
|
|
|
154
154
|
Handlers run on the terminal's event loop, so keep them quick. Raise
|
|
155
155
|
`ValueError("message")` to show a usage error. A name pcode already uses is
|
|
156
|
-
reported and skipped.
|
|
157
|
-
|
|
156
|
+
reported and skipped. A handler that returns a string sends it to the model as
|
|
157
|
+
a prompt of its own turn, after any running one; return `None` to send nothing.
|
|
158
158
|
|
|
159
159
|
### Sub-agents
|
|
160
160
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Plan mode: think a change through with the user before building it.
|
|
2
2
|
|
|
3
|
-
`/plan [
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
`/plan [request]` turns it on and sends the request, if any, as the first
|
|
4
|
+
prompt (its opening words also name the plan file). While it is on, each user
|
|
5
|
+
turn carries a reminder to investigate, keep a plan in
|
|
6
|
+
`.pcode/plans/<slug>.md`, and hold off on implementing until the user approves.
|
|
7
|
+
Approval is the model's call: when the user says "go ahead" (in whatever
|
|
8
|
+
words), or answers the last open questions without raising new ones, it calls
|
|
9
|
+
`exit_plan_mode` and starts building in the same turn.
|
|
10
|
+
`/plan off` is the manual way out.
|
|
8
11
|
|
|
9
12
|
The model is trusted rather than fenced in: nothing blocks edits, the reminder
|
|
10
13
|
just says what the user wants. The active plan lives in a marker file beside
|
|
@@ -30,9 +33,13 @@ Plan mode is on. The user wants to agree on a plan before any implementation.
|
|
|
30
33
|
- Do not start implementing. A small throwaway experiment to answer a question
|
|
31
34
|
is fine; making the change itself is not.
|
|
32
35
|
- End each turn with the open questions or a clear "ready to proceed?".
|
|
33
|
-
- When the user approves
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
- When the user approves, call `exit_plan_mode`, then implement the plan in
|
|
37
|
+
that same turn. Approval can be explicit ("go ahead", "lgtm", "ship it") or
|
|
38
|
+
implicit: if the user's reply answers every remaining open question and
|
|
39
|
+
raises no new concerns, that is the go-ahead; don't ask again for a
|
|
40
|
+
separate confirmation. Approval with changes ("yes, but skip step 3")
|
|
41
|
+
counts too: fold the change in, then exit. Stay in plan mode only while
|
|
42
|
+
something is genuinely unresolved or the user asks to keep planning.
|
|
36
43
|
</plan-mode>"""
|
|
37
44
|
|
|
38
45
|
|
|
@@ -43,7 +50,15 @@ def is_worker(ctx) -> bool:
|
|
|
43
50
|
|
|
44
51
|
def slugify(text: str) -> str:
|
|
45
52
|
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
46
|
-
|
|
53
|
+
if len(slug) > 60: # Cut at a word boundary when there is one.
|
|
54
|
+
cut = slug[:61]
|
|
55
|
+
slug = cut.rsplit("-", 1)[0] if "-" in cut else slug[:60]
|
|
56
|
+
return slug or "plan"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def topic_slug(request: str) -> str:
|
|
60
|
+
"""Name the plan after the request's opening words."""
|
|
61
|
+
return slugify(" ".join(request.split("\n", 1)[0].split()[:8]))
|
|
47
62
|
|
|
48
63
|
|
|
49
64
|
def setup(pcode) -> None:
|
|
@@ -74,7 +89,7 @@ def setup(pcode) -> None:
|
|
|
74
89
|
ignore.write_text("*\n")
|
|
75
90
|
marker.write_text(slug + "\n")
|
|
76
91
|
|
|
77
|
-
def plan_command(argument: str) -> None:
|
|
92
|
+
def plan_command(argument: str) -> str | None:
|
|
78
93
|
argument = argument.strip()
|
|
79
94
|
current = active()
|
|
80
95
|
if argument == "off":
|
|
@@ -89,21 +104,25 @@ def setup(pcode) -> None:
|
|
|
89
104
|
f"Plan mode on: {relative(current)}" if current else "Plan mode is off."
|
|
90
105
|
)
|
|
91
106
|
return
|
|
92
|
-
slug =
|
|
107
|
+
slug = topic_slug(argument) if argument else (current or latest() or "plan")
|
|
93
108
|
start(slug)
|
|
94
109
|
exists = (pcode.workspace / relative(slug)).exists()
|
|
95
110
|
verb = "Resuming" if exists else "Planning in"
|
|
111
|
+
ask = "Say" if argument else "Describe the change; say"
|
|
96
112
|
pcode.ui.notify(
|
|
97
|
-
f"Plan mode on. {verb} {relative(slug)}.
|
|
98
|
-
"
|
|
113
|
+
f"Plan mode on. {verb} {relative(slug)}. {ask} 'go ahead' "
|
|
114
|
+
"(or just answer its questions) when the plan looks right."
|
|
99
115
|
)
|
|
116
|
+
# The text is the request itself, so it goes to the model now rather
|
|
117
|
+
# than making the user send it again.
|
|
118
|
+
return argument or None
|
|
100
119
|
|
|
101
120
|
pcode.register_command(
|
|
102
121
|
"/plan",
|
|
103
122
|
"Plan before implementing; the model exits when you approve",
|
|
104
123
|
plan_command,
|
|
105
124
|
arguments=("off", "status"),
|
|
106
|
-
free_arguments=True, # anything else
|
|
125
|
+
free_arguments=True, # anything else is the request to plan
|
|
107
126
|
argument_descriptions={
|
|
108
127
|
"off": "Leave plan mode (the plan file is kept)",
|
|
109
128
|
"status": "Show whether plan mode is on and its file",
|
|
@@ -114,7 +133,8 @@ def setup(pcode) -> None:
|
|
|
114
133
|
def exit_plan_mode() -> str:
|
|
115
134
|
"""Leave plan mode once the user has approved the plan, then implement it.
|
|
116
135
|
|
|
117
|
-
Call this only when plan mode is on and the user has
|
|
136
|
+
Call this only when plan mode is on and the user has approved, either by
|
|
137
|
+
saying so or by answering every open question without raising new ones.
|
|
118
138
|
"""
|
|
119
139
|
current = active()
|
|
120
140
|
if current is None:
|
|
@@ -9,8 +9,10 @@ plain HTTP fetch otherwise.
|
|
|
9
9
|
|
|
10
10
|
The `web_search` preference picks the policy: `auto` (native when supported),
|
|
11
11
|
`local` (never advertise native tools), or `off`. Under `auto`, `web_fetch`
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
picks the fetch: `auto` uses a native fetch only where it returns the page
|
|
13
|
+
text, so `claude:` models read pages through `get_page` (the CLI's WebFetch
|
|
14
|
+
answers with a small model's summary instead); `native` opts into that summary
|
|
15
|
+
anyway, and `local` always fetches locally. Copy this file to
|
|
14
16
|
`~/.config/pcode/extensions/web_research.py` to replace the defaults; an empty
|
|
15
17
|
`setup` removes web tools entirely.
|
|
16
18
|
"""
|
|
@@ -18,7 +20,14 @@ set to `local` keeps fetching on the local tool, whose page text is verbatim
|
|
|
18
20
|
import os
|
|
19
21
|
|
|
20
22
|
SEARCH_RESULTS = 5
|
|
21
|
-
|
|
23
|
+
# Long enough for most documentation pages. With the default `spill` tool
|
|
24
|
+
# output mode, a page past the threshold is saved to disk and read back in
|
|
25
|
+
# slices, so the cap bounds the fetch, not what lands in context.
|
|
26
|
+
PAGE_CHARS = 100_000
|
|
27
|
+
# Harness's ceiling for Exa page text (`EXA_MAX_PAGE_TEXT_CHARS`, which it takes
|
|
28
|
+
# for Exa's limit; Exa's API reference documents 1,000,000), less the character
|
|
29
|
+
# of headroom it needs to notice a longer page and mark it truncated.
|
|
30
|
+
EXA_PAGE_CHARS = min(PAGE_CHARS, 9_999)
|
|
22
31
|
|
|
23
32
|
INSTRUCTIONS = (
|
|
24
33
|
"You can research the web: search for pages, then fetch the most promising "
|
|
@@ -43,7 +52,7 @@ def _exa_tools():
|
|
|
43
52
|
exa = ExaSearchToolset(
|
|
44
53
|
client=None, # Harness builds the client from EXA_API_KEY; the model never sees it.
|
|
45
54
|
num_results=SEARCH_RESULTS,
|
|
46
|
-
max_text_chars=
|
|
55
|
+
max_text_chars=EXA_PAGE_CHARS,
|
|
47
56
|
include_deep_search=False,
|
|
48
57
|
)
|
|
49
58
|
return Tool(exa.web_search, name="web_search"), Tool(exa.get_page, name="get_page")
|
|
@@ -75,6 +84,22 @@ def _local_tools():
|
|
|
75
84
|
return Tool(web_search, name="web_search"), fetch
|
|
76
85
|
|
|
77
86
|
|
|
87
|
+
def _native_fetch(native: bool):
|
|
88
|
+
"""The `native` argument for page fetching, per the `web_fetch` preference."""
|
|
89
|
+
fetch = _mode("web_fetch")
|
|
90
|
+
if not native or fetch == "local":
|
|
91
|
+
return False
|
|
92
|
+
if fetch == "native":
|
|
93
|
+
return True
|
|
94
|
+
from pydantic_ai.native_tools import WebFetchTool
|
|
95
|
+
|
|
96
|
+
def page_text_only(ctx):
|
|
97
|
+
# Omitting the native tool leaves `get_page` in the request instead.
|
|
98
|
+
return None if ctx.model.system == "claude" else WebFetchTool()
|
|
99
|
+
|
|
100
|
+
return page_text_only
|
|
101
|
+
|
|
102
|
+
|
|
78
103
|
def setup(pcode) -> None:
|
|
79
104
|
mode = _mode()
|
|
80
105
|
if mode == "off":
|
|
@@ -88,6 +113,5 @@ def setup(pcode) -> None:
|
|
|
88
113
|
native = mode == "auto"
|
|
89
114
|
# Ids name the prompt sources in /status; they never reach the model.
|
|
90
115
|
pcode.add_capability(WebSearch(id="web_research", native=native, local=search))
|
|
91
|
-
|
|
92
|
-
pcode.add_capability(WebFetch(id="web_fetch", native=native_fetch, local=fetch))
|
|
116
|
+
pcode.add_capability(WebFetch(id="web_fetch", native=_native_fetch(native), local=fetch))
|
|
93
117
|
pcode.instructions(INSTRUCTIONS)
|