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/completion.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Shell completion scripts generated from the argparse parser.
|
|
2
|
+
|
|
3
|
+
Generating from the live parser (rather than checking in hand-written scripts)
|
|
4
|
+
keeps the completions honest: a new flag shows up without a second edit.
|
|
5
|
+
|
|
6
|
+
Values that only exist at runtime (running session hosts) cannot be baked into
|
|
7
|
+
the script, so an option marked with `complete_with` makes the script call
|
|
8
|
+
back into `pcode __complete KIND`, which prints `value<TAB>description` lines.
|
|
9
|
+
`pcode.cli` answers that before importing the frontend, so a Tab stays quick.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
SHELLS = ("zsh", "fish", "bash")
|
|
20
|
+
|
|
21
|
+
# The hidden first argument the generated scripts call back with.
|
|
22
|
+
COMPLETE_COMMAND = "__complete"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_TITLE_WIDTH = 50
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _hosts() -> list[tuple[str, str]]:
|
|
29
|
+
from pcode.host_protocol import list_hosts
|
|
30
|
+
|
|
31
|
+
candidates = []
|
|
32
|
+
for entry in list_hosts():
|
|
33
|
+
title = " ".join(entry.label().split())
|
|
34
|
+
if len(title) > _TITLE_WIDTH:
|
|
35
|
+
title = title[: _TITLE_WIDTH - 1] + "…"
|
|
36
|
+
candidates.append((entry.id, f"{entry.state} · {Path(entry.workspace).name} · {title}"))
|
|
37
|
+
return candidates
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Kinds of runtime value an option can complete, by the name the scripts pass.
|
|
41
|
+
_DYNAMIC: dict[str, Callable[[], list[tuple[str, str]]]] = {"hosts": _hosts}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def complete_with(action: argparse.Action, kind: str) -> argparse.Action:
|
|
45
|
+
"""Mark `action` as completing `kind` values, looked up when Tab is pressed."""
|
|
46
|
+
if kind not in _DYNAMIC:
|
|
47
|
+
raise ValueError(f"unknown completion kind: {kind}")
|
|
48
|
+
action.complete_kind = kind # type: ignore[attr-defined]
|
|
49
|
+
return action
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _dynamic(action: argparse.Action) -> str | None:
|
|
53
|
+
return getattr(action, "complete_kind", None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def print_candidates(argv: list[str], stream=None) -> int:
|
|
57
|
+
"""`pcode __complete KIND`: one `value<TAB>description` line per candidate."""
|
|
58
|
+
stream = stream or sys.stdout
|
|
59
|
+
if len(argv) != 1 or argv[0] not in _DYNAMIC:
|
|
60
|
+
print(f"usage: pcode {COMPLETE_COMMAND} {{{','.join(_DYNAMIC)}}}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
for value, description in _DYNAMIC[argv[0]]():
|
|
63
|
+
# A tab or newline would split the line the shells read.
|
|
64
|
+
print(f"{value}\t{' '.join(description.split())}", file=stream)
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_INSTALL_HINTS = {
|
|
69
|
+
"zsh": "pcode --completions zsh > ~/.zsh/completions/_pcode # dir must be on $fpath",
|
|
70
|
+
"fish": "pcode --completions fish > ~/.config/fish/completions/pcode.fish",
|
|
71
|
+
"bash": 'eval "$(pcode --completions bash)" # add to ~/.bashrc',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install_hint(shell: str) -> str:
|
|
76
|
+
return _INSTALL_HINTS[shell]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _visible_actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
|
|
80
|
+
return [
|
|
81
|
+
action
|
|
82
|
+
for action in parser._actions
|
|
83
|
+
if action.option_strings and action.help is not argparse.SUPPRESS
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _takes_value(action: argparse.Action) -> bool:
|
|
88
|
+
return action.nargs != 0 and not isinstance(
|
|
89
|
+
action, argparse._StoreTrueAction | argparse._StoreFalseAction | argparse._CountAction
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _optional_value(action: argparse.Action) -> bool:
|
|
94
|
+
return action.nargs == "?"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _choices(action: argparse.Action) -> list[str]:
|
|
98
|
+
return [str(choice) for choice in (action.choices or [])]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _wants_path(action: argparse.Action) -> bool:
|
|
102
|
+
return action.type is Path
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _help(action: argparse.Action) -> str:
|
|
106
|
+
return " ".join((action.help or "").split())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _zsh_quote(text: str) -> str:
|
|
110
|
+
return text.replace("\\", "\\\\").replace("'", "'\\''").replace("[", "\\[").replace("]", "\\]")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _zsh(parser: argparse.ArgumentParser, prog: str) -> str:
|
|
114
|
+
lines = [f"#compdef {prog}", ""]
|
|
115
|
+
kinds = sorted({kind for action in _visible_actions(parser) if (kind := _dynamic(action))})
|
|
116
|
+
for kind in kinds:
|
|
117
|
+
# Helpers are defined at the top level so both the autoloaded and the
|
|
118
|
+
# sourced form of this file have them before `_{prog}` first runs.
|
|
119
|
+
lines += [
|
|
120
|
+
f"_{prog}_{kind}() {{",
|
|
121
|
+
" local -a values",
|
|
122
|
+
f' values=(${{(f)"$({prog} {COMPLETE_COMMAND} {kind} 2>/dev/null)"}})',
|
|
123
|
+
# _describe splits value from description on the first colon.
|
|
124
|
+
" values=(\"${(@)values//$'\\t'/:}\")",
|
|
125
|
+
f" _describe -t {kind} {kind} values",
|
|
126
|
+
"}",
|
|
127
|
+
"",
|
|
128
|
+
]
|
|
129
|
+
lines += [f"_{prog}() {{", " local -a specs", " specs=("]
|
|
130
|
+
for action in _visible_actions(parser):
|
|
131
|
+
# Repeating an option is almost never meaningful here, so tell zsh to
|
|
132
|
+
# drop every spelling of a flag once any of them is on the line.
|
|
133
|
+
exclusion = f"({' '.join(action.option_strings)})" if len(action.option_strings) > 1 else ""
|
|
134
|
+
kind = _dynamic(action)
|
|
135
|
+
for option in action.option_strings:
|
|
136
|
+
name = option
|
|
137
|
+
if _takes_value(action) and _optional_value(action):
|
|
138
|
+
# `=-` marks an option whose value, if any, must be glued on as
|
|
139
|
+
# `--flag=value`, so the next word still completes as a prompt.
|
|
140
|
+
# A runtime value is the reason to type the option at all, so
|
|
141
|
+
# there `=` also completes it as the next word.
|
|
142
|
+
name = f"{option}=" if kind else f"{option}=-"
|
|
143
|
+
spec = f"{exclusion}{name}[{_zsh_quote(_help(action))}]"
|
|
144
|
+
if _takes_value(action):
|
|
145
|
+
metavar = str(action.metavar or action.dest)
|
|
146
|
+
if kind:
|
|
147
|
+
optional = ":" if _optional_value(action) else ""
|
|
148
|
+
spec += f":{optional}{metavar}:_{prog}_{kind}"
|
|
149
|
+
elif choices := _choices(action):
|
|
150
|
+
spec += f":{metavar}:({' '.join(choices)})"
|
|
151
|
+
elif _wants_path(action):
|
|
152
|
+
spec += f":{metavar}:_files"
|
|
153
|
+
else:
|
|
154
|
+
spec += f":{metavar}: "
|
|
155
|
+
lines.append(f" '{spec}'")
|
|
156
|
+
lines += [
|
|
157
|
+
" '*:prompt:_files'",
|
|
158
|
+
" )",
|
|
159
|
+
" _arguments -s -S $specs && return 0",
|
|
160
|
+
"}",
|
|
161
|
+
"",
|
|
162
|
+
# In $fpath the file is autoloaded *as* the completion function, so it
|
|
163
|
+
# must call itself; when sourced from .zshrc it must register instead.
|
|
164
|
+
f'if [ "$funcstack[1]" = "_{prog}" ]; then',
|
|
165
|
+
f' _{prog} "$@"',
|
|
166
|
+
"else",
|
|
167
|
+
f" compdef _{prog} {prog}",
|
|
168
|
+
"fi",
|
|
169
|
+
"",
|
|
170
|
+
]
|
|
171
|
+
return "\n".join(lines)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _fish(parser: argparse.ArgumentParser, prog: str) -> str:
|
|
175
|
+
lines = ["# fish completions for pcode", ""]
|
|
176
|
+
for action in _visible_actions(parser):
|
|
177
|
+
flags = []
|
|
178
|
+
for option in action.option_strings:
|
|
179
|
+
if option.startswith("--"):
|
|
180
|
+
flags += ["-l", option[2:]]
|
|
181
|
+
else:
|
|
182
|
+
flags += ["-s", option[1:]]
|
|
183
|
+
parts = [f"complete -c {prog}", *flags]
|
|
184
|
+
if help_text := _help(action):
|
|
185
|
+
parts += ["-d", _fish_quote(help_text)]
|
|
186
|
+
kind = _dynamic(action)
|
|
187
|
+
if _takes_value(action):
|
|
188
|
+
# -r demands a value; an optional value stays -f so fish still
|
|
189
|
+
# offers the other flags. A runtime value is the reason to type the
|
|
190
|
+
# option at all, so it takes -r anyway (a `-` still lists flags).
|
|
191
|
+
parts.append("-r" if kind or not _optional_value(action) else "")
|
|
192
|
+
if kind:
|
|
193
|
+
parts += ["-f", "-a", _fish_quote(f"({prog} {COMPLETE_COMMAND} {kind})")]
|
|
194
|
+
elif choices := _choices(action):
|
|
195
|
+
parts += ["-f", "-a", _fish_quote(" ".join(choices))]
|
|
196
|
+
elif _wants_path(action):
|
|
197
|
+
parts.append("-F")
|
|
198
|
+
else:
|
|
199
|
+
parts.append("-f")
|
|
200
|
+
else:
|
|
201
|
+
parts.append("-f")
|
|
202
|
+
lines.append(" ".join(part for part in parts if part))
|
|
203
|
+
lines.append("")
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _fish_quote(text: str) -> str:
|
|
208
|
+
return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _bash(parser: argparse.ArgumentParser, prog: str) -> str:
|
|
212
|
+
options = [option for action in _visible_actions(parser) for option in action.option_strings]
|
|
213
|
+
choice_cases = []
|
|
214
|
+
for action in _visible_actions(parser):
|
|
215
|
+
if not _takes_value(action):
|
|
216
|
+
continue
|
|
217
|
+
if kind := _dynamic(action):
|
|
218
|
+
pattern = "|".join(action.option_strings)
|
|
219
|
+
values = f"$({prog} {COMPLETE_COMMAND} {kind} 2>/dev/null | cut -f1)"
|
|
220
|
+
# An optional value leaves a `-` word to the flag list below.
|
|
221
|
+
choice_cases.append(
|
|
222
|
+
f' {pattern})\n if [[ "$cur" != -* ]]; then\n'
|
|
223
|
+
f' COMPREPLY=($(compgen -W "{values}" -- "$cur")); return 0\n'
|
|
224
|
+
" fi ;;"
|
|
225
|
+
)
|
|
226
|
+
elif choices := _choices(action):
|
|
227
|
+
pattern = "|".join(action.option_strings)
|
|
228
|
+
choice_cases.append(
|
|
229
|
+
f" {pattern})\n"
|
|
230
|
+
f' COMPREPLY=($(compgen -W "{" ".join(choices)}" -- "$cur")); return 0 ;;'
|
|
231
|
+
)
|
|
232
|
+
elif _wants_path(action):
|
|
233
|
+
pattern = "|".join(action.option_strings)
|
|
234
|
+
choice_cases.append(
|
|
235
|
+
f' {pattern})\n COMPREPLY=($(compgen -f -- "$cur")); return 0 ;;'
|
|
236
|
+
)
|
|
237
|
+
cases = "\n".join(choice_cases)
|
|
238
|
+
return f"""_{prog}() {{
|
|
239
|
+
local cur prev
|
|
240
|
+
cur="${{COMP_WORDS[COMP_CWORD]}}"
|
|
241
|
+
prev="${{COMP_WORDS[COMP_CWORD-1]}}"
|
|
242
|
+
case "$prev" in
|
|
243
|
+
{cases}
|
|
244
|
+
esac
|
|
245
|
+
if [[ "$cur" == -* ]]; then
|
|
246
|
+
COMPREPLY=($(compgen -W "{" ".join(options)}" -- "$cur"))
|
|
247
|
+
return 0
|
|
248
|
+
fi
|
|
249
|
+
COMPREPLY=($(compgen -f -- "$cur"))
|
|
250
|
+
}}
|
|
251
|
+
complete -F _{prog} {prog}
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def render(parser: argparse.ArgumentParser, shell: str, prog: str = "pcode") -> str:
|
|
256
|
+
"""Return a completion script for `shell` describing `parser`."""
|
|
257
|
+
if shell == "zsh":
|
|
258
|
+
return _zsh(parser, prog)
|
|
259
|
+
if shell == "fish":
|
|
260
|
+
return _fish(parser, prog)
|
|
261
|
+
if shell == "bash":
|
|
262
|
+
return _bash(parser, prog)
|
|
263
|
+
raise ValueError(f"unsupported shell: {shell}")
|
pcode/config.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Defaults commands, shared by the CLI and interactive terminal.
|
|
2
|
+
|
|
3
|
+
Editing defaults deliberately does not mutate a running conversation. In-session
|
|
4
|
+
shortcuts change both the active setting and its saved default; the terminal also
|
|
5
|
+
applies layout-only settings (`attach_tasks`, `tasks_max_height`, `task_style`,
|
|
6
|
+
`tool_glyphs`, `tool_max_lines`, `tool_linger_seconds` and `show_hints`) immediately.
|
|
7
|
+
|
|
8
|
+
`config project ...` edits the workspace's `.pcode/preferences.json`, which is
|
|
9
|
+
layered over the user file at launch (except for `USER_ONLY` keys).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pcode.preferences import (
|
|
17
|
+
MODEL_EFFORTS_KEY,
|
|
18
|
+
SETTINGS,
|
|
19
|
+
USER_ONLY,
|
|
20
|
+
Setting,
|
|
21
|
+
preferences_path,
|
|
22
|
+
project_preferences_path,
|
|
23
|
+
read_preferences,
|
|
24
|
+
update_preferences,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
USAGE = "config [list | diff | path | get KEY | set KEY VALUE | unset KEY | reset | project ...]"
|
|
28
|
+
PROJECT_USAGE = "config project [list | path | set KEY VALUE | unset KEY | reset]"
|
|
29
|
+
|
|
30
|
+
# Resetting clears the recorded /login choice and every per-repository extension
|
|
31
|
+
# grant, so say so rather than leaving the next launch to re-prompt unexplained.
|
|
32
|
+
_RESET_NOTICE = {
|
|
33
|
+
"anthropic_auth": "re-run /login to choose a credential",
|
|
34
|
+
"trusted_projects": "repositories must be trusted again",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _setting(key: str) -> Setting:
|
|
39
|
+
if key not in SETTINGS:
|
|
40
|
+
raise ValueError(f"Unknown setting '{key}'. Available: {', '.join(SETTINGS)}")
|
|
41
|
+
return SETTINGS[key]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _effective(data: dict, key: str) -> str | None:
|
|
45
|
+
setting = SETTINGS[key]
|
|
46
|
+
value = data.get(key)
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
try:
|
|
49
|
+
setting.validate(key, value)
|
|
50
|
+
except ValueError:
|
|
51
|
+
pass
|
|
52
|
+
else:
|
|
53
|
+
return value
|
|
54
|
+
return setting.default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _effective_data() -> dict:
|
|
58
|
+
"""Both files read strictly, so a broken one is reported instead of hidden."""
|
|
59
|
+
data = read_preferences()
|
|
60
|
+
project = project_preferences_path()
|
|
61
|
+
if project is not None:
|
|
62
|
+
data.update({k: v for k, v in read_preferences(project).items() if k not in USER_ONLY})
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _reset(path: Path | None, label: str) -> str:
|
|
67
|
+
"""Drop every known setting, leaving unrecognized keys the file may carry."""
|
|
68
|
+
# Per-model efforts are a setting in every sense a user cares about, so a
|
|
69
|
+
# reset must clear them too even though they live outside SETTINGS.
|
|
70
|
+
known = set(SETTINGS) | {MODEL_EFFORTS_KEY}
|
|
71
|
+
removed = sorted(set(read_preferences(path)) & known)
|
|
72
|
+
if not removed:
|
|
73
|
+
return f"No {label} defaults to reset."
|
|
74
|
+
update_preferences({}, remove=tuple(removed), path=path)
|
|
75
|
+
notice = "".join(f" {_RESET_NOTICE[key]}." for key in removed if key in _RESET_NOTICE)
|
|
76
|
+
return f"Reset {label} defaults: {', '.join(removed)}. Applies on next launch.{notice}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _diff() -> str:
|
|
80
|
+
"""Only the settings actually changed, and which file changed them.
|
|
81
|
+
|
|
82
|
+
A stored value equal to the default is not a difference, and neither is an
|
|
83
|
+
invalid one: both leave the effective value where `list` shows it.
|
|
84
|
+
"""
|
|
85
|
+
user = read_preferences()
|
|
86
|
+
project_path = project_preferences_path()
|
|
87
|
+
project = read_preferences(project_path) if project_path is not None else {}
|
|
88
|
+
data = _effective_data()
|
|
89
|
+
lines = []
|
|
90
|
+
for key, setting in SETTINGS.items():
|
|
91
|
+
value = _effective(data, key)
|
|
92
|
+
if value == setting.default:
|
|
93
|
+
continue
|
|
94
|
+
source = "project" if key in project and key not in USER_ONLY else "user"
|
|
95
|
+
default = "unset" if setting.default is None else setting.default
|
|
96
|
+
lines.append(f"{key} = {value} (default {default}, from {source})")
|
|
97
|
+
efforts = user.get(MODEL_EFFORTS_KEY)
|
|
98
|
+
if isinstance(efforts, dict) and efforts:
|
|
99
|
+
chosen = ", ".join(f"{model}={effort}" for model, effort in sorted(efforts.items()))
|
|
100
|
+
lines.append(f"{MODEL_EFFORTS_KEY} = {chosen} (default none, from user)")
|
|
101
|
+
if not lines:
|
|
102
|
+
return "Every setting is at its default."
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _project_path() -> Path:
|
|
107
|
+
path = project_preferences_path()
|
|
108
|
+
if path is None:
|
|
109
|
+
raise ValueError("No workspace is selected, so there is no project config to edit.")
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _configure_project(args: list[str]) -> str:
|
|
114
|
+
action = args[0] if args else "list"
|
|
115
|
+
if action == "path" and len(args) == 1:
|
|
116
|
+
return str(_project_path())
|
|
117
|
+
if action == "list" and len(args) == 1:
|
|
118
|
+
return json.dumps(read_preferences(_project_path()), indent=2)
|
|
119
|
+
if action == "set" and len(args) == 3:
|
|
120
|
+
key, value = args[1:]
|
|
121
|
+
if key in USER_ONLY:
|
|
122
|
+
raise ValueError(f"{key} is user-only; a repository cannot set it. Use config set.")
|
|
123
|
+
_setting(key).validate(key, value)
|
|
124
|
+
update_preferences({key: value}, path=_project_path())
|
|
125
|
+
return f"Saved project default: {key} = {value}. Applies on next launch."
|
|
126
|
+
if action == "unset" and len(args) == 2:
|
|
127
|
+
key = args[1]
|
|
128
|
+
_setting(key)
|
|
129
|
+
update_preferences({}, remove=(key,), path=_project_path())
|
|
130
|
+
return f"Removed project default: {key}. Applies on next launch."
|
|
131
|
+
if action == "reset" and len(args) == 1:
|
|
132
|
+
return _reset(_project_path(), "project")
|
|
133
|
+
raise ValueError(f"Usage: {PROJECT_USAGE}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def configure(arguments: Sequence[str]) -> str:
|
|
137
|
+
"""Inspect or edit saved defaults without starting a model or session.
|
|
138
|
+
|
|
139
|
+
`list`, `diff`, and `get` report the effective value, with the project
|
|
140
|
+
overlay applied; `set`, `unset`, and `reset` edit the user file.
|
|
141
|
+
"""
|
|
142
|
+
args = list(arguments) or ["list"]
|
|
143
|
+
action = args[0]
|
|
144
|
+
if action == "project":
|
|
145
|
+
return _configure_project(args[1:])
|
|
146
|
+
if action == "path" and len(args) == 1:
|
|
147
|
+
return str(preferences_path())
|
|
148
|
+
if action == "list" and len(args) == 1:
|
|
149
|
+
data = _effective_data()
|
|
150
|
+
return json.dumps({key: _effective(data, key) for key in SETTINGS}, indent=2)
|
|
151
|
+
if action == "diff" and len(args) == 1:
|
|
152
|
+
return _diff()
|
|
153
|
+
if action == "get" and len(args) == 2:
|
|
154
|
+
key = args[1]
|
|
155
|
+
_setting(key)
|
|
156
|
+
value = _effective(_effective_data(), key)
|
|
157
|
+
return "null" if value is None else value
|
|
158
|
+
if action == "set" and len(args) == 3:
|
|
159
|
+
key, value = args[1:]
|
|
160
|
+
_setting(key).validate(key, value)
|
|
161
|
+
update_preferences({key: value})
|
|
162
|
+
return f"Saved global default: {key} = {value}. Applies on next launch."
|
|
163
|
+
if action == "unset" and len(args) == 2:
|
|
164
|
+
key = args[1]
|
|
165
|
+
setting = _setting(key)
|
|
166
|
+
update_preferences({}, remove=(key,))
|
|
167
|
+
value = setting.default if setting.default is not None else "null (offline preview)"
|
|
168
|
+
return f"Reset global default: {key} = {value}. Applies on next launch."
|
|
169
|
+
if action == "reset" and len(args) == 1:
|
|
170
|
+
return _reset(None, "global")
|
|
171
|
+
raise ValueError(f"Usage: {USAGE}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def config_argument_descriptions() -> dict[str, str]:
|
|
175
|
+
"""Completion-menu text: the setting's purpose beside each key, its default beside values."""
|
|
176
|
+
described: dict[str, str] = {
|
|
177
|
+
"list": "Show every effective setting",
|
|
178
|
+
"diff": "Show only settings that differ from their defaults",
|
|
179
|
+
"path": "Print the user preferences file",
|
|
180
|
+
"get": "Show one effective setting",
|
|
181
|
+
"set": "Save a user default (applies on next launch)",
|
|
182
|
+
"unset": "Remove a user default",
|
|
183
|
+
"reset": "Remove every user default",
|
|
184
|
+
"project": "Edit the workspace's .pcode/preferences.json",
|
|
185
|
+
"project list": "Show the project file",
|
|
186
|
+
"project path": "Print the project preferences file",
|
|
187
|
+
"project reset": "Remove every project default",
|
|
188
|
+
}
|
|
189
|
+
for key, setting in SETTINGS.items():
|
|
190
|
+
default = "unset" if setting.default is None else setting.default
|
|
191
|
+
text = f"{setting.description} (default {default})" if setting.description else ""
|
|
192
|
+
for action in ("get", "unset", "set", "project set", "project unset"):
|
|
193
|
+
described[f"{action} {key}"] = text
|
|
194
|
+
for value in setting.choices:
|
|
195
|
+
marker = " (default)" if value == setting.default else ""
|
|
196
|
+
described[f"set {key} {value}"] = f"{setting.description}{marker}"
|
|
197
|
+
described[f"project set {key} {value}"] = described[f"set {key} {value}"]
|
|
198
|
+
return described
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def config_arguments() -> tuple[str, ...]:
|
|
202
|
+
"""Complete subcommands, keys, and enum values with the existing completer."""
|
|
203
|
+
project_keys = [key for key in SETTINGS if key not in USER_ONLY]
|
|
204
|
+
return (
|
|
205
|
+
"list",
|
|
206
|
+
"diff",
|
|
207
|
+
"path",
|
|
208
|
+
"get",
|
|
209
|
+
"set",
|
|
210
|
+
"unset",
|
|
211
|
+
"reset",
|
|
212
|
+
"project",
|
|
213
|
+
*(f"get {key}" for key in SETTINGS),
|
|
214
|
+
*(f"unset {key}" for key in SETTINGS),
|
|
215
|
+
*(f"set {key}" for key in SETTINGS),
|
|
216
|
+
*(f"set {key} {value}" for key, setting in SETTINGS.items() for value in setting.choices),
|
|
217
|
+
"project list",
|
|
218
|
+
"project path",
|
|
219
|
+
"project reset",
|
|
220
|
+
*(f"project set {key}" for key in project_keys),
|
|
221
|
+
*(f"project unset {key}" for key in project_keys),
|
|
222
|
+
*(f"project set {key} {value}" for key in project_keys for value in SETTINGS[key].choices),
|
|
223
|
+
)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Attribute fixed prompt overhead to the thing that contributed it.
|
|
2
|
+
|
|
3
|
+
`context_usage` reports one number for the whole request, which says nothing
|
|
4
|
+
about *why* it is that large. Pydantic AI already tags every instruction part
|
|
5
|
+
with the capability that authored it, so the breakdown here is a grouping of
|
|
6
|
+
`ModelRequestParameters`, not a second estimate of the prompt.
|
|
7
|
+
|
|
8
|
+
Repository instructions arrive as one part holding several `<context-file>`
|
|
9
|
+
blocks; they are split for display only. Nothing in this module changes what is
|
|
10
|
+
sent, so a wrong label can never cost a cache hit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pcode.context_usage import compact_tokens
|
|
20
|
+
|
|
21
|
+
# `compaction.schema_tokens` uses the same divisor. Provider tokenizers disagree
|
|
22
|
+
# with each other anyway; a consistent estimate keeps these rows comparable with
|
|
23
|
+
# the compaction threshold that acts on them.
|
|
24
|
+
CHARS_PER_TOKEN = 4
|
|
25
|
+
|
|
26
|
+
# An instruction part is attributed to its source only when that source has an
|
|
27
|
+
# `id`, so pcode names the ones it constructs (see `agent.create_coder`) and reads
|
|
28
|
+
# Harness's own ids for the rest. Anything unlisted falls back to the bare id.
|
|
29
|
+
REPO_CONTEXT = "repo_context"
|
|
30
|
+
SOURCE_LABELS = {
|
|
31
|
+
REPO_CONTEXT: "Repository context",
|
|
32
|
+
"code_mode": "Code mode",
|
|
33
|
+
"file_tools": "File tools",
|
|
34
|
+
"mcp_servers": "MCP servers",
|
|
35
|
+
"planning": "Planning tool",
|
|
36
|
+
"sub_agents": "Sub-agents",
|
|
37
|
+
"tool_output_limits": "Tool output limits",
|
|
38
|
+
"ext.web_research": "Web research",
|
|
39
|
+
}
|
|
40
|
+
AGENT_LABEL = "Terminal instructions"
|
|
41
|
+
# Coder's base prompt is a concrete `Capability`, which attributes its
|
|
42
|
+
# instructions at construction and so cannot be named after the fact.
|
|
43
|
+
ANONYMOUS_LABEL = "Harness base prompts"
|
|
44
|
+
|
|
45
|
+
CONTEXT_FILE = re.compile(r'<context-file path="(?P<path>[^"]*)">\n.*?\n</context-file>', re.DOTALL)
|
|
46
|
+
ASSISTANT_CONFIG = re.compile(
|
|
47
|
+
r"<assistant-configuration>\n.*?\n</assistant-configuration>", re.DOTALL
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def estimate_tokens(text: str) -> int:
|
|
52
|
+
return len(text) // CHARS_PER_TOKEN
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Row:
|
|
57
|
+
"""One attributed slice of the fixed prompt overhead."""
|
|
58
|
+
|
|
59
|
+
label: str
|
|
60
|
+
tokens: int
|
|
61
|
+
detail: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def source_id(part) -> str | None:
|
|
65
|
+
"""Name the capability that authored `part`; None for the agent's own or anonymous."""
|
|
66
|
+
return getattr(getattr(getattr(part, "id", None), "source", None), "id", None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _short(path: str) -> str:
|
|
70
|
+
"""Shorten a home-relative instruction path the way a shell prompt would."""
|
|
71
|
+
candidate = Path(path)
|
|
72
|
+
return (
|
|
73
|
+
f"~/{candidate.relative_to(Path.home())}"
|
|
74
|
+
if candidate.is_absolute() and candidate.is_relative_to(Path.home())
|
|
75
|
+
else path
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _asset_detail(block: str) -> str:
|
|
80
|
+
"""Count the discovered assets the block lists, without re-scanning the disk."""
|
|
81
|
+
try:
|
|
82
|
+
inventory = next(
|
|
83
|
+
(json.loads(line) for line in block.splitlines() if line.startswith("{")), None
|
|
84
|
+
)
|
|
85
|
+
except ValueError:
|
|
86
|
+
# An unreadable inventory is still a measurable number of tokens, and
|
|
87
|
+
# /status must not be the thing that fails on it.
|
|
88
|
+
inventory = None
|
|
89
|
+
if inventory is None:
|
|
90
|
+
return "paths only"
|
|
91
|
+
counts = [
|
|
92
|
+
(sum(len(root.get(key, ())) for root in inventory.get("roots", ())), noun)
|
|
93
|
+
for key, noun in (("skills", "skill"), ("agents", "agent"))
|
|
94
|
+
]
|
|
95
|
+
listed = ", ".join(f"{count} {noun}" for count, noun in counts if count)
|
|
96
|
+
return f"paths only · {listed}" if listed else "paths only · nothing discovered"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def repo_rows(text: str) -> Iterator[Row]:
|
|
100
|
+
"""Split repository context into the instruction files and the asset inventory."""
|
|
101
|
+
remainder = text
|
|
102
|
+
for match in CONTEXT_FILE.finditer(text):
|
|
103
|
+
remainder = remainder.replace(match.group(), "", 1)
|
|
104
|
+
yield Row(_short(match["path"]), estimate_tokens(match.group()), "instructions")
|
|
105
|
+
if config := ASSISTANT_CONFIG.search(text):
|
|
106
|
+
remainder = remainder.replace(config.group(), "", 1)
|
|
107
|
+
yield Row(
|
|
108
|
+
"Assistant config", estimate_tokens(config.group()), _asset_detail(config.group())
|
|
109
|
+
)
|
|
110
|
+
if leftover := remainder.strip():
|
|
111
|
+
yield Row(SOURCE_LABELS[REPO_CONTEXT], estimate_tokens(leftover))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def instruction_rows(parts: Sequence) -> list[Row]:
|
|
115
|
+
"""One row per instruction source, largest first, with repository context expanded."""
|
|
116
|
+
rows: list[Row] = []
|
|
117
|
+
anonymous = 0
|
|
118
|
+
for part in parts or ():
|
|
119
|
+
identity = source_id(part)
|
|
120
|
+
if identity == REPO_CONTEXT:
|
|
121
|
+
rows.extend(repo_rows(part.content))
|
|
122
|
+
elif identity:
|
|
123
|
+
rows.append(Row(SOURCE_LABELS.get(identity, identity), estimate_tokens(part.content)))
|
|
124
|
+
elif getattr(part, "id", None) is None:
|
|
125
|
+
anonymous += estimate_tokens(part.content)
|
|
126
|
+
else:
|
|
127
|
+
rows.append(Row(AGENT_LABEL, estimate_tokens(part.content)))
|
|
128
|
+
if anonymous:
|
|
129
|
+
rows.append(Row(ANONYMOUS_LABEL, anonymous))
|
|
130
|
+
return sorted(rows, key=lambda row: -row.tokens)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tool_rows(parameters) -> list[Row]:
|
|
134
|
+
"""Size each tool the way `compaction.schema_tokens` sizes them in aggregate."""
|
|
135
|
+
return sorted(
|
|
136
|
+
(
|
|
137
|
+
Row(
|
|
138
|
+
tool.name,
|
|
139
|
+
estimate_tokens(json.dumps(tool.parameters_json_schema) + (tool.description or "")),
|
|
140
|
+
)
|
|
141
|
+
for tool in [*parameters.function_tools, *parameters.output_tools]
|
|
142
|
+
),
|
|
143
|
+
key=lambda row: -row.tokens,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def overhead_rows(parameters, *, window: int | None = None) -> list[tuple[str, str]]:
|
|
148
|
+
"""Label/value rows describing the fixed cost of the last request's prompt.
|
|
149
|
+
|
|
150
|
+
Fixed means what the provider is re-sent whatever the conversation did:
|
|
151
|
+
instructions and tool schemas, never message history.
|
|
152
|
+
"""
|
|
153
|
+
instructions = instruction_rows(parameters.instruction_parts or [])
|
|
154
|
+
tools = tool_rows(parameters)
|
|
155
|
+
prompt = sum(row.tokens for row in instructions)
|
|
156
|
+
schemas = sum(row.tokens for row in tools)
|
|
157
|
+
total = prompt + schemas
|
|
158
|
+
share = f" · {total * 100 // window}% of {compact_tokens(window)}" if window else ""
|
|
159
|
+
rows = [
|
|
160
|
+
("Prompt overhead", f"~{compact_tokens(total)} tokens{share} · estimated"),
|
|
161
|
+
(" Instructions", f"~{compact_tokens(prompt)}"),
|
|
162
|
+
]
|
|
163
|
+
for row in instructions:
|
|
164
|
+
rows.append(
|
|
165
|
+
(
|
|
166
|
+
f" {row.label}",
|
|
167
|
+
f"~{compact_tokens(row.tokens)}" + (f" · {row.detail}" if row.detail else ""),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
rows.append((" Tool schemas", f"~{compact_tokens(schemas)} · {len(tools)} tools"))
|
|
171
|
+
if tools:
|
|
172
|
+
rows.append(
|
|
173
|
+
(
|
|
174
|
+
" Largest",
|
|
175
|
+
" · ".join(f"{row.label} {compact_tokens(row.tokens)}" for row in tools[:5]),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return rows
|