thefuck-leeguoo 3.41__py2.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.
- thefuck/__init__.py +0 -0
- thefuck/ai.py +765 -0
- thefuck/argument_parser.py +96 -0
- thefuck/conf.py +141 -0
- thefuck/const.py +111 -0
- thefuck/corrector.py +92 -0
- thefuck/entrypoints/__init__.py +0 -0
- thefuck/entrypoints/alias.py +28 -0
- thefuck/entrypoints/fix_command.py +105 -0
- thefuck/entrypoints/main.py +50 -0
- thefuck/entrypoints/not_configured.py +201 -0
- thefuck/entrypoints/setup.py +227 -0
- thefuck/entrypoints/shell_logger.py +79 -0
- thefuck/exceptions.py +10 -0
- thefuck/logs.py +255 -0
- thefuck/output_readers/__init__.py +20 -0
- thefuck/output_readers/read_log.py +108 -0
- thefuck/output_readers/rerun.py +72 -0
- thefuck/output_readers/shell_logger.py +60 -0
- thefuck/rules/__init__.py +0 -0
- thefuck/rules/adb_unknown_command.py +54 -0
- thefuck/rules/ag_literal.py +10 -0
- thefuck/rules/apt_get.py +50 -0
- thefuck/rules/apt_get_search.py +14 -0
- thefuck/rules/apt_invalid_operation.py +63 -0
- thefuck/rules/apt_list_upgradable.py +16 -0
- thefuck/rules/apt_upgrade.py +16 -0
- thefuck/rules/aws_cli.py +17 -0
- thefuck/rules/az_cli.py +17 -0
- thefuck/rules/brew_cask_dependency.py +33 -0
- thefuck/rules/brew_install.py +24 -0
- thefuck/rules/brew_link.py +15 -0
- thefuck/rules/brew_reinstall.py +19 -0
- thefuck/rules/brew_uninstall.py +14 -0
- thefuck/rules/brew_unknown_command.py +82 -0
- thefuck/rules/brew_update_formula.py +12 -0
- thefuck/rules/cargo.py +6 -0
- thefuck/rules/cargo_no_command.py +15 -0
- thefuck/rules/cat_dir.py +14 -0
- thefuck/rules/cd_correction.py +61 -0
- thefuck/rules/cd_cs.py +21 -0
- thefuck/rules/cd_mkdir.py +21 -0
- thefuck/rules/cd_parent.py +16 -0
- thefuck/rules/chmod_x.py +15 -0
- thefuck/rules/choco_install.py +25 -0
- thefuck/rules/composer_not_command.py +22 -0
- thefuck/rules/conda_mistype.py +17 -0
- thefuck/rules/cp_create_destination.py +15 -0
- thefuck/rules/cp_omitting_directory.py +15 -0
- thefuck/rules/cpp11.py +12 -0
- thefuck/rules/dirty_untar.py +53 -0
- thefuck/rules/dirty_unzip.py +60 -0
- thefuck/rules/django_south_ghost.py +8 -0
- thefuck/rules/django_south_merge.py +8 -0
- thefuck/rules/dnf_no_such_command.py +37 -0
- thefuck/rules/docker_image_being_used_by_container.py +20 -0
- thefuck/rules/docker_login.py +13 -0
- thefuck/rules/docker_not_command.py +49 -0
- thefuck/rules/dry.py +15 -0
- thefuck/rules/fab_command_not_found.py +38 -0
- thefuck/rules/fix_alt_space.py +15 -0
- thefuck/rules/fix_file.py +80 -0
- thefuck/rules/gem_unknown_command.py +36 -0
- thefuck/rules/git_add.py +27 -0
- thefuck/rules/git_add_force.py +13 -0
- thefuck/rules/git_bisect_usage.py +16 -0
- thefuck/rules/git_branch_0flag.py +24 -0
- thefuck/rules/git_branch_delete.py +13 -0
- thefuck/rules/git_branch_delete_checked_out.py +19 -0
- thefuck/rules/git_branch_exists.py +25 -0
- thefuck/rules/git_branch_list.py +14 -0
- thefuck/rules/git_checkout.py +49 -0
- thefuck/rules/git_clone_git_clone.py +12 -0
- thefuck/rules/git_clone_missing.py +42 -0
- thefuck/rules/git_commit_add.py +17 -0
- thefuck/rules/git_commit_amend.py +11 -0
- thefuck/rules/git_commit_reset.py +11 -0
- thefuck/rules/git_diff_no_index.py +16 -0
- thefuck/rules/git_diff_staged.py +13 -0
- thefuck/rules/git_fix_stash.py +37 -0
- thefuck/rules/git_flag_after_filename.py +31 -0
- thefuck/rules/git_help_aliased.py +12 -0
- thefuck/rules/git_hook_bypass.py +27 -0
- thefuck/rules/git_lfs_mistype.py +18 -0
- thefuck/rules/git_main_master.py +16 -0
- thefuck/rules/git_merge.py +18 -0
- thefuck/rules/git_merge_unrelated.py +12 -0
- thefuck/rules/git_not_command.py +18 -0
- thefuck/rules/git_pull.py +16 -0
- thefuck/rules/git_pull_clone.py +13 -0
- thefuck/rules/git_pull_uncommitted_changes.py +14 -0
- thefuck/rules/git_push.py +44 -0
- thefuck/rules/git_push_different_branch_names.py +12 -0
- thefuck/rules/git_push_force.py +18 -0
- thefuck/rules/git_push_pull.py +20 -0
- thefuck/rules/git_push_without_commits.py +12 -0
- thefuck/rules/git_rebase_merge_dir.py +17 -0
- thefuck/rules/git_rebase_no_changes.py +13 -0
- thefuck/rules/git_remote_delete.py +13 -0
- thefuck/rules/git_remote_seturl_add.py +12 -0
- thefuck/rules/git_rm_local_modifications.py +19 -0
- thefuck/rules/git_rm_recursive.py +16 -0
- thefuck/rules/git_rm_staged.py +19 -0
- thefuck/rules/git_stash.py +15 -0
- thefuck/rules/git_stash_pop.py +18 -0
- thefuck/rules/git_tag_force.py +13 -0
- thefuck/rules/git_two_dashes.py +14 -0
- thefuck/rules/go_run.py +16 -0
- thefuck/rules/go_unknown_command.py +28 -0
- thefuck/rules/gradle_no_task.py +34 -0
- thefuck/rules/gradle_wrapper.py +13 -0
- thefuck/rules/grep_arguments_order.py +23 -0
- thefuck/rules/grep_recursive.py +10 -0
- thefuck/rules/grunt_task_not_found.py +37 -0
- thefuck/rules/gulp_not_task.py +22 -0
- thefuck/rules/has_exists_script.py +13 -0
- thefuck/rules/heroku_multiple_apps.py +12 -0
- thefuck/rules/heroku_not_command.py +11 -0
- thefuck/rules/history.py +15 -0
- thefuck/rules/hostscli.py +27 -0
- thefuck/rules/ifconfig_device_not_found.py +23 -0
- thefuck/rules/java.py +17 -0
- thefuck/rules/javac.py +18 -0
- thefuck/rules/lein_not_task.py +19 -0
- thefuck/rules/ln_no_hard_link.py +23 -0
- thefuck/rules/ln_s_order.py +26 -0
- thefuck/rules/long_form_help.py +27 -0
- thefuck/rules/ls_all.py +10 -0
- thefuck/rules/ls_lah.py +12 -0
- thefuck/rules/man.py +33 -0
- thefuck/rules/man_no_space.py +10 -0
- thefuck/rules/mercurial.py +27 -0
- thefuck/rules/missing_space_before_subcommand.py +21 -0
- thefuck/rules/mkdir_p.py +13 -0
- thefuck/rules/mvn_no_command.py +11 -0
- thefuck/rules/mvn_unknown_lifecycle_phase.py +30 -0
- thefuck/rules/nixos_cmd_not_found.py +15 -0
- thefuck/rules/no_command.py +41 -0
- thefuck/rules/no_such_file.py +30 -0
- thefuck/rules/npm_missing_script.py +17 -0
- thefuck/rules/npm_run_script.py +17 -0
- thefuck/rules/npm_wrong_command.py +42 -0
- thefuck/rules/omnienv_no_such_command.py +35 -0
- thefuck/rules/open.py +40 -0
- thefuck/rules/pacman.py +17 -0
- thefuck/rules/pacman_invalid_option.py +20 -0
- thefuck/rules/pacman_not_found.py +26 -0
- thefuck/rules/path_from_history.py +53 -0
- thefuck/rules/php_s.py +11 -0
- thefuck/rules/pip_install.py +15 -0
- thefuck/rules/pip_unknown_command.py +19 -0
- thefuck/rules/port_already_in_use.py +40 -0
- thefuck/rules/prove_recursively.py +27 -0
- thefuck/rules/python_command.py +17 -0
- thefuck/rules/python_execute.py +15 -0
- thefuck/rules/python_module_error.py +13 -0
- thefuck/rules/quotation_marks.py +12 -0
- thefuck/rules/rails_migrations_pending.py +14 -0
- thefuck/rules/react_native_command_unrecognized.py +34 -0
- thefuck/rules/remove_shell_prompt_literal.py +23 -0
- thefuck/rules/remove_trailing_cedilla.py +11 -0
- thefuck/rules/rm_dir.py +16 -0
- thefuck/rules/rm_root.py +16 -0
- thefuck/rules/scm_correction.py +32 -0
- thefuck/rules/sed_unterminated_s.py +18 -0
- thefuck/rules/sl_ls.py +14 -0
- thefuck/rules/ssh_known_hosts.py +37 -0
- thefuck/rules/sudo.py +47 -0
- thefuck/rules/sudo_command_from_user_path.py +21 -0
- thefuck/rules/switch_lang.py +117 -0
- thefuck/rules/systemctl.py +22 -0
- thefuck/rules/terraform_init.py +13 -0
- thefuck/rules/terraform_no_command.py +16 -0
- thefuck/rules/test.py.py +10 -0
- thefuck/rules/tmux.py +18 -0
- thefuck/rules/touch.py +14 -0
- thefuck/rules/tsuru_login.py +12 -0
- thefuck/rules/tsuru_not_command.py +15 -0
- thefuck/rules/unknown_command.py +13 -0
- thefuck/rules/unsudo.py +15 -0
- thefuck/rules/vagrant_up.py +21 -0
- thefuck/rules/whois.py +34 -0
- thefuck/rules/workon_doesnt_exists.py +32 -0
- thefuck/rules/wrong_hyphen_before_subcommand.py +20 -0
- thefuck/rules/yarn_alias.py +14 -0
- thefuck/rules/yarn_command_not_found.py +43 -0
- thefuck/rules/yarn_command_replaced.py +13 -0
- thefuck/rules/yarn_help.py +17 -0
- thefuck/rules/yum_invalid_operation.py +39 -0
- thefuck/shells/__init__.py +52 -0
- thefuck/shells/bash.py +94 -0
- thefuck/shells/fish.py +131 -0
- thefuck/shells/generic.py +154 -0
- thefuck/shells/powershell.py +43 -0
- thefuck/shells/tcsh.py +44 -0
- thefuck/shells/zsh.py +98 -0
- thefuck/specific/__init__.py +0 -0
- thefuck/specific/apt.py +3 -0
- thefuck/specific/archlinux.py +48 -0
- thefuck/specific/brew.py +15 -0
- thefuck/specific/dnf.py +3 -0
- thefuck/specific/git.py +32 -0
- thefuck/specific/nix.py +3 -0
- thefuck/specific/npm.py +21 -0
- thefuck/specific/sudo.py +18 -0
- thefuck/specific/yum.py +3 -0
- thefuck/system/__init__.py +7 -0
- thefuck/system/unix.py +57 -0
- thefuck/system/win32.py +43 -0
- thefuck/types.py +261 -0
- thefuck/ui.py +116 -0
- thefuck/utils.py +385 -0
- thefuck_leeguoo-3.41.dist-info/METADATA +681 -0
- thefuck_leeguoo-3.41.dist-info/RECORD +218 -0
- thefuck_leeguoo-3.41.dist-info/WHEEL +6 -0
- thefuck_leeguoo-3.41.dist-info/entry_points.txt +3 -0
- thefuck_leeguoo-3.41.dist-info/licenses/LICENSE.md +22 -0
- thefuck_leeguoo-3.41.dist-info/top_level.txt +1 -0
thefuck/ai.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from collections import namedtuple
|
|
5
|
+
from itertools import chain
|
|
6
|
+
import colorama
|
|
7
|
+
import six
|
|
8
|
+
from six.moves.urllib import request
|
|
9
|
+
from six.moves.urllib.error import HTTPError, URLError
|
|
10
|
+
|
|
11
|
+
from . import const, logs
|
|
12
|
+
from .conf import settings
|
|
13
|
+
from .types import CorrectedCommand
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
AiResult = namedtuple('AiResult', [
|
|
17
|
+
'commands', 'explanation', 'streamed', 'descriptions'
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SYSTEM_PROMPT = """You are a CLI command correction assistant.
|
|
22
|
+
|
|
23
|
+
IMPORTANT: Use proper spacing between all words in your response.
|
|
24
|
+
|
|
25
|
+
Reply format:
|
|
26
|
+
|
|
27
|
+
think: [1-2 sentence explanation with normal word spacing]
|
|
28
|
+
|
|
29
|
+
answer: [JSON object]
|
|
30
|
+
|
|
31
|
+
JSON schema:
|
|
32
|
+
{"primary": {"command": "...", "desc": "..."}, "alternatives": [{"command": "...", "desc": "..."}]}
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
1. Commands must be exact shell commands with correct spacing
|
|
36
|
+
2. Description must use normal English with spaces between words
|
|
37
|
+
3. If unsure, use empty command ""
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
think: The command gti is a typo for git.
|
|
42
|
+
|
|
43
|
+
answer: {"primary": {"command": "git status", "desc": "Show repository status"}, "alternatives": []}"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_enabled():
|
|
47
|
+
return bool(settings.ai_enabled and settings.ai_url)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_ai_theme():
|
|
51
|
+
from rich.theme import Theme
|
|
52
|
+
return Theme({
|
|
53
|
+
'ai.label': 'bold green',
|
|
54
|
+
'ai.text': 'white',
|
|
55
|
+
'ai.heading': 'bold green',
|
|
56
|
+
'ai.command': 'bold cyan',
|
|
57
|
+
'ai.desc': 'yellow',
|
|
58
|
+
'ai.punct': 'yellow',
|
|
59
|
+
'markdown': 'white',
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def emit_ai_result(result):
|
|
64
|
+
if not result:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
explanation = result.explanation or ''
|
|
68
|
+
commands = result.commands or []
|
|
69
|
+
descriptions = result.descriptions or {}
|
|
70
|
+
if not explanation and not commands:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
think_text = _strip_commands_section(explanation)
|
|
74
|
+
try:
|
|
75
|
+
from rich.console import Console
|
|
76
|
+
from rich.markdown import Markdown
|
|
77
|
+
from rich.text import Text
|
|
78
|
+
except Exception:
|
|
79
|
+
_emit_ai_plain(think_text, commands, descriptions, numbered=True)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
console = Console(stderr=True, theme=_build_ai_theme())
|
|
83
|
+
console.print('AI:', style='ai.label')
|
|
84
|
+
if think_text:
|
|
85
|
+
console.print(Markdown(think_text), style='ai.text')
|
|
86
|
+
if commands:
|
|
87
|
+
console.print()
|
|
88
|
+
console.print('Commands', style='ai.heading')
|
|
89
|
+
for idx, cmd in enumerate(commands, 1):
|
|
90
|
+
line = Text()
|
|
91
|
+
line.append('{}. '.format(idx), style='ai.punct')
|
|
92
|
+
line.append(cmd, style='ai.command')
|
|
93
|
+
desc = descriptions.get(cmd, '')
|
|
94
|
+
if desc:
|
|
95
|
+
line.append(' — ', style='ai.punct')
|
|
96
|
+
line.append(desc, style='ai.desc')
|
|
97
|
+
console.print(line)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def emit_ai_commands(result):
|
|
101
|
+
if not result:
|
|
102
|
+
return
|
|
103
|
+
commands = result.commands or []
|
|
104
|
+
if not commands:
|
|
105
|
+
return
|
|
106
|
+
descriptions = result.descriptions or {}
|
|
107
|
+
try:
|
|
108
|
+
from rich.console import Console
|
|
109
|
+
from rich.text import Text
|
|
110
|
+
except Exception:
|
|
111
|
+
_emit_ai_plain('', commands, descriptions, numbered=True)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
console = Console(stderr=True, theme=_build_ai_theme())
|
|
115
|
+
console.print()
|
|
116
|
+
console.print('Commands', style='ai.heading')
|
|
117
|
+
for idx, cmd in enumerate(commands, 1):
|
|
118
|
+
line = Text()
|
|
119
|
+
line.append('{}. '.format(idx), style='ai.punct')
|
|
120
|
+
line.append(cmd, style='ai.command')
|
|
121
|
+
desc = descriptions.get(cmd, '')
|
|
122
|
+
if desc:
|
|
123
|
+
line.append(' — ', style='ai.punct')
|
|
124
|
+
line.append(desc, style='ai.desc')
|
|
125
|
+
console.print(line)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _emit_ai_plain(text, commands, descriptions, numbered=False):
|
|
129
|
+
label = logs.color(colorama.Style.BRIGHT + colorama.Fore.GREEN)
|
|
130
|
+
body = logs.color(colorama.Fore.WHITE)
|
|
131
|
+
cmd_color = logs.color(colorama.Style.BRIGHT + colorama.Fore.CYAN)
|
|
132
|
+
desc_color = logs.color(colorama.Fore.YELLOW)
|
|
133
|
+
punct = logs.color(colorama.Fore.YELLOW)
|
|
134
|
+
reset = logs.color(colorama.Style.RESET_ALL)
|
|
135
|
+
|
|
136
|
+
if text or commands:
|
|
137
|
+
sys.stderr.write(u'{label}AI:{reset}\n'.format(
|
|
138
|
+
label=label, reset=reset))
|
|
139
|
+
if text:
|
|
140
|
+
sys.stderr.write(u'{body}{text}{reset}\n'.format(
|
|
141
|
+
body=body, text=text, reset=reset))
|
|
142
|
+
if commands:
|
|
143
|
+
sys.stderr.write(u'\n{body}Commands{reset}\n'.format(
|
|
144
|
+
body=body, reset=reset))
|
|
145
|
+
for idx, cmd in enumerate(commands, 1):
|
|
146
|
+
prefix = u'{}. '.format(idx) if numbered else u''
|
|
147
|
+
line = u'{punct}{prefix}{reset}{cmd_color}{cmd}{reset}'.format(
|
|
148
|
+
punct=punct, prefix=prefix, reset=reset,
|
|
149
|
+
cmd_color=cmd_color, cmd=cmd)
|
|
150
|
+
desc = descriptions.get(cmd, '')
|
|
151
|
+
if desc:
|
|
152
|
+
line += u' — {desc_color}{desc}{reset}'.format(
|
|
153
|
+
desc_color=desc_color, desc=desc, reset=reset)
|
|
154
|
+
sys.stderr.write(line + '\n')
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def emit_markdown(explanation):
|
|
158
|
+
if not explanation:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
from rich.console import Console
|
|
162
|
+
from rich.markdown import Markdown
|
|
163
|
+
except Exception:
|
|
164
|
+
sys.stderr.write(u'AI:\n{}\n'.format(explanation))
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
console = Console(stderr=True)
|
|
168
|
+
console.print('AI:')
|
|
169
|
+
console.print(Markdown(explanation))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_corrected_commands(result):
|
|
173
|
+
if not result or not result.commands:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
def _side_effect(old_cmd, new_cmd):
|
|
177
|
+
emit_ai_result(result)
|
|
178
|
+
|
|
179
|
+
if result.streamed:
|
|
180
|
+
side_effect = None
|
|
181
|
+
else:
|
|
182
|
+
side_effect = _side_effect if result.explanation else None
|
|
183
|
+
descriptions = result.descriptions or {}
|
|
184
|
+
corrected = []
|
|
185
|
+
total = len(result.commands)
|
|
186
|
+
for idx, cmd in enumerate(result.commands):
|
|
187
|
+
corrected_command = CorrectedCommand(
|
|
188
|
+
script=cmd,
|
|
189
|
+
side_effect=side_effect,
|
|
190
|
+
priority=const.DEFAULT_PRIORITY * (idx + 1))
|
|
191
|
+
desc = descriptions.get(cmd)
|
|
192
|
+
if desc:
|
|
193
|
+
corrected_command.desc = desc
|
|
194
|
+
corrected_command._tf_source = 'ai'
|
|
195
|
+
corrected_command._tf_index = idx + 1
|
|
196
|
+
corrected_command._tf_total = total
|
|
197
|
+
corrected.append(corrected_command)
|
|
198
|
+
return corrected
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def fallback_corrected_commands(command, corrected_commands):
|
|
202
|
+
try:
|
|
203
|
+
first = next(corrected_commands)
|
|
204
|
+
except StopIteration:
|
|
205
|
+
result = get_ai_suggestion(command)
|
|
206
|
+
corrected = build_corrected_commands(result)
|
|
207
|
+
if corrected:
|
|
208
|
+
return iter(corrected), result
|
|
209
|
+
return iter(()), result
|
|
210
|
+
|
|
211
|
+
return chain([first], corrected_commands), None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_ai_suggestion(command, prompt=None, warn_on_error=False):
|
|
215
|
+
if not is_enabled():
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
stream_output = bool(settings.ai_stream_output)
|
|
219
|
+
payload = _build_payload(command, prompt)
|
|
220
|
+
try:
|
|
221
|
+
response_text, streamed = _send_request(
|
|
222
|
+
payload, stream_output=stream_output)
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
if warn_on_error:
|
|
225
|
+
logs.warn(u'AI request failed: {}'.format(exc))
|
|
226
|
+
else:
|
|
227
|
+
logs.debug(u'AI request failed: {}'.format(exc))
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
result = _parse_response(response_text, streamed)
|
|
231
|
+
if not result:
|
|
232
|
+
return
|
|
233
|
+
return result
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_payload(command, prompt):
|
|
237
|
+
output = six.text_type(command.output or '')
|
|
238
|
+
user_lines = [
|
|
239
|
+
'Failed command:',
|
|
240
|
+
six.text_type(command.script or ''),
|
|
241
|
+
'',
|
|
242
|
+
'Output:',
|
|
243
|
+
output
|
|
244
|
+
]
|
|
245
|
+
if prompt:
|
|
246
|
+
user_lines.extend(['', 'User prompt:', six.text_type(prompt)])
|
|
247
|
+
|
|
248
|
+
payload = {
|
|
249
|
+
'model': settings.ai_model,
|
|
250
|
+
'messages': [
|
|
251
|
+
{'role': 'system', 'content': SYSTEM_PROMPT},
|
|
252
|
+
{'role': 'user', 'content': '\n'.join(user_lines)}
|
|
253
|
+
],
|
|
254
|
+
'stream': bool(settings.ai_stream)
|
|
255
|
+
}
|
|
256
|
+
if settings.ai_reasoning_effort:
|
|
257
|
+
payload['reasoning'] = {'effort': settings.ai_reasoning_effort}
|
|
258
|
+
return payload
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _send_request(payload, stream_output=False):
|
|
262
|
+
data = json.dumps(payload).encode('utf-8')
|
|
263
|
+
headers = {'Content-Type': 'application/json'}
|
|
264
|
+
if settings.ai_token:
|
|
265
|
+
headers['Authorization'] = 'Bearer {}'.format(settings.ai_token)
|
|
266
|
+
if settings.ai_stream:
|
|
267
|
+
headers['Accept'] = 'text/event-stream'
|
|
268
|
+
req = request.Request(settings.ai_url, data=data, headers=headers)
|
|
269
|
+
try:
|
|
270
|
+
response = request.urlopen(req, timeout=settings.ai_timeout)
|
|
271
|
+
try:
|
|
272
|
+
if settings.ai_stream:
|
|
273
|
+
content_type = response.headers.get('Content-Type', '')
|
|
274
|
+
if 'text/event-stream' in content_type:
|
|
275
|
+
return _read_sse_response(
|
|
276
|
+
response, stream_output=stream_output)
|
|
277
|
+
return response.read().decode('utf-8'), False
|
|
278
|
+
finally:
|
|
279
|
+
response.close()
|
|
280
|
+
except HTTPError as exc:
|
|
281
|
+
body = exc.read().decode('utf-8', errors='replace')
|
|
282
|
+
raise RuntimeError('status {}: {}'.format(exc.code, body))
|
|
283
|
+
except URLError as exc:
|
|
284
|
+
raise RuntimeError(str(exc))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _read_sse_response(response, stream_output=False):
|
|
288
|
+
chunks = []
|
|
289
|
+
stream_writer = _make_stream_writer() if stream_output else None
|
|
290
|
+
while True:
|
|
291
|
+
line = response.readline()
|
|
292
|
+
if not line:
|
|
293
|
+
break
|
|
294
|
+
if isinstance(line, bytes):
|
|
295
|
+
line = line.decode('utf-8', errors='replace')
|
|
296
|
+
line = line.rstrip('\r\n')
|
|
297
|
+
if not line:
|
|
298
|
+
continue
|
|
299
|
+
if not line.startswith('data:'):
|
|
300
|
+
continue
|
|
301
|
+
data = line[5:]
|
|
302
|
+
if data.startswith(' '):
|
|
303
|
+
data = data[1:]
|
|
304
|
+
data = data.rstrip('\r\n')
|
|
305
|
+
if data == '[DONE]':
|
|
306
|
+
break
|
|
307
|
+
chunk = _extract_stream_chunk(data)
|
|
308
|
+
if chunk:
|
|
309
|
+
chunks.append(chunk)
|
|
310
|
+
if stream_writer:
|
|
311
|
+
stream_writer.feed(chunk)
|
|
312
|
+
streamed = False
|
|
313
|
+
if stream_writer:
|
|
314
|
+
stream_writer.finish()
|
|
315
|
+
streamed = stream_writer.streamed
|
|
316
|
+
return ''.join(chunks), streamed
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _extract_stream_chunk(data):
|
|
320
|
+
try:
|
|
321
|
+
parsed = json.loads(data)
|
|
322
|
+
except ValueError:
|
|
323
|
+
return data
|
|
324
|
+
|
|
325
|
+
if isinstance(parsed, dict):
|
|
326
|
+
choices = parsed.get('choices')
|
|
327
|
+
if isinstance(choices, list) and choices:
|
|
328
|
+
choice = choices[0] or {}
|
|
329
|
+
if isinstance(choice, dict):
|
|
330
|
+
delta = choice.get('delta')
|
|
331
|
+
if isinstance(delta, dict) and 'content' in delta:
|
|
332
|
+
return delta.get('content') or ''
|
|
333
|
+
message = choice.get('message')
|
|
334
|
+
if isinstance(message, dict) and 'content' in message:
|
|
335
|
+
return message.get('content') or ''
|
|
336
|
+
if 'text' in choice:
|
|
337
|
+
return choice.get('text') or ''
|
|
338
|
+
if 'content' in parsed:
|
|
339
|
+
return parsed.get('content') or ''
|
|
340
|
+
|
|
341
|
+
return ''
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class _StreamWriter(object):
|
|
345
|
+
def __init__(self):
|
|
346
|
+
self.streamed = False
|
|
347
|
+
self._buffer = ''
|
|
348
|
+
self._pending = ''
|
|
349
|
+
self._started = False
|
|
350
|
+
self._done = False
|
|
351
|
+
self._text = ''
|
|
352
|
+
self._console = None
|
|
353
|
+
self._live = None
|
|
354
|
+
self._markdown_cls = None
|
|
355
|
+
self._use_rich = False
|
|
356
|
+
self._init_rich()
|
|
357
|
+
|
|
358
|
+
def _init_rich(self):
|
|
359
|
+
try:
|
|
360
|
+
from rich.console import Console
|
|
361
|
+
from rich.live import Live
|
|
362
|
+
from rich.markdown import Markdown
|
|
363
|
+
except Exception:
|
|
364
|
+
return
|
|
365
|
+
self._use_rich = True
|
|
366
|
+
self._console = Console(stderr=True, theme=_build_ai_theme())
|
|
367
|
+
self._live = Live(Markdown(''), console=self._console,
|
|
368
|
+
refresh_per_second=8)
|
|
369
|
+
self._markdown_cls = Markdown
|
|
370
|
+
|
|
371
|
+
def feed(self, chunk):
|
|
372
|
+
if self._done:
|
|
373
|
+
return
|
|
374
|
+
self._buffer += chunk
|
|
375
|
+
lower = self._buffer.lower()
|
|
376
|
+
idx = lower.find('answer:')
|
|
377
|
+
if idx != -1:
|
|
378
|
+
out = self._buffer[:idx]
|
|
379
|
+
self._buffer = self._buffer[idx:]
|
|
380
|
+
self._done = True
|
|
381
|
+
self._emit(out, final=True)
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
keep = len('answer:') - 1
|
|
385
|
+
if len(self._buffer) > keep:
|
|
386
|
+
out = self._buffer[:-keep]
|
|
387
|
+
self._buffer = self._buffer[-keep:]
|
|
388
|
+
self._emit(out, final=False)
|
|
389
|
+
|
|
390
|
+
def finish(self):
|
|
391
|
+
if not self._done and self._buffer:
|
|
392
|
+
self._emit(self._buffer, final=True)
|
|
393
|
+
self._buffer = ''
|
|
394
|
+
if self._use_rich and self._live and self._started:
|
|
395
|
+
self._live.stop()
|
|
396
|
+
elif self._started:
|
|
397
|
+
sys.stderr.write('\n')
|
|
398
|
+
sys.stderr.flush()
|
|
399
|
+
|
|
400
|
+
def _emit(self, text, final):
|
|
401
|
+
if not text:
|
|
402
|
+
return
|
|
403
|
+
if not self._started:
|
|
404
|
+
self._pending += text
|
|
405
|
+
if len(self._pending) < 16 and not final:
|
|
406
|
+
return
|
|
407
|
+
text = self._strip_think_prefix(self._pending)
|
|
408
|
+
self._pending = ''
|
|
409
|
+
if not text and not final:
|
|
410
|
+
return
|
|
411
|
+
self._start()
|
|
412
|
+
if self._use_rich:
|
|
413
|
+
self._text += text
|
|
414
|
+
self._live.update(self._markdown_cls(self._text))
|
|
415
|
+
else:
|
|
416
|
+
sys.stderr.write(text)
|
|
417
|
+
sys.stderr.flush()
|
|
418
|
+
|
|
419
|
+
def _start(self):
|
|
420
|
+
if self._started:
|
|
421
|
+
return
|
|
422
|
+
self._started = True
|
|
423
|
+
self.streamed = True
|
|
424
|
+
if self._use_rich:
|
|
425
|
+
self._console.print('AI:', style='ai.label')
|
|
426
|
+
self._live.start()
|
|
427
|
+
else:
|
|
428
|
+
sys.stderr.write('{}AI:{} '.format(
|
|
429
|
+
logs.color(colorama.Style.BRIGHT + colorama.Fore.GREEN),
|
|
430
|
+
logs.color(colorama.Style.RESET_ALL)))
|
|
431
|
+
sys.stderr.flush()
|
|
432
|
+
|
|
433
|
+
def _strip_think_prefix(self, text):
|
|
434
|
+
stripped = text.lstrip()
|
|
435
|
+
lower = stripped.lower()
|
|
436
|
+
if lower.startswith('think:'):
|
|
437
|
+
stripped = stripped[len('think:'):].lstrip()
|
|
438
|
+
return stripped
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _make_stream_writer():
|
|
442
|
+
return _StreamWriter()
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _parse_response(response_text, streamed):
|
|
446
|
+
try:
|
|
447
|
+
data = json.loads(response_text)
|
|
448
|
+
except ValueError:
|
|
449
|
+
logs.debug(u'AI response is not JSON')
|
|
450
|
+
return _parse_content(response_text, streamed)
|
|
451
|
+
|
|
452
|
+
content = _extract_content(data)
|
|
453
|
+
if content is None:
|
|
454
|
+
logs.debug(u'AI response missing content')
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
result = _parse_content(content, streamed)
|
|
458
|
+
if not result:
|
|
459
|
+
return
|
|
460
|
+
return result
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _extract_content(data):
|
|
464
|
+
if isinstance(data, dict):
|
|
465
|
+
if 'command' in data or 'commands' in data or 'explanation' in data:
|
|
466
|
+
return data
|
|
467
|
+
choices = data.get('choices')
|
|
468
|
+
if isinstance(choices, list) and choices:
|
|
469
|
+
choice = choices[0] or {}
|
|
470
|
+
if isinstance(choice, dict):
|
|
471
|
+
message = choice.get('message', {})
|
|
472
|
+
if isinstance(message, dict) and 'content' in message:
|
|
473
|
+
return message.get('content')
|
|
474
|
+
if 'text' in choice:
|
|
475
|
+
return choice.get('text')
|
|
476
|
+
if 'content' in data:
|
|
477
|
+
return data.get('content')
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _parse_content(content, streamed):
|
|
482
|
+
if isinstance(content, dict):
|
|
483
|
+
structured = _normalize_structured(content, streamed)
|
|
484
|
+
if structured:
|
|
485
|
+
return structured
|
|
486
|
+
return _normalize_result(content, streamed)
|
|
487
|
+
|
|
488
|
+
if not isinstance(content, six.string_types):
|
|
489
|
+
content = six.text_type(content)
|
|
490
|
+
content = content.strip()
|
|
491
|
+
if not content:
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
structured = _parse_think_answer(content, streamed)
|
|
495
|
+
if structured:
|
|
496
|
+
return structured
|
|
497
|
+
|
|
498
|
+
parsed = _try_parse_json(content)
|
|
499
|
+
if parsed is None:
|
|
500
|
+
commands = _extract_commands_from_markdown(content)
|
|
501
|
+
explanation = _strip_commands_section(content)
|
|
502
|
+
return AiResult(commands=commands,
|
|
503
|
+
explanation=explanation,
|
|
504
|
+
streamed=streamed,
|
|
505
|
+
descriptions={})
|
|
506
|
+
if isinstance(parsed, dict):
|
|
507
|
+
structured = _normalize_structured(parsed, streamed)
|
|
508
|
+
if structured:
|
|
509
|
+
return structured
|
|
510
|
+
return _normalize_result(parsed, streamed)
|
|
511
|
+
return _parse_content(six.text_type(parsed), streamed)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _try_parse_json(content):
|
|
515
|
+
try:
|
|
516
|
+
return json.loads(content)
|
|
517
|
+
except ValueError:
|
|
518
|
+
start = content.find('{')
|
|
519
|
+
end = content.rfind('}')
|
|
520
|
+
if start != -1 and end != -1 and end > start:
|
|
521
|
+
snippet = content[start:end + 1]
|
|
522
|
+
try:
|
|
523
|
+
return json.loads(snippet)
|
|
524
|
+
except ValueError:
|
|
525
|
+
return
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _normalize_result(parsed, streamed):
|
|
530
|
+
if not isinstance(parsed, dict):
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
commands = parsed.get('commands', parsed.get('command', ''))
|
|
534
|
+
explanation = parsed.get('explanation', '')
|
|
535
|
+
|
|
536
|
+
if commands is None:
|
|
537
|
+
commands = ''
|
|
538
|
+
if explanation is None:
|
|
539
|
+
explanation = ''
|
|
540
|
+
|
|
541
|
+
commands = _normalize_commands(commands)
|
|
542
|
+
explanation = six.text_type(explanation).strip()
|
|
543
|
+
|
|
544
|
+
return AiResult(commands=commands, explanation=explanation, streamed=streamed,
|
|
545
|
+
descriptions={})
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _normalize_structured(parsed, streamed):
|
|
549
|
+
if not isinstance(parsed, dict):
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
if 'think' in parsed and 'answer' in parsed:
|
|
553
|
+
think = parsed.get('think', '')
|
|
554
|
+
answer = parsed.get('answer', {})
|
|
555
|
+
answer_obj = _coerce_json(answer)
|
|
556
|
+
return _build_from_answer(answer_obj, think, streamed)
|
|
557
|
+
|
|
558
|
+
if 'primary' in parsed or 'alternatives' in parsed:
|
|
559
|
+
return _build_from_answer(parsed, '', streamed)
|
|
560
|
+
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _parse_think_answer(content, streamed):
|
|
565
|
+
match = re.search(r'(?i)answer\s*:\s*', content)
|
|
566
|
+
if not match:
|
|
567
|
+
return
|
|
568
|
+
|
|
569
|
+
answer_text = content[match.end():]
|
|
570
|
+
think_text = content[:match.start()]
|
|
571
|
+
think_text = _strip_think_prefix(think_text)
|
|
572
|
+
answer_text = _strip_code_fence(answer_text)
|
|
573
|
+
answer_obj = _try_parse_json(answer_text)
|
|
574
|
+
if not isinstance(answer_obj, dict):
|
|
575
|
+
answer_obj = _extract_json_from_text(answer_text)
|
|
576
|
+
return _build_from_answer(answer_obj, think_text, streamed)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _strip_think_prefix(text):
|
|
580
|
+
match = re.search(r'(?i)think\s*:\s*', text)
|
|
581
|
+
if match:
|
|
582
|
+
return text[match.end():].strip()
|
|
583
|
+
return text.strip()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _extract_json_from_text(text):
|
|
587
|
+
start = text.find('{')
|
|
588
|
+
end = text.rfind('}')
|
|
589
|
+
if start != -1 and end != -1 and end > start:
|
|
590
|
+
try:
|
|
591
|
+
return json.loads(text[start:end + 1])
|
|
592
|
+
except ValueError:
|
|
593
|
+
return
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _coerce_json(value):
|
|
598
|
+
if isinstance(value, dict):
|
|
599
|
+
return value
|
|
600
|
+
if isinstance(value, six.string_types):
|
|
601
|
+
value = _strip_code_fence(value)
|
|
602
|
+
return _try_parse_json(value) or _extract_json_from_text(value) or {}
|
|
603
|
+
return {}
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _build_from_answer(answer_obj, think_text, streamed):
|
|
607
|
+
if not isinstance(answer_obj, dict):
|
|
608
|
+
answer_obj = {}
|
|
609
|
+
|
|
610
|
+
if 'primary' not in answer_obj and 'alternatives' not in answer_obj:
|
|
611
|
+
if 'command' in answer_obj or 'desc' in answer_obj:
|
|
612
|
+
answer_obj = {
|
|
613
|
+
'primary': {
|
|
614
|
+
'command': answer_obj.get('command', ''),
|
|
615
|
+
'desc': answer_obj.get('desc', '')
|
|
616
|
+
},
|
|
617
|
+
'alternatives': []
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
primary = answer_obj.get('primary') or {}
|
|
621
|
+
alternatives = answer_obj.get('alternatives') or []
|
|
622
|
+
|
|
623
|
+
commands = []
|
|
624
|
+
descriptions = []
|
|
625
|
+
primary_command = _extract_command(primary)
|
|
626
|
+
if primary_command:
|
|
627
|
+
commands.append(primary_command)
|
|
628
|
+
desc = _extract_desc(primary)
|
|
629
|
+
descriptions.append((primary_command, desc, True))
|
|
630
|
+
|
|
631
|
+
for alt in alternatives[:3]:
|
|
632
|
+
alt_command = _extract_command(alt)
|
|
633
|
+
if alt_command and alt_command not in commands:
|
|
634
|
+
commands.append(alt_command)
|
|
635
|
+
desc = _extract_desc(alt)
|
|
636
|
+
descriptions.append((alt_command, desc, False))
|
|
637
|
+
|
|
638
|
+
explanation = _format_think_with_commands(think_text, descriptions)
|
|
639
|
+
descriptions_map = {
|
|
640
|
+
cmd: desc for cmd, desc, _ in descriptions if desc
|
|
641
|
+
}
|
|
642
|
+
explanation = think_text.strip()
|
|
643
|
+
return AiResult(commands=commands, explanation=explanation, streamed=streamed,
|
|
644
|
+
descriptions=descriptions_map)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _extract_command(item):
|
|
648
|
+
if isinstance(item, dict):
|
|
649
|
+
return _strip_code_fence(six.text_type(item.get('command', '') or '')).strip()
|
|
650
|
+
return _strip_code_fence(six.text_type(item or '')).strip()
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _extract_desc(item):
|
|
654
|
+
if isinstance(item, dict):
|
|
655
|
+
return six.text_type(item.get('desc', '') or '').strip()
|
|
656
|
+
return ''
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _format_think_with_commands(think_text, descriptions):
|
|
660
|
+
parts = []
|
|
661
|
+
if think_text:
|
|
662
|
+
parts.append(think_text.strip())
|
|
663
|
+
if descriptions:
|
|
664
|
+
parts.append('### Commands')
|
|
665
|
+
for cmd, desc, is_primary in descriptions:
|
|
666
|
+
prefix = '- **{}**'.format(cmd) if is_primary else '- {}'.format(cmd)
|
|
667
|
+
if desc:
|
|
668
|
+
parts.append('{} — {}'.format(prefix, desc))
|
|
669
|
+
else:
|
|
670
|
+
parts.append(prefix)
|
|
671
|
+
return '\n'.join(parts).strip()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _normalize_commands(commands):
|
|
675
|
+
if commands is None:
|
|
676
|
+
return []
|
|
677
|
+
if isinstance(commands, six.string_types):
|
|
678
|
+
return _clean_commands([commands])
|
|
679
|
+
if isinstance(commands, (list, tuple)):
|
|
680
|
+
return _clean_commands(commands)
|
|
681
|
+
return _clean_commands([six.text_type(commands)])
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _clean_commands(commands):
|
|
685
|
+
cleaned = []
|
|
686
|
+
for cmd in commands:
|
|
687
|
+
if cmd is None:
|
|
688
|
+
continue
|
|
689
|
+
cmd_text = _strip_code_fence(six.text_type(cmd)).strip()
|
|
690
|
+
if cmd_text:
|
|
691
|
+
cleaned.append(cmd_text)
|
|
692
|
+
return cleaned
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _extract_commands_from_markdown(content):
|
|
696
|
+
lines = content.splitlines()
|
|
697
|
+
commands = []
|
|
698
|
+
in_section = False
|
|
699
|
+
for line in lines:
|
|
700
|
+
stripped = line.strip()
|
|
701
|
+
if not stripped:
|
|
702
|
+
continue
|
|
703
|
+
if _is_commands_heading(stripped):
|
|
704
|
+
in_section = True
|
|
705
|
+
continue
|
|
706
|
+
if in_section:
|
|
707
|
+
if stripped.startswith('#'):
|
|
708
|
+
break
|
|
709
|
+
command = _parse_command_line(stripped)
|
|
710
|
+
if command:
|
|
711
|
+
commands.append(command)
|
|
712
|
+
elif commands:
|
|
713
|
+
break
|
|
714
|
+
return commands
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _is_commands_heading(line):
|
|
718
|
+
heading = line.lstrip('#').strip().rstrip(':').lower()
|
|
719
|
+
return heading in (
|
|
720
|
+
'commands', 'command', 'command suggestions', 'suggested commands'
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _parse_command_line(line):
|
|
725
|
+
stripped = line.strip()
|
|
726
|
+
if stripped.startswith(('-', '*', '+')):
|
|
727
|
+
stripped = stripped[1:].strip()
|
|
728
|
+
elif stripped[:2].isdigit() and stripped[1] in ('.', ')'):
|
|
729
|
+
stripped = stripped[2:].strip()
|
|
730
|
+
elif stripped[:3].isdigit() and stripped[2] in ('.', ')'):
|
|
731
|
+
stripped = stripped[3:].strip()
|
|
732
|
+
if stripped.startswith('`') and stripped.endswith('`'):
|
|
733
|
+
stripped = stripped[1:-1].strip()
|
|
734
|
+
return stripped or None
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _strip_commands_section(content):
|
|
738
|
+
lines = content.splitlines()
|
|
739
|
+
output = []
|
|
740
|
+
in_section = False
|
|
741
|
+
for line in lines:
|
|
742
|
+
stripped = line.strip()
|
|
743
|
+
if _is_commands_heading(stripped):
|
|
744
|
+
in_section = True
|
|
745
|
+
continue
|
|
746
|
+
if in_section:
|
|
747
|
+
if stripped.startswith('#') or not stripped:
|
|
748
|
+
in_section = False
|
|
749
|
+
else:
|
|
750
|
+
continue
|
|
751
|
+
if not in_section:
|
|
752
|
+
output.append(line)
|
|
753
|
+
return '\n'.join(output).strip()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _strip_code_fence(text):
|
|
757
|
+
text = text.strip()
|
|
758
|
+
if text.startswith('```'):
|
|
759
|
+
lines = text.splitlines()
|
|
760
|
+
if lines and lines[0].startswith('```'):
|
|
761
|
+
lines = lines[1:]
|
|
762
|
+
if lines and lines[-1].startswith('```'):
|
|
763
|
+
lines = lines[:-1]
|
|
764
|
+
return '\n'.join(lines).strip()
|
|
765
|
+
return text
|