mangopi-cli 0.1.1__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.
mango_cli.py
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
__author__ = "moofs"
|
|
5
|
+
__license__ = "Apache License 2.0"
|
|
6
|
+
|
|
7
|
+
import copy
|
|
8
|
+
import difflib
|
|
9
|
+
import glob
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import glob as globlib
|
|
20
|
+
import platform
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from typing import List, Dict, Any, Optional
|
|
23
|
+
|
|
24
|
+
# --- System Env ---
|
|
25
|
+
MANGO_KEY = os.environ.get("MANGO_KEY")
|
|
26
|
+
MANGO_API_URL = os.environ.get("MANGO_API_URL")
|
|
27
|
+
MANGO_MODEL = os.environ.get("MANGO_MODEL")
|
|
28
|
+
MANGO_MAX_CONTEXT = int(os.environ.get("MANGO_MAX_CONTEXT", 128000))
|
|
29
|
+
|
|
30
|
+
project_root = os.getcwd()
|
|
31
|
+
base_persist_dir = os.path.join(project_root, '.mangocli')
|
|
32
|
+
session_dir = os.path.join(project_root, ".mangocli", "session")
|
|
33
|
+
|
|
34
|
+
# ANSI colors
|
|
35
|
+
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
36
|
+
BLUE, CYAN, GREEN, YELLOW, RED, GREY, ORANGE = (
|
|
37
|
+
"\033[34m", "\033[36m", "\033[32m", "\033[33m", "\033[31m", "\033[90m", "\033[38;2;245;78;0m")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _c(text, color): return f"{color}{text}{RESET}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --- UI ---
|
|
44
|
+
class Printer:
|
|
45
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._spinner_running = False
|
|
49
|
+
self._spinner_thread = None
|
|
50
|
+
self._spinner_message = ""
|
|
51
|
+
self._lock = threading.RLock()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _clear_spinner_line():
|
|
55
|
+
sys.stdout.write("\r\033[K")
|
|
56
|
+
sys.stdout.flush()
|
|
57
|
+
|
|
58
|
+
def _write_line(self, text: str = ""):
|
|
59
|
+
with self._lock:
|
|
60
|
+
was_running = self._spinner_running
|
|
61
|
+
if was_running:
|
|
62
|
+
self._clear_spinner_line()
|
|
63
|
+
print(text)
|
|
64
|
+
if was_running:
|
|
65
|
+
self._render_spinner_frame()
|
|
66
|
+
|
|
67
|
+
def _render_spinner_frame(self, frame: str = "⠋"):
|
|
68
|
+
# text = f"{frame} {self._spinner_message}"
|
|
69
|
+
text = f"{_c(frame, ORANGE)} {_c(self._spinner_message, ORANGE)}"
|
|
70
|
+
sys.stdout.write("\r" + text)
|
|
71
|
+
sys.stdout.flush()
|
|
72
|
+
|
|
73
|
+
def section(self, title):
|
|
74
|
+
self._write_line()
|
|
75
|
+
self._write_line(_c(f"• {title}", ORANGE))
|
|
76
|
+
|
|
77
|
+
def kv(self, data: Dict[str, Any]):
|
|
78
|
+
for k, v in data.items():
|
|
79
|
+
self._write_line(f"{_c(str(k), GREY)}{_c(': ', GREY)}{_c(str(v), GREY)}")
|
|
80
|
+
|
|
81
|
+
def tool_call(self, name: str, desc: str):
|
|
82
|
+
self.section("Tool Call")
|
|
83
|
+
self._write_line(f"{_c('› ', GREY)}{_c(name, CYAN)} {_c(desc, GREY)}")
|
|
84
|
+
|
|
85
|
+
def tool_result(self, ok=True, meta="applied"):
|
|
86
|
+
icon = "✓" if ok else "✗"
|
|
87
|
+
color = GREEN if ok else RED
|
|
88
|
+
suffix = f" {meta}" if meta else ""
|
|
89
|
+
self._write_line(f" {_c(icon, color)}{_c(suffix, GREY)}")
|
|
90
|
+
|
|
91
|
+
def success(self, msg: str):
|
|
92
|
+
self._write_line(f"{_c('✓ ', GREEN)}{_c(msg, GREY)}")
|
|
93
|
+
|
|
94
|
+
def error(self, msg: str):
|
|
95
|
+
self._write_line(f"{_c('✗ ', RED)}{_c(msg, GREY)}")
|
|
96
|
+
|
|
97
|
+
def warning(self, msg: str):
|
|
98
|
+
self._write_line(f"{_c('! ', YELLOW)}{_c(msg, GREY)}")
|
|
99
|
+
|
|
100
|
+
def text(self, msg: str):
|
|
101
|
+
self._write_line(_c(msg, GREY))
|
|
102
|
+
|
|
103
|
+
def separator(self):
|
|
104
|
+
self._write_line(f"{DIM}{'─' * min(os.get_terminal_size().columns, 80)}{RESET}")
|
|
105
|
+
|
|
106
|
+
def thinking(self, content: str):
|
|
107
|
+
self.section("Thinking")
|
|
108
|
+
for line in content.splitlines():
|
|
109
|
+
self._write_line(" " + _c(line, GREY))
|
|
110
|
+
|
|
111
|
+
def output(self, content: str):
|
|
112
|
+
self.section("Output")
|
|
113
|
+
for line in content.splitlines():
|
|
114
|
+
self._write_line(" " + _c(line, GREY))
|
|
115
|
+
|
|
116
|
+
def token_usage(self, iteration: int, input_tokens: int, output_tokens: int, context_tokens: int, max_context: int):
|
|
117
|
+
def fmt(n):
|
|
118
|
+
return f"{n / 1000:.1f}k" if n >= 1000 else str(n)
|
|
119
|
+
|
|
120
|
+
ratio = context_tokens / max_context if max_context else 0
|
|
121
|
+
percent = int(ratio * 100)
|
|
122
|
+
color = GREEN if percent < 50 else YELLOW if percent < 70 else RED
|
|
123
|
+
|
|
124
|
+
self._write_line()
|
|
125
|
+
self._write_line(
|
|
126
|
+
_c(f"round: {iteration} | tokens: {fmt(input_tokens)} in / {fmt(output_tokens)} out | ctx: ", GREY) +
|
|
127
|
+
_c(f"{percent}%", color))
|
|
128
|
+
|
|
129
|
+
def compact_status(self, before_tokens: int, after_tokens: int, max_context: int, strategy: str = "auto"):
|
|
130
|
+
saved = before_tokens - after_tokens
|
|
131
|
+
ratio = (after_tokens / max_context) if max_context else 0
|
|
132
|
+
percent = int(ratio * 100)
|
|
133
|
+
color = GREEN if percent < 50 else YELLOW if percent < 70 else RED
|
|
134
|
+
|
|
135
|
+
self.section("Compact")
|
|
136
|
+
self._write_line(f" {_c('strategy', GREY)} {_c(strategy, ORANGE)}")
|
|
137
|
+
self._write_line(
|
|
138
|
+
f" {_c('tokens', GREY)} "
|
|
139
|
+
f"{_c(f'{before_tokens:,}', RED)}"
|
|
140
|
+
f" {_c('→', GREY)} "
|
|
141
|
+
f"{_c(f'{after_tokens:,}', GREEN)} "
|
|
142
|
+
f"{_c(f'(-{saved:,})', ORANGE)}"
|
|
143
|
+
)
|
|
144
|
+
self._write_line(f" {_c('context', GREY)} {_c(f'{percent}%', color)}")
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def prompt_apply(message: str) -> bool:
|
|
148
|
+
while True:
|
|
149
|
+
resp = input(f"{YELLOW}{message} [y/n]: {RESET}").strip().lower()
|
|
150
|
+
if resp in ("y", "yes"):
|
|
151
|
+
return True
|
|
152
|
+
elif resp in ("n", "no"):
|
|
153
|
+
return False
|
|
154
|
+
else:
|
|
155
|
+
print("请输入 y 或 n")
|
|
156
|
+
|
|
157
|
+
def diff(self, old: str, new: str, context: int = 3, filename: str = "file.py"):
|
|
158
|
+
self.section("Code Diff")
|
|
159
|
+
old_lines = old.splitlines()
|
|
160
|
+
new_lines = new.splitlines()
|
|
161
|
+
|
|
162
|
+
diff_lines = difflib.unified_diff(
|
|
163
|
+
old_lines, new_lines, fromfile=f"a/{filename}", tofile=f"b/{filename}", lineterm="", n=context,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
for dl in diff_lines:
|
|
167
|
+
if dl.startswith("+") and not dl.startswith("+++"):
|
|
168
|
+
self._write_line(_c(dl, GREEN))
|
|
169
|
+
elif dl.startswith("-") and not dl.startswith("---"):
|
|
170
|
+
self._write_line(_c(dl, RED))
|
|
171
|
+
elif dl.startswith("@@"):
|
|
172
|
+
self._write_line(_c(dl, CYAN))
|
|
173
|
+
else:
|
|
174
|
+
self._write_line(_c(dl, GREY))
|
|
175
|
+
|
|
176
|
+
def start_spinner(self, message: str = "Running..."):
|
|
177
|
+
if self._spinner_running:
|
|
178
|
+
return
|
|
179
|
+
self._spinner_running = True
|
|
180
|
+
self._spinner_message = message
|
|
181
|
+
|
|
182
|
+
def run():
|
|
183
|
+
i = 0
|
|
184
|
+
while self._spinner_running:
|
|
185
|
+
with self._lock:
|
|
186
|
+
frame = self.SPINNER_FRAMES[i % len(self.SPINNER_FRAMES)]
|
|
187
|
+
self._render_spinner_frame(frame)
|
|
188
|
+
time.sleep(0.1)
|
|
189
|
+
i += 1
|
|
190
|
+
|
|
191
|
+
self._spinner_thread = threading.Thread(target=run, daemon=True)
|
|
192
|
+
self._spinner_thread.start()
|
|
193
|
+
|
|
194
|
+
def end_spinner(self):
|
|
195
|
+
if not self._spinner_running:
|
|
196
|
+
return
|
|
197
|
+
self._spinner_running = False
|
|
198
|
+
if self._spinner_thread:
|
|
199
|
+
self._spinner_thread.join()
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._clear_spinner_line()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
console = Printer()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --- i18n ---
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# --- Init dir, Base data ---
|
|
211
|
+
def initialize_system():
|
|
212
|
+
if not os.path.exists(base_persist_dir):
|
|
213
|
+
os.mkdir(base_persist_dir)
|
|
214
|
+
if not os.path.exists(session_dir):
|
|
215
|
+
os.mkdir(session_dir)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def helper():
|
|
219
|
+
console.text("Mango CLI — 基于大模型的命令行编程助手")
|
|
220
|
+
console.text("内置命令:")
|
|
221
|
+
console.text(" /q, /quit 退出程序")
|
|
222
|
+
console.text(" /c, /compact 手动压缩当前会话(释放上下文空间)")
|
|
223
|
+
console.text(" /n, /new 结束当前会话并创建一个全新的会话")
|
|
224
|
+
console.text(" /h, /help 显示本帮助信息")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- Utils function ---
|
|
228
|
+
def _check_command_safety(command: str):
|
|
229
|
+
# 1.文件删除命令, 2.系统格式化和分区操作,3.危险权限修改, 4.提权命令,5.危险进程操作,6.环境变量和系统配置,7.历史和日志清理
|
|
230
|
+
dangerous_patterns = [
|
|
231
|
+
(r'\brm\s+.*-[rf]', 1), (r'\brm\s+-[rf]', 1), (r'\bunlink\b', 1),
|
|
232
|
+
(r'\bmkfs\b', 2), (r'\bfdisk\b', 2), (r'\bparted\b', 2), (r'\bdd\s+.*if=.*of=', 2),
|
|
233
|
+
(r'\bchmod\s+(?:-[a-zA-Z]+\s+)*\d*7\d*7\b', 3), (r'\bchmod\s+777\b', 3), (r'\bchmod\s+\d*7\d*7\b', 3),
|
|
234
|
+
(r'\bchown\s+.*root\b', 3),
|
|
235
|
+
(r'\bsudo\s+.*rm\b', 4), (r'\bsu\s+-\b', 4), (r'\bsu\s+root\b', 4),
|
|
236
|
+
(r'\bkill\s+-9\s+1\b', 5), (r'\bkillall\s+-9\b', 5), (r'\bpkill\s+-9\b', 5), (r'\bkill\s+-9\s+-\d+\b', 5),
|
|
237
|
+
(r'\bexport\s+PATH=', 6), (r'\bunset\s+PATH\b', 6), (r'>>?\s*/etc/', 6), (r'\becho\s+.*>\s*/etc/', 6),
|
|
238
|
+
(r'\bhistory\s+-c\b', 7), (r'>\s*/dev/null\s+2>&1', 7),
|
|
239
|
+
]
|
|
240
|
+
command = command.strip()
|
|
241
|
+
if not command:
|
|
242
|
+
return False, None
|
|
243
|
+
for pattern, reason in dangerous_patterns:
|
|
244
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
245
|
+
return True, f"危险命令: {reason}"
|
|
246
|
+
return False, None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validate_file_path(path: str) -> Optional[str]:
|
|
250
|
+
""" 验证给定路径是否在项目根目录内, 返回 None 表示合法,否则返回错误描述字符串。"""
|
|
251
|
+
abs_path = os.path.abspath(path)
|
|
252
|
+
real_path = os.path.realpath(abs_path)
|
|
253
|
+
real_root = os.path.realpath(project_root)
|
|
254
|
+
if not real_path.startswith(real_root + os.sep) and real_path != real_root: # 必须位于项目根目录下
|
|
255
|
+
return f"path '{path}' is outside project root"
|
|
256
|
+
if os.path.isdir(real_path): # 不允许直接操作目录(write/edit 只能操作文件)
|
|
257
|
+
return f"path '{path}' is a directory, not a file"
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --- Tool definitions: (description, schema, function) ---
|
|
262
|
+
def read(args):
|
|
263
|
+
lines = open(args["path"]).readlines()
|
|
264
|
+
offset = args.get("offset", 0)
|
|
265
|
+
limit = args.get("limit", len(lines))
|
|
266
|
+
selected = lines[offset: offset + limit]
|
|
267
|
+
return "".join(f"{offset + idx + 1:4}| {line}" for idx, line in enumerate(selected))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def write(args):
|
|
271
|
+
error = _validate_file_path(args["path"])
|
|
272
|
+
if error:
|
|
273
|
+
return f"write error: {error}"
|
|
274
|
+
with open(args["path"], "w") as f:
|
|
275
|
+
f.write(args["content"])
|
|
276
|
+
return f"write {len(args['content'])}byte to {len(args['path'])} ok"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def edit(args):
|
|
280
|
+
error = _validate_file_path(args["path"])
|
|
281
|
+
if error:
|
|
282
|
+
return f"edit error: {error}"
|
|
283
|
+
text = open(args["path"]).read()
|
|
284
|
+
old, new = args["old"], args["new"]
|
|
285
|
+
if old not in text:
|
|
286
|
+
return "edit error: old_string not found"
|
|
287
|
+
count = text.count(old)
|
|
288
|
+
if not args.get("all") and count > 1:
|
|
289
|
+
return f"error: old_string appears {count} times, must be unique (use all=true)"
|
|
290
|
+
replacement = (text.replace(old, new) if args.get("all") else text.replace(old, new, 1))
|
|
291
|
+
with open(args["path"], "w") as f:
|
|
292
|
+
f.write(replacement)
|
|
293
|
+
return f"edit {len(args['path'])} ok"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def search(args):
|
|
297
|
+
pattern = (args.get("path", ".") + "/" + args["pat"]).replace("//", "/")
|
|
298
|
+
files = globlib.glob(pattern, recursive=True)
|
|
299
|
+
files = sorted(files, key=lambda f: os.path.getmtime(f) if os.path.isfile(f) else 0, reverse=True,)
|
|
300
|
+
return "\n".join(files) or "none"
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def grep(args):
|
|
304
|
+
pattern = re.compile(args["pat"])
|
|
305
|
+
hits = []
|
|
306
|
+
for filepath in glob.glob(args.get("path", ".") + "/**", recursive=True):
|
|
307
|
+
try:
|
|
308
|
+
for line_num, line in enumerate(open(filepath), 1):
|
|
309
|
+
if pattern.search(line):
|
|
310
|
+
hits.append(f"{filepath}:{line_num}:{line.rstrip()}")
|
|
311
|
+
except Exception as err:
|
|
312
|
+
return f"grep tool error: {err}"
|
|
313
|
+
return "\n".join(hits[:50]) or "none"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def bash(args):
|
|
317
|
+
proc = subprocess.Popen(args["cmd"], shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
318
|
+
output_lines = []
|
|
319
|
+
try:
|
|
320
|
+
while True:
|
|
321
|
+
line = proc.stdout.readline()
|
|
322
|
+
if not line and proc.poll() is not None:
|
|
323
|
+
break
|
|
324
|
+
if line:
|
|
325
|
+
output_lines.append(line)
|
|
326
|
+
proc.wait(timeout=60)
|
|
327
|
+
except subprocess.TimeoutExpired:
|
|
328
|
+
proc.kill()
|
|
329
|
+
output_lines.append("\n(timed out after 60s)")
|
|
330
|
+
return "".join(output_lines).strip() or "(empty)"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def attempt_completion(args):
|
|
334
|
+
return args["result"]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
TOOLS = {
|
|
338
|
+
"read": (
|
|
339
|
+
"Read a file from the local filesystem",
|
|
340
|
+
{
|
|
341
|
+
"path": {"type": "string", "description": "Path to the file to read"},
|
|
342
|
+
"offset": {"type": "number?", "description": "Line number to start reading from (0-indexed, default 0)"},
|
|
343
|
+
"limit": {"type": "number?", "description": "Maximum number of lines to read (default: all lines)"}
|
|
344
|
+
},
|
|
345
|
+
read,
|
|
346
|
+
),
|
|
347
|
+
"write": (
|
|
348
|
+
"Write content to a file, overwriting if it exists",
|
|
349
|
+
{
|
|
350
|
+
"path": {"type": "string", "description": "Path to the file to write"},
|
|
351
|
+
"content": {"type": "string", "description": "Content to write to the file"}
|
|
352
|
+
},
|
|
353
|
+
write,
|
|
354
|
+
),
|
|
355
|
+
"edit": (
|
|
356
|
+
"Edit a file by replacing an exact string with a new string",
|
|
357
|
+
{
|
|
358
|
+
"path": {"type": "string", "description": "Path to the file to edit"},
|
|
359
|
+
"old": {"type": "string", "description": "Exact string to be replaced"},
|
|
360
|
+
"new": {"type": "string", "description": "String to replace it with"},
|
|
361
|
+
"all": {"type": "boolean?", "description": "Replace all occurrences (default: false)"}
|
|
362
|
+
},
|
|
363
|
+
edit,
|
|
364
|
+
),
|
|
365
|
+
"search": (
|
|
366
|
+
"Search for files using a glob pattern",
|
|
367
|
+
{
|
|
368
|
+
"pat": {"type": "string", "description": "Glob pattern to match file paths (e.g. '**/*.py')"},
|
|
369
|
+
"path": {"type": "string?", "description": "Directory to start search from (default: current directory)"}
|
|
370
|
+
},
|
|
371
|
+
search,
|
|
372
|
+
),
|
|
373
|
+
"grep": (
|
|
374
|
+
"Search file contents recursively using a regular expression pattern",
|
|
375
|
+
{
|
|
376
|
+
"pat": {
|
|
377
|
+
"type": "string",
|
|
378
|
+
"description": "Regular expression pattern to search for (Python regex syntax)"},
|
|
379
|
+
"path": {
|
|
380
|
+
"type": "string?",
|
|
381
|
+
"description": "Search directory to recursively (defaults to current working directory if omitted)"}
|
|
382
|
+
},
|
|
383
|
+
grep,
|
|
384
|
+
),
|
|
385
|
+
"bash": (
|
|
386
|
+
"Execute a shell command and return its stdout/stderr output (timeout after 60s)",
|
|
387
|
+
{
|
|
388
|
+
"cmd": {"type": "string", "description": "The shell command to execute, e.g., 'ls -la' or 'git status'"}
|
|
389
|
+
},
|
|
390
|
+
bash,
|
|
391
|
+
),
|
|
392
|
+
"attempt_completion": (
|
|
393
|
+
"Indicate that the task is complete and provide the final result/answer to the user",
|
|
394
|
+
{
|
|
395
|
+
"result": {"type": "string", "description": "The final result or summary of the completed task"}
|
|
396
|
+
},
|
|
397
|
+
attempt_completion,
|
|
398
|
+
),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def tool_schema():
|
|
403
|
+
result = []
|
|
404
|
+
for name, (description, params, _fn) in TOOLS.items():
|
|
405
|
+
properties = {}
|
|
406
|
+
required = []
|
|
407
|
+
for param_name, param_info in params.items():
|
|
408
|
+
param_type = param_info['type']
|
|
409
|
+
is_optional = param_type.endswith("?")
|
|
410
|
+
base_type = param_type.rstrip("?")
|
|
411
|
+
properties[param_name] = {
|
|
412
|
+
"type": "integer" if base_type == "number" else base_type, "description": param_info['description']
|
|
413
|
+
}
|
|
414
|
+
if not is_optional:
|
|
415
|
+
required.append(param_name)
|
|
416
|
+
result.append(
|
|
417
|
+
{
|
|
418
|
+
"type": "function",
|
|
419
|
+
"function": {
|
|
420
|
+
"name": name,
|
|
421
|
+
"description": description,
|
|
422
|
+
"parameters": {
|
|
423
|
+
"type": "object",
|
|
424
|
+
"properties": properties,
|
|
425
|
+
"required": required,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# --- Context manager: () ---
|
|
434
|
+
class ContextManager:
|
|
435
|
+
def __init__(self):
|
|
436
|
+
self.messages: List[Dict] = []
|
|
437
|
+
self.white_tool_list = []
|
|
438
|
+
|
|
439
|
+
self.auto_compact_threshold = int(MANGO_MAX_CONTEXT * 0.8)
|
|
440
|
+
self.auto_compact_disabled = False
|
|
441
|
+
self.continuous_failures = 0
|
|
442
|
+
self.max_failures = 3
|
|
443
|
+
|
|
444
|
+
def __len__(self): return len(self.messages)
|
|
445
|
+
|
|
446
|
+
def disabled_compact(self): self.auto_compact_disabled = True
|
|
447
|
+
|
|
448
|
+
def enabled_compact(self): self.auto_compact_disabled = False
|
|
449
|
+
|
|
450
|
+
def set_max_failures(self, n: int = 3): self.max_failures = n
|
|
451
|
+
|
|
452
|
+
def clear(self): self.messages = []
|
|
453
|
+
|
|
454
|
+
def append_system(self, content: str): self.messages.append({"role": "system", "content": content})
|
|
455
|
+
|
|
456
|
+
def append_user(self, content: str):
|
|
457
|
+
self.messages.append({"role": "user", "content": content, "ts": int(time.time())})
|
|
458
|
+
|
|
459
|
+
def append_assistant(self, content: dict):
|
|
460
|
+
content.update({"ts": int(time.time())})
|
|
461
|
+
self.messages.append(content)
|
|
462
|
+
|
|
463
|
+
def append_tool(self, tool_call_id: str, content: str):
|
|
464
|
+
self.messages.append({"role": "tool", "tool_call_id": tool_call_id, "content": content, "ts": int(time.time())})
|
|
465
|
+
|
|
466
|
+
def load(self, persist_file: str):
|
|
467
|
+
if os.path.exists(persist_file):
|
|
468
|
+
try:
|
|
469
|
+
with open(persist_file, "r", encoding="utf-8") as f:
|
|
470
|
+
self.messages = json.load(f)
|
|
471
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
472
|
+
self.backup(persist_file) # 备份损坏会话文件
|
|
473
|
+
self.messages = [] # 清空消息列表,使后续流程以全新会话开始
|
|
474
|
+
console.error(f"session.json file is corrupted ({e}). "
|
|
475
|
+
f"The corrupted file has been backed up and a new session.json has been generated.")
|
|
476
|
+
|
|
477
|
+
def save(self, persist_file: str):
|
|
478
|
+
with open(persist_file, "w", encoding="utf-8") as fp:
|
|
479
|
+
fp.write(json.dumps(self.messages, indent=2, ensure_ascii=False))
|
|
480
|
+
|
|
481
|
+
@staticmethod
|
|
482
|
+
def backup(persist_file: str):
|
|
483
|
+
backup_path = persist_file + f".{str(int(time.time()))}.backup" # 备份会话文件
|
|
484
|
+
if os.path.exists(persist_file):
|
|
485
|
+
try:
|
|
486
|
+
os.rename(persist_file, backup_path)
|
|
487
|
+
console.warning(f"Session file backed up to {backup_path}")
|
|
488
|
+
except Exception as e:
|
|
489
|
+
console.warning(f"Failed to backup corrupted session file: {e}")
|
|
490
|
+
|
|
491
|
+
def get_messages(self) -> List[Dict[str, Any]]: return self.messages
|
|
492
|
+
|
|
493
|
+
def get_latest(self, n: int = 10) -> List[Dict]: return self.messages[-n:]
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def estimated_tokens(msg: Dict[str, Any]) -> int: # token 估算 (粗略)
|
|
497
|
+
content_len = len(msg.get("content", ""))
|
|
498
|
+
return content_len // 4 + 4
|
|
499
|
+
|
|
500
|
+
def total_tokens(self) -> int: return sum(self.estimated_tokens(m) for m in self.messages)
|
|
501
|
+
|
|
502
|
+
def auto_compact_if_needed(self):
|
|
503
|
+
if self.auto_compact_disabled:
|
|
504
|
+
return
|
|
505
|
+
if self.total_tokens() < self.auto_compact_threshold:
|
|
506
|
+
return
|
|
507
|
+
if self.continuous_failures >= self.max_failures:
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
try: # 尝试会话记忆压缩
|
|
511
|
+
success = self.session_memory_compact()
|
|
512
|
+
if success and self.total_tokens() < self.auto_compact_threshold:
|
|
513
|
+
self.continuous_failures = 0
|
|
514
|
+
return
|
|
515
|
+
except Exception as e:
|
|
516
|
+
self.continuous_failures += 1
|
|
517
|
+
|
|
518
|
+
try: # 回退传统压缩
|
|
519
|
+
self.compact_conversation()
|
|
520
|
+
self.continuous_failures = 0
|
|
521
|
+
except Exception as e:
|
|
522
|
+
self.continuous_failures += 1
|
|
523
|
+
|
|
524
|
+
def micro_compact(self, max_age_seconds: int = 21_600):
|
|
525
|
+
""" 扫描消息数组,查找来自可压缩工具白名单的 tool_result 块,并将其内容替换为 <Old tool result content cleared> """
|
|
526
|
+
now = int(time.time())
|
|
527
|
+
for m in self.messages: # 如果是工具消息且很旧 → 用占位符替换
|
|
528
|
+
if m.get("role") == "tool" and now - m.get("ts", now) > max_age_seconds:
|
|
529
|
+
m["content"] = "<Old tool result content expired(6hours)>"
|
|
530
|
+
|
|
531
|
+
def session_memory_compact(self, retain_count: int = 100) -> bool:
|
|
532
|
+
""" 保留最近用户 + 助手消息,剥离旧工具结果, 返回: True 压缩成功 """
|
|
533
|
+
new_msgs = []
|
|
534
|
+
for m in self.messages:
|
|
535
|
+
if m.get("role") == "system": # 先保留 system 消息
|
|
536
|
+
new_msgs.append(copy.deepcopy(m))
|
|
537
|
+
|
|
538
|
+
non_system = [m for m in self.messages if m.get("role") != "system"]
|
|
539
|
+
recent_msgs = non_system[-retain_count:]
|
|
540
|
+
for m in recent_msgs:
|
|
541
|
+
if m.get("role") == "tool":
|
|
542
|
+
m = copy.deepcopy(m)
|
|
543
|
+
m["content"] = "<Old tool result content compacted>"
|
|
544
|
+
new_msgs.append(m)
|
|
545
|
+
self.messages = new_msgs
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
def compact_conversation(self):
|
|
549
|
+
""" 剥离大附件, 工具输出等内容,用占位符替代旧内容,保证 token 降到阈值以下 """
|
|
550
|
+
new_msgs = []
|
|
551
|
+
for m in self.messages:
|
|
552
|
+
m_copy = copy.deepcopy(m)
|
|
553
|
+
role = m_copy.get("role")
|
|
554
|
+
if role == "tool" and len(m_copy.get("content", "")) > 200:
|
|
555
|
+
m_copy["content"] = "<Old tool result content removed>"
|
|
556
|
+
elif role == "assistant":
|
|
557
|
+
if len(m_copy.get("content", "")) > 500:
|
|
558
|
+
m_copy["content"] = "<Old assistant content removed>"
|
|
559
|
+
if len(m_copy.get("reasoning_content", "")) > 500:
|
|
560
|
+
m_copy["reasoning_content"] = "<Old assistant reasoning_content removed>"
|
|
561
|
+
new_msgs.append(m_copy)
|
|
562
|
+
|
|
563
|
+
systems = [m for m in new_msgs if m.get("role") == "system"]
|
|
564
|
+
others = [m for m in new_msgs if m.get("role") != "system"]
|
|
565
|
+
while sum(self.estimated_tokens(m) for m in systems + others) > self.auto_compact_threshold: # 如果还是超长, 从头删除旧消息
|
|
566
|
+
if not others: # 防止无限循环和 IndexError
|
|
567
|
+
break
|
|
568
|
+
others.pop(0)
|
|
569
|
+
|
|
570
|
+
self.messages = systems + others
|
|
571
|
+
|
|
572
|
+
def full_compact(self): # 手动执行,调用模型进行大规模的摘要生成,后续实现
|
|
573
|
+
pass
|
|
574
|
+
|
|
575
|
+
def prepare_for_api(self):
|
|
576
|
+
self.micro_compact()
|
|
577
|
+
before = self.total_tokens()
|
|
578
|
+
self.auto_compact_if_needed()
|
|
579
|
+
after = self.total_tokens()
|
|
580
|
+
if before > after:
|
|
581
|
+
console.compact_status(
|
|
582
|
+
before_tokens=before, after_tokens=after, max_context=MANGO_MAX_CONTEXT, strategy="auto")
|
|
583
|
+
return self.get_messages()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def chat_completion(messages: List[Dict[str, str]], timeout: int = 60, max_retries: int = 3):
|
|
587
|
+
extra_body = {"thinking": {"type": "enabled"}}
|
|
588
|
+
body = {
|
|
589
|
+
"model": MANGO_MODEL, "messages": messages, "stream": False, "extra_body": extra_body, "tools": tool_schema()
|
|
590
|
+
}
|
|
591
|
+
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {MANGO_KEY}"}
|
|
592
|
+
last_exception = None
|
|
593
|
+
request = urllib.request.Request(MANGO_API_URL, data=json.dumps(body).encode(), headers=headers, method="POST", )
|
|
594
|
+
for attempt in range(max_retries + 1):
|
|
595
|
+
try:
|
|
596
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
597
|
+
raw_data = response.read().decode("utf-8")
|
|
598
|
+
return json.loads(raw_data)
|
|
599
|
+
except urllib.error.HTTPError as e:
|
|
600
|
+
if e.code >= 500 or e.code == 429:
|
|
601
|
+
last_exception = e
|
|
602
|
+
else:
|
|
603
|
+
raise
|
|
604
|
+
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
|
605
|
+
last_exception = e
|
|
606
|
+
except Exception as e:
|
|
607
|
+
raise e
|
|
608
|
+
|
|
609
|
+
if attempt < max_retries:
|
|
610
|
+
delay = 1 * (2 ** attempt)
|
|
611
|
+
console.warning(
|
|
612
|
+
f"Request failed (attempt {attempt + 1}/{max_retries + 1}), retrying in {delay:.1f}s: {last_exception}"
|
|
613
|
+
)
|
|
614
|
+
time.sleep(delay)
|
|
615
|
+
else:
|
|
616
|
+
break # 所有重试均已耗尽,跳出循环并抛出最后一个异常
|
|
617
|
+
raise last_exception
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def parse_chat_completion(response: Dict[str, Any]) -> Dict[str, Any]:
|
|
621
|
+
choices = response.get("choices", [])
|
|
622
|
+
if not choices:
|
|
623
|
+
return {
|
|
624
|
+
"finish_reason": None,
|
|
625
|
+
"raw_message": {},
|
|
626
|
+
"content": "",
|
|
627
|
+
"reasoning_content": None,
|
|
628
|
+
"tool_calls": [],
|
|
629
|
+
"has_tool_calls": False,
|
|
630
|
+
"model": response.get("model", ""),
|
|
631
|
+
"usage": response.get("usage", {})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
choice = choices[0]
|
|
635
|
+
message = choice.get("message", {})
|
|
636
|
+
finish_reason = choice.get("finish_reason", "stop")
|
|
637
|
+
content = message.get("content", "") or "" # 提取文本内容
|
|
638
|
+
reasoning_content = message.get("reasoning_content", "") or "" # 提取推理内容
|
|
639
|
+
raw_tool_calls = message.get("tool_calls", []) # 处理工具调用
|
|
640
|
+
tool_calls = []
|
|
641
|
+
for tc in raw_tool_calls:
|
|
642
|
+
function = tc.get("function", {})
|
|
643
|
+
args_str = function.get("arguments", "{}")
|
|
644
|
+
try:
|
|
645
|
+
arguments = json.loads(args_str) if args_str else {}
|
|
646
|
+
except json.JSONDecodeError as e:
|
|
647
|
+
raise json.JSONDecodeError(f"工具调用参数响应非 JSON: {args_str[:200]}", e.doc, e.pos, ) from e
|
|
648
|
+
tool_calls.append({
|
|
649
|
+
"name": function.get("name", ""),
|
|
650
|
+
"arguments": arguments,
|
|
651
|
+
"id": tc.get("id", ""),
|
|
652
|
+
"type": tc.get("type", "function")
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
"finish_reason": finish_reason,
|
|
657
|
+
"raw_message": message,
|
|
658
|
+
"content": content,
|
|
659
|
+
"reasoning_content": reasoning_content,
|
|
660
|
+
"tool_calls": tool_calls,
|
|
661
|
+
"has_tool_calls": bool(tool_calls),
|
|
662
|
+
"model": response.get("model", ""),
|
|
663
|
+
"usage": response.get("usage", {})
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def run_tool(tool_name, tool_args):
|
|
668
|
+
try:
|
|
669
|
+
arg_preview = str(list(tool_args.values())[0])[:50]
|
|
670
|
+
console.tool_call(tool_name, arg_preview)
|
|
671
|
+
|
|
672
|
+
if tool_name == "edit":
|
|
673
|
+
console.diff(old=tool_args["old"], new=tool_args["new"])
|
|
674
|
+
if console.prompt_apply(f"Apply changes to {tool_args['path']}?"):
|
|
675
|
+
result = TOOLS[tool_name][2](tool_args)
|
|
676
|
+
else:
|
|
677
|
+
result = "error: User denied edit"
|
|
678
|
+
elif tool_name == "bash":
|
|
679
|
+
is_dangerous, reason = _check_command_safety(tool_args["cmd"])
|
|
680
|
+
if is_dangerous and not console.prompt_apply(f"Execute dangerous cmd ({reason})? {tool_args['cmd']}"):
|
|
681
|
+
result = "error: User denied dangerous command"
|
|
682
|
+
else:
|
|
683
|
+
console.start_spinner()
|
|
684
|
+
result = TOOLS[tool_name][2](tool_args)
|
|
685
|
+
console.end_spinner()
|
|
686
|
+
else:
|
|
687
|
+
console.start_spinner()
|
|
688
|
+
result = TOOLS[tool_name][2](tool_args)
|
|
689
|
+
console.end_spinner()
|
|
690
|
+
|
|
691
|
+
if not result:
|
|
692
|
+
print(f" {DIM}⎿ (no output){RESET}") # 空结果直接提示
|
|
693
|
+
else:
|
|
694
|
+
result_lines = result.split("\n")
|
|
695
|
+
max_preview_lines = 20 # 最多展示前3行
|
|
696
|
+
max_line_width = 100 # 单行最大宽度,超出截断
|
|
697
|
+
lines_to_show = result_lines[:max_preview_lines]
|
|
698
|
+
preview_lines = []
|
|
699
|
+
for line in lines_to_show:
|
|
700
|
+
if len(line) > max_line_width:
|
|
701
|
+
line = line[:max_line_width - 3] + "..."
|
|
702
|
+
preview_lines.append(line)
|
|
703
|
+
if len(result_lines) > max_preview_lines:
|
|
704
|
+
more = len(result_lines) - max_preview_lines
|
|
705
|
+
preview_lines.append(f"... and {more} more line{'s' if more > 1 else ''}")
|
|
706
|
+
prefix = f" {DIM}⎿ "
|
|
707
|
+
for i, line in enumerate(preview_lines):
|
|
708
|
+
if i == 0:
|
|
709
|
+
print(f"{prefix}{line}{RESET}")
|
|
710
|
+
else:
|
|
711
|
+
print(f" {DIM}{line}{RESET}") # 后续行与第一行内容对齐(5个空格 + 颜色)
|
|
712
|
+
|
|
713
|
+
console.tool_result(True)
|
|
714
|
+
return result
|
|
715
|
+
except Exception as err:
|
|
716
|
+
return f"error: {err}"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
class SystemPrompt:
|
|
720
|
+
""" 分层装配的提示词运行时. 可根据会话状态、记忆、环境变量等动态生成完整的 system prompt."""
|
|
721
|
+
def __init__(self):
|
|
722
|
+
self.sections = [] # 有序的 section 列表,每个元素为 (section_name, content)
|
|
723
|
+
self._init_default_sections() # 默认加载基础 sections
|
|
724
|
+
|
|
725
|
+
def _init_default_sections(self):
|
|
726
|
+
self.sections.append(("base_intro", self._build_base_intro()))
|
|
727
|
+
self.sections.append(("tool_guidance", self._build_tool_guidance()))
|
|
728
|
+
self.sections.append(("safety", self._build_safety()))
|
|
729
|
+
self.sections.append(("language", self._build_language()))
|
|
730
|
+
self.sections.append(("memory", self._build_memory()))
|
|
731
|
+
self.sections.append(("environment", self._build_environment()))
|
|
732
|
+
|
|
733
|
+
@staticmethod
|
|
734
|
+
def _build_base_intro() -> list[str]: # 基础身份和核心约束
|
|
735
|
+
return [
|
|
736
|
+
"You are an interactive agent that helps users with software engineering tasks. Use the instructions "
|
|
737
|
+
"below and the tools available to you to assist the user.",
|
|
738
|
+
"",
|
|
739
|
+
"IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are "
|
|
740
|
+
"for helping the user with programming. For file paths, always prefer absolute paths when possible. If "
|
|
741
|
+
"you need to read a directory, use the bash tool (ls) because the read tool cannot read directories.",
|
|
742
|
+
]
|
|
743
|
+
|
|
744
|
+
@staticmethod
|
|
745
|
+
def _build_tool_guidance() -> list[str]: # 工具使用指导
|
|
746
|
+
return [
|
|
747
|
+
"## Tool Selection Guidelines",
|
|
748
|
+
"You have access to the following dedicated tools: read/write/edit/search/grep/bash/attempt_completion.",
|
|
749
|
+
"",
|
|
750
|
+
"- For reading files: use **read**.",
|
|
751
|
+
"- For writing or overwriting files: use **write**.",
|
|
752
|
+
|
|
753
|
+
"- For replacing exact strings within a file: use **edit**. Prefer edit when you only need to change a "
|
|
754
|
+
"small portion of a file.",
|
|
755
|
+
|
|
756
|
+
"- For searching file names/paths: use **search** with a glob pattern.",
|
|
757
|
+
"- For searching file content with regex: use **grep**.",
|
|
758
|
+
|
|
759
|
+
"- Only use **bash** when no dedicated tool can accomplish the task, or for system commands (e.g., "
|
|
760
|
+
"installing packages, running tests, managing directories).",
|
|
761
|
+
|
|
762
|
+
"- Always use **attempt_completion** to present the final result to the user.",
|
|
763
|
+
"- When using edit, ensure the `old` string is unique or set `all` to true.",
|
|
764
|
+
]
|
|
765
|
+
|
|
766
|
+
@staticmethod
|
|
767
|
+
def _build_environment() -> list[str]: # 动态环境信息注入
|
|
768
|
+
os_info = f"{platform.system()} {platform.release()} ({platform.machine()})"
|
|
769
|
+
python_ver = sys.version.split()[0]
|
|
770
|
+
|
|
771
|
+
return [
|
|
772
|
+
"## Environment",
|
|
773
|
+
"",
|
|
774
|
+
f"- Current date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
775
|
+
f"- Working directory: {project_root}",
|
|
776
|
+
f"- Operating system: {os_info}",
|
|
777
|
+
f"- Python version: {python_ver}",
|
|
778
|
+
f"- Shell: {os.environ.get('SHELL', 'unknown')}",
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
@staticmethod
|
|
782
|
+
def _build_language() -> list[str]:
|
|
783
|
+
"""语言偏好(可通过环境变量 MANGO_LANG 配置), 若未设置则默认使用 English. """
|
|
784
|
+
lang = os.environ.get("MANGO_LANG", "zh")
|
|
785
|
+
if lang.lower() == "chinese" or lang.lower() == "zh":
|
|
786
|
+
return ["## Language", f"You should communicate with the user in Chinese (Simplified)."]
|
|
787
|
+
return ["## Language", f"You should communicate with the user in {lang}."]
|
|
788
|
+
|
|
789
|
+
@staticmethod
|
|
790
|
+
def _build_memory() -> list[str]:
|
|
791
|
+
""" 记忆加载, .mangocli/MEMORY.md 存在,则将其内容作为记忆注入. """
|
|
792
|
+
memory_path = os.path.join(project_root, ".mangocli", "MANGO.md")
|
|
793
|
+
if not os.path.exists(memory_path):
|
|
794
|
+
return ["## Memory", "No persistent memory available."]
|
|
795
|
+
if os.path.getsize(memory_path) == 0:
|
|
796
|
+
return ["## Memory", "No persistent memory available."]
|
|
797
|
+
content = open(memory_path, "r", encoding="utf-8").readlines()
|
|
798
|
+
return [f"## Persisted Memory", ""] + content
|
|
799
|
+
|
|
800
|
+
@staticmethod
|
|
801
|
+
def _build_safety() -> list[str]:
|
|
802
|
+
""" 安全边界提示. 要求模型在执行前对危险命令进行确认,并遵守工具的安全检查。"""
|
|
803
|
+
return [
|
|
804
|
+
"## Safety",
|
|
805
|
+
"",
|
|
806
|
+
"- Before executing any command that modifies the file system, deletes files, changes permissions, "
|
|
807
|
+
"or performs system administration, you MUST ensure the command is safe and the user has confirmed if "
|
|
808
|
+
"necessary.",
|
|
809
|
+
"- Do not attempt to access files outside the project root unless explicitly required and confirmed by "
|
|
810
|
+
"the user.",
|
|
811
|
+
]
|
|
812
|
+
|
|
813
|
+
def assemble(self) -> str: # 将所有 section 按顺序拼接成完整的 system prompt。
|
|
814
|
+
_basic = []
|
|
815
|
+
for _, content in self.sections:
|
|
816
|
+
_basic.append("\n".join(content))
|
|
817
|
+
return "\n\n".join(_basic)
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def main():
|
|
821
|
+
initialize_system()
|
|
822
|
+
|
|
823
|
+
print(f"{BOLD}Mango Cli v{__version__}{RESET} | {DIM}{MANGO_MODEL} | {project_root}{RESET}\n")
|
|
824
|
+
|
|
825
|
+
ctx_file_path = os.path.join(session_dir, "session.json")
|
|
826
|
+
ctx = ContextManager()
|
|
827
|
+
ctx.enabled_compact()
|
|
828
|
+
ctx.set_max_failures()
|
|
829
|
+
ctx.load(ctx_file_path)
|
|
830
|
+
|
|
831
|
+
prompt_runtime = SystemPrompt()
|
|
832
|
+
system_prompt = prompt_runtime.assemble()
|
|
833
|
+
if len(ctx) == 0: # 刚初始化的ctx才需要system prompt
|
|
834
|
+
ctx.append_system(system_prompt)
|
|
835
|
+
|
|
836
|
+
while True:
|
|
837
|
+
try:
|
|
838
|
+
console.separator()
|
|
839
|
+
user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
|
|
840
|
+
if not user_input:
|
|
841
|
+
continue
|
|
842
|
+
if user_input.startswith('/'):
|
|
843
|
+
if user_input.strip() in ("/q", "/quit"): # 退出
|
|
844
|
+
break
|
|
845
|
+
if user_input.strip() in ("/c", "/compact"): # 手动触发 full compact
|
|
846
|
+
continue
|
|
847
|
+
if user_input.strip() in ("/n", "/new"): # 创建新的session
|
|
848
|
+
ctx.backup(ctx_file_path)
|
|
849
|
+
ctx.clear()
|
|
850
|
+
ctx.append_system(system_prompt)
|
|
851
|
+
console.success("New session created.")
|
|
852
|
+
continue
|
|
853
|
+
if user_input.strip() in ("/h", "/help"):
|
|
854
|
+
helper()
|
|
855
|
+
continue
|
|
856
|
+
|
|
857
|
+
ctx.append_user(user_input)
|
|
858
|
+
|
|
859
|
+
# agentic loop: keep calling API until no more tool calls
|
|
860
|
+
iteration = 0
|
|
861
|
+
while True:
|
|
862
|
+
console.start_spinner("Request...")
|
|
863
|
+
response = parse_chat_completion(chat_completion(ctx.prepare_for_api()))
|
|
864
|
+
console.end_spinner()
|
|
865
|
+
ctx.append_assistant(response["raw_message"])
|
|
866
|
+
|
|
867
|
+
iteration += 1
|
|
868
|
+
console.token_usage(
|
|
869
|
+
iteration=iteration,
|
|
870
|
+
input_tokens=response["usage"]["prompt_tokens"],
|
|
871
|
+
output_tokens=response["usage"]["completion_tokens"],
|
|
872
|
+
context_tokens=ctx.total_tokens(),
|
|
873
|
+
max_context=MANGO_MAX_CONTEXT)
|
|
874
|
+
|
|
875
|
+
if response["content"]:
|
|
876
|
+
console.output(response["content"])
|
|
877
|
+
if response["reasoning_content"]:
|
|
878
|
+
console.thinking(response["reasoning_content"])
|
|
879
|
+
|
|
880
|
+
if response["finish_reason"] == "stop":
|
|
881
|
+
break # 模型明确表示结束,退出循环
|
|
882
|
+
if response["has_tool_calls"]:
|
|
883
|
+
tool_calls = response["tool_calls"]
|
|
884
|
+
for tool in tool_calls:
|
|
885
|
+
tool_name, tool_args = tool["name"], tool["arguments"]
|
|
886
|
+
result = run_tool(tool_name, tool_args)
|
|
887
|
+
ctx.append_tool(tool["id"], result)
|
|
888
|
+
if any(tc["name"] == "attempt_completion" for tc in tool_calls):
|
|
889
|
+
break
|
|
890
|
+
else:
|
|
891
|
+
break
|
|
892
|
+
ctx.save(ctx_file_path)
|
|
893
|
+
except (KeyboardInterrupt, EOFError):
|
|
894
|
+
break
|
|
895
|
+
except Exception as err:
|
|
896
|
+
print(f"{RED}⏺ Error: {err}{RESET}")
|
|
897
|
+
finally:
|
|
898
|
+
ctx.save(ctx_file_path)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
if __name__ == '__main__':
|
|
902
|
+
main()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: mangopi-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Mango Pi Cli
|
|
5
|
+
Author: moofs
|
|
6
|
+
License: Apache License 2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/w4n9H/mangocli
|
|
8
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
|
|
18
|
+
# Mango CLI
|
|
19
|
+
|
|
20
|
+
> A lightweight AI coding assistant running directly in your terminal.
|
|
21
|
+
|
|
22
|
+
Mango CLI is a zero-dependency, local-first AI coding assistant inspired by Claude Code.
|
|
23
|
+
|
|
24
|
+
It supports:
|
|
25
|
+
|
|
26
|
+
* AI-powered coding workflows
|
|
27
|
+
* File editing and shell execution
|
|
28
|
+
* Tool calling
|
|
29
|
+
* Context-aware conversation management
|
|
30
|
+
* Automatic context compacting
|
|
31
|
+
|
|
32
|
+
All with instant startup and no heavy framework dependencies.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
# Features
|
|
37
|
+
|
|
38
|
+
* Zero dependency (Python standard library only)
|
|
39
|
+
* Instant startup
|
|
40
|
+
* Claude Code–style terminal UX
|
|
41
|
+
* Built-in file and shell tools
|
|
42
|
+
* Automatic context compacting
|
|
43
|
+
* Local session persistence
|
|
44
|
+
* Fully hackable and easy to extend
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
# Installation
|
|
49
|
+
|
|
50
|
+
## From PyPI
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install mangocli
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Start Mango CLI:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
mango-cli
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## From Source
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone git@github.com:w4n9H/mangocli.git
|
|
68
|
+
cd mangocli
|
|
69
|
+
python mango_cli.py
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
# Configuration
|
|
75
|
+
|
|
76
|
+
Set your API configuration:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
export MANGO_KEY="your_api_key"
|
|
80
|
+
export MANGO_API_URL="https://api.deepseek.com/chat/completions"
|
|
81
|
+
export MANGO_MODEL="deepseek-v4-flash"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Optional:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
export MANGO_MAX_CONTEXT=1000000
|
|
88
|
+
export MANGO_LANG=zh
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
# Usage
|
|
94
|
+
|
|
95
|
+
Start the CLI:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
mango-cli
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
or:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python mango_cli.py
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Built-in commands:
|
|
108
|
+
|
|
109
|
+
| Command | Description |
|
|
110
|
+
|---------|-----------------|
|
|
111
|
+
| `/q` | Quit |
|
|
112
|
+
| `/n` | New session |
|
|
113
|
+
| `/c` | Compact session |
|
|
114
|
+
| `/h` | Help |
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# Built-in Tools
|
|
119
|
+
|
|
120
|
+
* `read`
|
|
121
|
+
* `write`
|
|
122
|
+
* `edit`
|
|
123
|
+
* `search`
|
|
124
|
+
* `grep`
|
|
125
|
+
* `bash`
|
|
126
|
+
|
|
127
|
+
Mango CLI can autonomously inspect files, modify code, search projects, and execute shell commands.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
# Philosophy
|
|
132
|
+
|
|
133
|
+
Mango CLI focuses on:
|
|
134
|
+
|
|
135
|
+
* fast startup
|
|
136
|
+
* zero dependency
|
|
137
|
+
* local-first workflows
|
|
138
|
+
* terminal-native AI interaction
|
|
139
|
+
* lightweight runtime design
|
|
140
|
+
|
|
141
|
+
No Electron, Docker, Redis, or heavyweight AI frameworks.
|
|
142
|
+
|
|
143
|
+
Just a fast and hackable AI coding assistant for the terminal.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
# License
|
|
148
|
+
|
|
149
|
+
Apache License 2.0
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mango_cli.py,sha256=yXWIBJKTNVoQEdQYGEgDmJ2heOY4VHl7ZLH4re69YxE,35822
|
|
2
|
+
mangopi_cli-0.1.1.dist-info/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
3
|
+
mangopi_cli-0.1.1.dist-info/METADATA,sha256=nbwc0HfHuW7mnC6-Zp9gOJ2BTPh-Hy8R2jSrxs8z1FE,2528
|
|
4
|
+
mangopi_cli-0.1.1.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
|
5
|
+
mangopi_cli-0.1.1.dist-info/entry_points.txt,sha256=lOBRtOJ_3oD3oZd8zJ8tYmQCfKhxpuBVYnHFxULMyqY,44
|
|
6
|
+
mangopi_cli-0.1.1.dist-info/top_level.txt,sha256=BRbs5YtcUKqii3EUXZQIglwjDS5wELo6vgnYjrDqgOY,10
|
|
7
|
+
mangopi_cli-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mango_cli
|