Justin-lee commited on
Commit
683cfca
·
verified ·
1 Parent(s): d13d68e

Complete: P0+P1+P2+Skills (2189 lines, 22 features)

Browse files
Files changed (1) hide show
  1. codepilot_v4.py +713 -17
codepilot_v4.py CHANGED
@@ -340,6 +340,9 @@ class ProjectTools:
340
  def read_file(self, path, offset=1, limit=200):
341
  full = self._resolve(path)
342
  if not os.path.exists(full): return f"❌ 不存在: {path}"
 
 
 
343
  try:
344
  content = Path(full).read_text(encoding="utf-8", errors="replace"); lines = content.splitlines()
345
  self.read_cache[full] = {"time": os.path.getmtime(full), "content": content}
@@ -369,8 +372,12 @@ class ProjectTools:
369
  return f"✅ {'建立' if is_new else '覆寫'}: {path}"
370
 
371
  def run_command(self, command, timeout=120):
372
- for d in {"rm -rf /", "git push --force", "git reset --hard"}:
373
- if d in command: return f"⛔ 危險: {command}"
 
 
 
 
374
  try:
375
  r = subprocess.run(command, shell=True, cwd=self.cwd, capture_output=True, text=True, timeout=timeout)
376
  return (r.stdout + (f"\nSTDERR:\n{r.stderr}" if r.stderr else ""))[:10000]
@@ -442,9 +449,278 @@ def execute_tool(tools, call):
442
  elif n == "search_files": result = tools.search_files(p.get("pattern",""), p.get("glob"))
443
  elif n == "list_files": result = tools.list_files(p.get("pattern","*"), int(p.get("max_depth",3)))
444
  elif n == "git_status": result = tools.git_context()
 
 
445
  else: result = f"❌ 未知: {n}"
446
  except Exception as e: result = f"❌ {e}"
447
- return truncate_tool_result(result) # P0-2: 自動截斷
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
 
449
 
450
  # ============================================================
@@ -657,6 +933,341 @@ class Hooks:
657
  return None
658
 
659
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  # ============================================================
661
  # P1-1: APPROVAL SYSTEM(權限/審批)
662
  # ============================================================
@@ -1097,15 +1708,18 @@ def run_agent_loop(args):
1097
  messages = [{"role": "system", "content": system_prompt}]
1098
 
1099
  hooks = Hooks(project_dir)
1100
- bg_tasks = BackgroundTaskManager() # P1-2: 背景任務
1101
- custom_agents = load_custom_agents(project_dir) # P1-3: 自訂代理
1102
- approval_mode = args.approval or "auto" # P1-1: 審批模式
 
 
 
1103
  edited_files_this_session = []
1104
 
1105
  if custom_agents:
1106
  console.print(f"[dim]🤖 自訂代理: {', '.join(custom_agents.keys())}[/]")
1107
 
1108
- console.print(f"[dim]指令: /init /verify /commit /agent /bg /approval | /duel /memo /grind /ls /git /clear /status /train /quit[/]\n")
1109
 
1110
  while True:
1111
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
@@ -1188,6 +1802,46 @@ def run_agent_loop(args):
1188
  auto_git_commit(tools, model, edited_files_this_session, console)
1189
  continue
1190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1191
  elif cmd.startswith("/agent"):
1192
  # P1-3: 自訂代理
1193
  parts = cmd.split(None, 2)
@@ -1245,6 +1899,42 @@ def run_agent_loop(args):
1245
  console.print("[dim]/bg list | /bg run <cmd> | /bg check <id> | /bg kill <id>[/]")
1246
  continue
1247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1248
  elif cmd.startswith("/approval"):
1249
  # P1-1: 切換審批模式
1250
  parts = cmd.split()
@@ -1339,19 +2029,23 @@ def run_agent_loop(args):
1339
  tools_used_this_turn = [] # 追蹤這輪用了哪些工具
1340
 
1341
  for rnd in range(10):
1342
- with console.status(f"[bold cyan]{'思考中' if rnd == 0 else f'工具 round {rnd+1}'}..."):
1343
- try:
1344
- # P0-3: 使用帶恢復的 chat
1345
- response = chat_with_recovery(
1346
- model, messages, ctx=ctx, console=console,
1347
- fallback_model=local_model_ref if provider_key != "local" else None)
1348
- except Exception as e:
1349
- console.print(f"[red]❌ 所有重試失敗: {e}[/]")
1350
- break
 
 
 
1351
 
1352
  tool_calls = parse_tool_calls(response)
1353
  text_parts = TOOL_PATTERN.sub("", response).strip()
1354
- if text_parts:
 
1355
  console.print(f"\n[bold blue]🤖 CodePilot:[/]")
1356
  console.print(Markdown(text_parts))
1357
  full_response += response + "\n"
@@ -1427,6 +2121,7 @@ def run_agent_loop(args):
1427
 
1428
  ctx.save_session(messages)
1429
 
 
1430
  console.print("\n[cyan]👋[/]")
1431
 
1432
 
@@ -1483,6 +2178,7 @@ def main():
1483
  p.add_argument("--distill", action="store_true")
1484
  p.add_argument("--grind", action="store_true", help="LeetCode 自動刷題")
1485
  p.add_argument("--grind-count", type=int, default=100, help="刷幾題")
 
1486
  p.add_argument("--stats", action="store_true"); p.add_argument("--train", action="store_true")
1487
  a = p.parse_args()
1488
  if a.stats: show_stats()
 
340
  def read_file(self, path, offset=1, limit=200):
341
  full = self._resolve(path)
342
  if not os.path.exists(full): return f"❌ 不存在: {path}"
343
+ # P2-3: 多模態檔案
344
+ mm = read_multimodal(full)
345
+ if mm is not None: return mm
346
  try:
347
  content = Path(full).read_text(encoding="utf-8", errors="replace"); lines = content.splitlines()
348
  self.read_cache[full] = {"time": os.path.getmtime(full), "content": content}
 
372
  return f"✅ {'建立' if is_new else '覆寫'}: {path}"
373
 
374
  def run_command(self, command, timeout=120):
375
+ # P2-4: 安全分類器
376
+ safety, reason = classify_command(command)
377
+ if safety == "block":
378
+ return f"⛔ 危險指令被阻擋: {command}\n原因: {reason}"
379
+ if safety == "warn":
380
+ return f"⚠️ 警告: {reason}\n指令: {command}\n(在 --approval ask 模式下會要求確認)"
381
  try:
382
  r = subprocess.run(command, shell=True, cwd=self.cwd, capture_output=True, text=True, timeout=timeout)
383
  return (r.stdout + (f"\nSTDERR:\n{r.stderr}" if r.stderr else ""))[:10000]
 
449
  elif n == "search_files": result = tools.search_files(p.get("pattern",""), p.get("glob"))
450
  elif n == "list_files": result = tools.list_files(p.get("pattern","*"), int(p.get("max_depth",3)))
451
  elif n == "git_status": result = tools.git_context()
452
+ elif n == "web_fetch": result = web_fetch(p.get("url","")) # P2-1
453
+ elif n == "web_search": result = web_search(p.get("query","")) # P2-1
454
  else: result = f"❌ 未知: {n}"
455
  except Exception as e: result = f"❌ {e}"
456
+ return truncate_tool_result(result)
457
+
458
+
459
+ # ============================================================
460
+ # P2-1: WEB FETCH / WEB SEARCH
461
+ # ============================================================
462
+ def web_fetch(url, max_chars=8000):
463
+ """讀取網頁內容(去掉 HTML 標籤)"""
464
+ try:
465
+ if not httpx: return "❌ 請安裝 httpx: pip install httpx"
466
+ resp = httpx.get(url, timeout=15, follow_redirects=True,
467
+ headers={"User-Agent": "CodePilot/1.0"})
468
+ resp.raise_for_status()
469
+ content = resp.text
470
+ # 簡易去 HTML 標籤
471
+ content = re.sub(r'<script[^>]*>.*?</script>', '', content, flags=re.DOTALL)
472
+ content = re.sub(r'<style[^>]*>.*?</style>', '', content, flags=re.DOTALL)
473
+ content = re.sub(r'<[^>]+>', ' ', content)
474
+ content = re.sub(r'\s+', ' ', content).strip()
475
+ return content[:max_chars]
476
+ except Exception as e:
477
+ return f"❌ 抓取失敗: {e}"
478
+
479
+ def web_search(query, max_results=5):
480
+ """網路搜尋(使用 DuckDuckGo HTML,不需要 API key)"""
481
+ try:
482
+ if not httpx: return "❌ 請安裝 httpx: pip install httpx"
483
+ resp = httpx.get("https://html.duckduckgo.com/html/",
484
+ params={"q": query}, timeout=10,
485
+ headers={"User-Agent": "CodePilot/1.0"})
486
+ # 提取搜尋結果
487
+ results = []
488
+ for match in re.finditer(r'<a[^>]+href="(https?://[^"]+)"[^>]*class="result__a"[^>]*>(.*?)</a>', resp.text, re.DOTALL):
489
+ url = match.group(1)
490
+ title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
491
+ results.append(f"- [{title}]({url})")
492
+ if len(results) >= max_results: break
493
+ # 也嘗試提取摘要
494
+ for match in re.finditer(r'<a[^>]+class="result__snippet"[^>]*>(.*?)</a>', resp.text, re.DOTALL):
495
+ snippet = re.sub(r'<[^>]+>', '', match.group(1)).strip()
496
+ if snippet and len(results) > 0:
497
+ idx = min(len(results)-1, len([r for r in results if not r.startswith(" ")]) - 1)
498
+ if idx >= 0: results.insert(idx+1, f" {snippet[:150]}")
499
+ return "\n".join(results) if results else f"無搜尋結果: {query}"
500
+ except Exception as e:
501
+ return f"❌ 搜尋失敗: {e}"
502
+
503
+
504
+ # ============================================================
505
+ # P2-2: STREAMING OUTPUT(逐字輸出)
506
+ # ============================================================
507
+ def stream_local_chat(model, messages, console, max_tokens=4096):
508
+ """本地模型 streaming — 逐 token 顯示"""
509
+ if not hasattr(model, 'tokenizer') or not hasattr(model, 'model'):
510
+ return model.chat(messages, max_tokens) # 非本地模型 fallback
511
+
512
+ from transformers import TextIteratorStreamer
513
+ import threading
514
+
515
+ text = model.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
516
+ inputs = model.tokenizer(text, return_tensors="pt").to(model.model.device)
517
+
518
+ streamer = TextIteratorStreamer(model.tokenizer, skip_prompt=True, skip_special_tokens=True)
519
+ gen_kwargs = dict(**inputs, max_new_tokens=max_tokens, do_sample=True,
520
+ temperature=0.7, top_p=0.9, repetition_penalty=1.1,
521
+ pad_token_id=model.tokenizer.pad_token_id, streamer=streamer)
522
+
523
+ thread = threading.Thread(target=model.model.generate, kwargs=gen_kwargs)
524
+ thread.start()
525
+
526
+ console.print(f"\n[bold blue]🤖 CodePilot:[/]", end="")
527
+ full_text = ""
528
+ for chunk in streamer:
529
+ print(chunk, end="", flush=True)
530
+ full_text += chunk
531
+ print() # newline
532
+ thread.join()
533
+ return full_text
534
+
535
+
536
+ # ============================================================
537
+ # P2-3: MULTIMODAL(圖片/PDF 讀取)
538
+ # ============================================================
539
+ def read_multimodal(path):
540
+ """讀取圖片/PDF/notebook 的文字描述"""
541
+ ext = Path(path).suffix.lower()
542
+
543
+ if ext in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"):
544
+ # 圖片:回傳檔案資訊
545
+ try:
546
+ size = os.path.getsize(path)
547
+ return f"[Image: {path}, {size/1024:.0f}KB, {ext}]\n(圖片內容無法在文字模式顯示。如需分析圖片,請使用支援多模態的雲端模型。)"
548
+ except: return f"❌ 無法讀取圖片: {path}"
549
+
550
+ elif ext == ".pdf":
551
+ # PDF:嘗試用 pdfminer 或 fallback
552
+ try:
553
+ from pdfminer.high_level import extract_text
554
+ text = extract_text(path, maxpages=20)
555
+ return f"[PDF: {path}, {len(text)} chars extracted]\n\n{text[:10000]}"
556
+ except ImportError:
557
+ try:
558
+ # fallback: pdftotext 指令
559
+ r = subprocess.run(["pdftotext", "-l", "20", path, "-"],
560
+ capture_output=True, text=True, timeout=30)
561
+ return f"[PDF: {path}]\n\n{r.stdout[:10000]}"
562
+ except:
563
+ return f"[PDF: {path}] (安裝 pdfminer.six 以讀取: pip install pdfminer.six)"
564
+
565
+ elif ext == ".ipynb":
566
+ # Jupyter Notebook:提取 code cells 和 markdown
567
+ try:
568
+ nb = json.loads(Path(path).read_text())
569
+ cells = nb.get("cells", [])
570
+ output = []
571
+ for i, cell in enumerate(cells[:30]):
572
+ ctype = cell.get("cell_type", "")
573
+ source = "".join(cell.get("source", []))
574
+ if ctype == "markdown":
575
+ output.append(f"[Markdown Cell {i+1}]\n{source}")
576
+ elif ctype == "code":
577
+ output.append(f"[Code Cell {i+1}]\n```python\n{source}\n```")
578
+ return "\n\n".join(output)[:10000]
579
+ except Exception as e:
580
+ return f"❌ 無法讀取 notebook: {e}"
581
+
582
+ return None # 非多模態檔案
583
+
584
+
585
+ # ============================================================
586
+ # P2-4: SHELL SANDBOX(指令安全分類)
587
+ # ============================================================
588
+ # 不用 ML,用規則分類 — 比 ML 更可靠且不需要額外模型
589
+ DANGEROUS_PATTERNS = [
590
+ r"rm\s+(-rf?|--recursive)\s+[/~]", # rm -rf /
591
+ r"rm\s+-rf?\s+\.", # rm -rf .
592
+ r">(>?)\s*/dev/sd", # 覆寫磁碟
593
+ r"mkfs\.", # 格式化
594
+ r"dd\s+if=", # 磁碟操作
595
+ r":()\{.*\|.*&\s*\};:", # fork bomb
596
+ r"chmod\s+777\s+/", # 危險權限
597
+ r"curl.*\|\s*(bash|sh)", # pipe to shell
598
+ r"wget.*\|\s*(bash|sh)", # pipe to shell
599
+ ]
600
+
601
+ WARN_PATTERNS = [
602
+ r"git\s+push\s+.*--force", # force push
603
+ r"git\s+reset\s+--hard", # hard reset
604
+ r"git\s+clean\s+-fd", # clean untracked
605
+ r"npm\s+publish", # publish package
606
+ r"pip\s+install\s+--force", # force install
607
+ r"docker\s+system\s+prune", # docker cleanup
608
+ r"DROP\s+TABLE", # SQL drop
609
+ r"DELETE\s+FROM\s+\w+\s*;?\s*$", # SQL delete all
610
+ r"sudo\s+", # sudo
611
+ ]
612
+
613
+ def classify_command(command):
614
+ """
615
+ 分類指令安全等級:
616
+ - 'block': 直接阻擋
617
+ - 'warn': 需要額外確認
618
+ - 'safe': 安全
619
+ """
620
+ for p in DANGEROUS_PATTERNS:
621
+ if re.search(p, command, re.IGNORECASE):
622
+ return "block", f"危險指令匹配: {p}"
623
+ for p in WARN_PATTERNS:
624
+ if re.search(p, command, re.IGNORECASE):
625
+ return "warn", f"需要確認: {p}"
626
+ return "safe", ""
627
+
628
+
629
+ # ============================================================
630
+ # P2-5: MCP LITE(簡易外部工具協議)
631
+ # ============================================================
632
+ class MCPLite:
633
+ """
634
+ 簡易 MCP — 讀取 .codepilot/mcp.json,連接外部工具伺服器。
635
+ 支援 stdio 和 http 兩種傳輸方式。
636
+
637
+ .codepilot/mcp.json:
638
+ {
639
+ "servers": {
640
+ "database": {
641
+ "command": "python db_mcp_server.py",
642
+ "type": "stdio"
643
+ },
644
+ "api": {
645
+ "url": "http://localhost:9000/mcp",
646
+ "type": "http"
647
+ }
648
+ }
649
+ }
650
+ """
651
+ def __init__(self, project_dir):
652
+ self.servers = {}
653
+ self.processes = {}
654
+ mcp_file = os.path.join(project_dir, ".codepilot", "mcp.json")
655
+ if os.path.exists(mcp_file):
656
+ try:
657
+ config = json.loads(Path(mcp_file).read_text())
658
+ self.servers = config.get("servers", {})
659
+ except: pass
660
+
661
+ def call(self, server_name, method, params=None):
662
+ """呼叫 MCP 伺服器"""
663
+ server = self.servers.get(server_name)
664
+ if not server:
665
+ return f"❌ MCP 伺服器不存在: {server_name}(可用: {', '.join(self.servers.keys())})"
666
+
667
+ if server.get("type") == "http":
668
+ return self._call_http(server, method, params)
669
+ else:
670
+ return self._call_stdio(server_name, server, method, params)
671
+
672
+ def _call_http(self, server, method, params):
673
+ try:
674
+ if not httpx: return "❌ 需要 httpx"
675
+ resp = httpx.post(server["url"], json={
676
+ "jsonrpc": "2.0", "id": 1, "method": method,
677
+ "params": params or {}
678
+ }, timeout=30)
679
+ resp.raise_for_status()
680
+ result = resp.json()
681
+ return json.dumps(result.get("result", result), ensure_ascii=False, indent=2)
682
+ except Exception as e:
683
+ return f"❌ MCP HTTP 錯誤: {e}"
684
+
685
+ def _call_stdio(self, name, server, method, params):
686
+ try:
687
+ # 啟動進程(如果還沒啟動)
688
+ if name not in self.processes or self.processes[name].poll() is not None:
689
+ self.processes[name] = subprocess.Popen(
690
+ server["command"], shell=True,
691
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
692
+ stderr=subprocess.PIPE, text=True)
693
+
694
+ proc = self.processes[name]
695
+ request = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method,
696
+ "params": params or {}}) + "\n"
697
+ proc.stdin.write(request)
698
+ proc.stdin.flush()
699
+
700
+ # 讀取回應(1 行 JSON)
701
+ import select
702
+ ready, _, _ = select.select([proc.stdout], [], [], 10)
703
+ if ready:
704
+ line = proc.stdout.readline()
705
+ result = json.loads(line)
706
+ return json.dumps(result.get("result", result), ensure_ascii=False, indent=2)
707
+ return "⏰ MCP 伺服器無回應"
708
+ except Exception as e:
709
+ return f"❌ MCP stdio 錯誤: {e}"
710
+
711
+ def list_servers(self):
712
+ if not self.servers: return "(無 MCP 伺服器。建立 .codepilot/mcp.json)"
713
+ lines = []
714
+ for name, cfg in self.servers.items():
715
+ stype = cfg.get("type", "stdio")
716
+ target = cfg.get("url", cfg.get("command", "?"))
717
+ lines.append(f" 🔌 {name} ({stype}): {target}")
718
+ return "\n".join(lines)
719
+
720
+ def cleanup(self):
721
+ for proc in self.processes.values():
722
+ try: proc.kill()
723
+ except: pass
724
 
725
 
726
  # ============================================================
 
933
  return None
934
 
935
 
936
+ # ============================================================
937
+ # SKILL SYSTEM(技能系統 — 仿 Claude Code SkillTool)
938
+ # ============================================================
939
+ """
940
+ Skill 和 Agent 的關鍵差異(來自 Claude Code 原始碼):
941
+ - Skill → 注入指令到「當前」context window(不建新 context)
942
+ - Agent → spawn 一個「新的」隔離 context window
943
+
944
+ Skill 定義方式:
945
+ .codepilot/skills/<name>/SKILL.md
946
+
947
+ SKILL.md 格式:
948
+ ---
949
+ name: API Generator
950
+ description: Generate RESTful API endpoints from a data model
951
+ tools: [read_file, edit_file, write_file, run_command]
952
+ arguments:
953
+ - name: model_file
954
+ description: Path to the data model file
955
+ - name: framework
956
+ description: Web framework (fastapi, express, gin)
957
+ default: fastapi
958
+ hooks:
959
+ post_edit_file: "black {file}"
960
+ ---
961
+
962
+ 你是一位 API 專家。根據用戶提供的 data model,生成完整的 RESTful CRUD API。
963
+
964
+ 步驟:
965
+ 1. 讀取 model_file 了解數據結構
966
+ 2. 生成路由文件
967
+ 3. 生成測試文件
968
+ 4. 執行測試確認通過
969
+
970
+ 內建 Skills(bundled):
971
+ - create-skill: 幫你建立新的 skill
972
+ - refactor: 重構程式碼
973
+ - test-gen: 自動產生測試
974
+ - doc-gen: 自動產生文檔
975
+ - debug: 除錯助手
976
+ """
977
+
978
+ class SkillManager:
979
+ """管理和執行 Skills"""
980
+
981
+ def __init__(self, project_dir):
982
+ self.project_dir = project_dir
983
+ self.skills = {}
984
+
985
+ # 載入自訂 skills
986
+ skills_dir = os.path.join(project_dir, ".codepilot", "skills")
987
+ if os.path.isdir(skills_dir):
988
+ for skill_dir in Path(skills_dir).iterdir():
989
+ if skill_dir.is_dir():
990
+ skill_md = skill_dir / "SKILL.md"
991
+ if skill_md.exists():
992
+ skill = self._parse_skill(skill_md)
993
+ if skill:
994
+ self.skills[skill["name"]] = skill
995
+
996
+ # 載入全域 skills
997
+ global_skills = CONFIG_DIR / "skills" if isinstance(CONFIG_DIR, Path) else Path(CONFIG_DIR) / "skills"
998
+ if global_skills.is_dir():
999
+ for skill_dir in global_skills.iterdir():
1000
+ if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
1001
+ skill = self._parse_skill(skill_dir / "SKILL.md")
1002
+ if skill and skill["name"] not in self.skills:
1003
+ self.skills[skill["name"]] = skill
1004
+
1005
+ # 註冊內建 bundled skills
1006
+ self._register_bundled_skills()
1007
+
1008
+ def _parse_skill(self, skill_md_path):
1009
+ """解析 SKILL.md"""
1010
+ try:
1011
+ content = Path(skill_md_path).read_text(encoding="utf-8")
1012
+ skill = {
1013
+ "name": skill_md_path.parent.name,
1014
+ "path": str(skill_md_path.parent),
1015
+ "description": "",
1016
+ "prompt": content,
1017
+ "tools": None, # None = 全部工具, list = 限定
1018
+ "arguments": [],
1019
+ "hooks": {},
1020
+ "model": None,
1021
+ "fork": False, # True = 在隔離 context 中執行
1022
+ }
1023
+
1024
+ # 解析 YAML frontmatter
1025
+ if content.startswith("---"):
1026
+ parts = content.split("---", 2)
1027
+ if len(parts) >= 3:
1028
+ for line in parts[1].strip().split("\n"):
1029
+ line = line.strip()
1030
+ if not line or line.startswith("#"):
1031
+ continue
1032
+ if ":" in line:
1033
+ k, v = line.split(":", 1)
1034
+ k, v = k.strip(), v.strip()
1035
+ if k == "name": skill["name"] = v
1036
+ elif k == "description": skill["description"] = v
1037
+ elif k == "model": skill["model"] = v
1038
+ elif k == "fork": skill["fork"] = v.lower() in ("true", "yes", "1")
1039
+ elif k == "tools":
1040
+ if v.startswith("["):
1041
+ skill["tools"] = [x.strip().strip("'\"") for x in v[1:-1].split(",")]
1042
+ elif k == "arguments":
1043
+ pass # 複雜結構,在下面處理
1044
+ elif k == "hooks":
1045
+ pass # 在下面處理
1046
+
1047
+ # 解析 arguments(簡易版)
1048
+ in_args = False
1049
+ current_arg = {}
1050
+ for line in parts[1].strip().split("\n"):
1051
+ line = line.strip()
1052
+ if line.startswith("arguments:"):
1053
+ in_args = True; continue
1054
+ if in_args:
1055
+ if line.startswith("- name:"):
1056
+ if current_arg: skill["arguments"].append(current_arg)
1057
+ current_arg = {"name": line.split(":", 1)[1].strip()}
1058
+ elif line.startswith("description:") and current_arg:
1059
+ current_arg["description"] = line.split(":", 1)[1].strip()
1060
+ elif line.startswith("default:") and current_arg:
1061
+ current_arg["default"] = line.split(":", 1)[1].strip()
1062
+ elif not line.startswith(" ") and not line.startswith("-"):
1063
+ in_args = False
1064
+ if current_arg and "name" in current_arg:
1065
+ skill["arguments"].append(current_arg)
1066
+
1067
+ # hooks
1068
+ in_hooks = False
1069
+ for line in parts[1].strip().split("\n"):
1070
+ line = line.strip()
1071
+ if line.startswith("hooks:"):
1072
+ in_hooks = True; continue
1073
+ if in_hooks and ":" in line and line.startswith(" "):
1074
+ hk, hv = line.strip().split(":", 1)
1075
+ skill["hooks"][hk.strip()] = hv.strip().strip('"').strip("'")
1076
+ elif in_hooks and not line.startswith(" "):
1077
+ in_hooks = False
1078
+
1079
+ skill["prompt"] = parts[2].strip()
1080
+
1081
+ return skill
1082
+ except Exception as e:
1083
+ return None
1084
+
1085
+ def _register_bundled_skills(self):
1086
+ """註冊內建 skills"""
1087
+ bundled = {
1088
+ "create-skill": {
1089
+ "name": "create-skill",
1090
+ "description": "建立新的 skill",
1091
+ "prompt": """幫用戶在 .codepilot/skills/<name>/SKILL.md 建立一個新的 skill。
1092
+
1093
+ 先問用戶:
1094
+ 1. Skill 名稱
1095
+ 2. 用途描述
1096
+ 3. 需要用到哪些工具
1097
+
1098
+ 然後��生 SKILL.md,包含 YAML frontmatter 和詳細指令。""",
1099
+ "tools": ["write_file", "list_files"],
1100
+ "arguments": [{"name": "name", "description": "skill 名稱"}],
1101
+ "hooks": {},
1102
+ "fork": False,
1103
+ "path": "(bundled)",
1104
+ },
1105
+ "refactor": {
1106
+ "name": "refactor",
1107
+ "description": "重構程式碼:提取函數、重命名、簡化邏輯",
1108
+ "prompt": """你是重構專家。閱讀用戶指定的文件,進行以下改進:
1109
+ 1. 提取重複的程式碼為函數
1110
+ 2. 改善命名(變數、函數、類別)
1111
+ 3. 簡化複雜的條件邏輯
1112
+ 4. 加入或改進 docstring
1113
+ 5. 確保修改後測試仍然通過
1114
+
1115
+ 每次只做一個小修改,驗證後再做下一個。""",
1116
+ "tools": ["read_file", "edit_file", "run_command", "search_files"],
1117
+ "arguments": [{"name": "file", "description": "要重構的文件路徑"}],
1118
+ "hooks": {},
1119
+ "fork": False,
1120
+ "path": "(bundled)",
1121
+ },
1122
+ "test-gen": {
1123
+ "name": "test-gen",
1124
+ "description": "自動產生測試",
1125
+ "prompt": """你是測試工程師。為用戶指定的文件或函數產生完整的測試。
1126
+
1127
+ 步驟:
1128
+ 1. 讀取原始碼,了解所有公開函數和類別
1129
+ 2. 為每個函數產生:正常輸入、邊界值、錯誤輸入的測試
1130
+ 3. 使用專案現有的測試框架(pytest/jest/等)
1131
+ 4. 把測試寫入對應的 tests/ 目錄
1132
+ 5. 執行測試確認通過""",
1133
+ "tools": ["read_file", "write_file", "run_command", "search_files", "list_files"],
1134
+ "arguments": [{"name": "file", "description": "要產生測試的文件"}],
1135
+ "hooks": {},
1136
+ "fork": False,
1137
+ "path": "(bundled)",
1138
+ },
1139
+ "doc-gen": {
1140
+ "name": "doc-gen",
1141
+ "description": "自動產生文檔(docstring / README / API docs)",
1142
+ "prompt": """你是技術文件專家。為用戶的程式碼產生或改善文檔。
1143
+
1144
+ 可以:
1145
+ 1. 為所有函數加上 docstring
1146
+ 2. 產生或更新 README.md
1147
+ 3. 產生 API 文檔(如有 web framework)
1148
+ 4. 產生 CHANGELOG
1149
+
1150
+ 根據用戶的要求決定做哪個。""",
1151
+ "tools": ["read_file", "edit_file", "write_file", "search_files", "list_files"],
1152
+ "arguments": [{"name": "target", "description": "文件或目錄", "default": "."}],
1153
+ "hooks": {},
1154
+ "fork": False,
1155
+ "path": "(bundled)",
1156
+ },
1157
+ "debug": {
1158
+ "name": "debug",
1159
+ "description": "除錯助手:分析錯誤訊息、找出原因、修復",
1160
+ "prompt": """你是除錯專家。用戶會給你一個錯誤訊息或描述問題。
1161
+
1162
+ 步驟:
1163
+ 1. 分析錯誤訊息,定位問題文件和行數
1164
+ 2. 讀取相關程式碼
1165
+ 3. 搜尋可能相關的其他文件
1166
+ 4. 找出根本原因
1167
+ 5. 提出修復方案
1168
+ 6. 實施修復
1169
+ 7. 跑測試驗證
1170
+
1171
+ 先分析再動手,不要急著改。""",
1172
+ "tools": ["read_file", "edit_file", "run_command", "search_files", "list_files", "git_status"],
1173
+ "arguments": [{"name": "error", "description": "錯誤訊息或問題描述"}],
1174
+ "hooks": {},
1175
+ "fork": False,
1176
+ "path": "(bundled)",
1177
+ },
1178
+ }
1179
+ for name, skill in bundled.items():
1180
+ if name not in self.skills:
1181
+ self.skills[name] = skill
1182
+
1183
+ def list_skills(self):
1184
+ """列出所有可用 skills"""
1185
+ lines = []
1186
+ bundled = []
1187
+ custom = []
1188
+ for name, s in sorted(self.skills.items()):
1189
+ icon = "📦" if s.get("path") == "(bundled)" else "🔧"
1190
+ desc = s.get("description", "")
1191
+ args = ", ".join(a["name"] for a in s.get("arguments", []))
1192
+ entry = f" {icon} {name}: {desc}"
1193
+ if args: entry += f" [dim]({args})[/]"
1194
+ if s.get("path") == "(bundled)":
1195
+ bundled.append(entry)
1196
+ else:
1197
+ custom.append(entry)
1198
+ if custom:
1199
+ lines.append("[bold]自訂 Skills:[/]")
1200
+ lines.extend(custom)
1201
+ if bundled:
1202
+ lines.append("[bold]內建 Skills:[/]")
1203
+ lines.extend(bundled)
1204
+ return "\n".join(lines) if lines else "(無 skill。用 /skill create-skill 建立)"
1205
+
1206
+ def invoke(self, skill_name, args_dict, model, tools, console, messages=None):
1207
+ """
1208
+ 執行 skill。
1209
+ 核心差異:skill 注入到當前 context(不像 agent 建新 context)
1210
+ """
1211
+ from rich.markdown import Markdown
1212
+
1213
+ skill = self.skills.get(skill_name)
1214
+ if not skill:
1215
+ console.print(f"[red]❌ 未知 skill: {skill_name}[/]")
1216
+ console.print(self.list_skills())
1217
+ return None, None
1218
+
1219
+ console.print(f"\n[bold magenta]⚡ Skill: {skill['name']}[/] — {skill.get('description','')}")
1220
+
1221
+ # 組裝 skill prompt + 用戶參數
1222
+ skill_prompt = skill["prompt"]
1223
+
1224
+ # 替換參數
1225
+ for arg_def in skill.get("arguments", []):
1226
+ arg_name = arg_def["name"]
1227
+ arg_val = args_dict.get(arg_name, arg_def.get("default", ""))
1228
+ skill_prompt = skill_prompt.replace(f"{{{arg_name}}}", str(arg_val))
1229
+
1230
+ if skill.get("fork"):
1231
+ # Fork 模式:隔離 context(像 agent)
1232
+ console.print(f" [dim](fork mode — 隔離 context)[/]")
1233
+ fork_messages = [
1234
+ {"role": "system", "content": skill_prompt},
1235
+ {"role": "user", "content": json.dumps(args_dict, ensure_ascii=False)},
1236
+ ]
1237
+ full_response = ""
1238
+ for rnd in range(8):
1239
+ with console.status(f"[magenta]{skill_name} round {rnd+1}..."):
1240
+ try: response = model.chat(fork_messages)
1241
+ except: break
1242
+ tcalls = parse_tool_calls(response)
1243
+ text = TOOL_PATTERN.sub("", response).strip()
1244
+ if text: console.print(Markdown(text))
1245
+ full_response += response + "\n"
1246
+ if not tcalls: break
1247
+ fork_messages.append({"role": "assistant", "content": response})
1248
+ results = []
1249
+ for call in tcalls:
1250
+ # 工具權限過濾
1251
+ if skill.get("tools") and call["tool"] not in skill["tools"]:
1252
+ results.append(f"[{call['tool']}] ❌ 此 skill 不允許"); continue
1253
+ result = execute_tool(tools, call)
1254
+ console.print(f" [dim]🔧 {call['tool']}[/]")
1255
+ results.append(f"[{call['tool']}] {result}")
1256
+ # 觸發 skill 自帶的 hooks
1257
+ if call["tool"] in ("edit_file", "write_file"):
1258
+ fpath = call["params"].get("path", "")
1259
+ hook_cmd = skill.get("hooks", {}).get(f"post_{call['tool']}")
1260
+ if hook_cmd and fpath:
1261
+ subprocess.run(hook_cmd.replace("{file}", fpath), shell=True,
1262
+ cwd=tools.project_dir, capture_output=True, timeout=30)
1263
+ fork_messages.append({"role": "user", "content": "Tool results:\n" + "\n\n".join(results)})
1264
+ return full_response, None
1265
+ else:
1266
+ # 注入模式(預設):把 skill 指令注入當前 context
1267
+ inject_msg = f"[Skill: {skill_name}]\n\n{skill_prompt}\n\nUser arguments: {json.dumps(args_dict, ensure_ascii=False)}"
1268
+ return None, inject_msg # 回傳注入內容,由主循環處理
1269
+
1270
+
1271
  # ============================================================
1272
  # P1-1: APPROVAL SYSTEM(權限/審批)
1273
  # ============================================================
 
1708
  messages = [{"role": "system", "content": system_prompt}]
1709
 
1710
  hooks = Hooks(project_dir)
1711
+ bg_tasks = BackgroundTaskManager()
1712
+ custom_agents = load_custom_agents(project_dir)
1713
+ mcp = MCPLite(project_dir)
1714
+ skill_mgr = SkillManager(project_dir) # Skill 系統
1715
+ approval_mode = args.approval or "auto"
1716
+ use_streaming = args.stream and provider_key == "local"
1717
  edited_files_this_session = []
1718
 
1719
  if custom_agents:
1720
  console.print(f"[dim]🤖 自訂代理: {', '.join(custom_agents.keys())}[/]")
1721
 
1722
+ console.print(f"[dim]指令: /init /verify /commit /agent /bg /approval /web /mcp /stream | /duel /memo /grind /ls /git /clear /status /train /quit[/]\n")
1723
 
1724
  while True:
1725
  try: user_input = Prompt.ask("\n[bold green]🧑 You")
 
1802
  auto_git_commit(tools, model, edited_files_this_session, console)
1803
  continue
1804
 
1805
+ elif cmd.startswith("/skill"):
1806
+ # Skill 系統
1807
+ parts = cmd.split(None, 2)
1808
+ if len(parts) < 2 or parts[1] == "list":
1809
+ console.print(skill_mgr.list_skills())
1810
+ continue
1811
+ skill_name = parts[1]
1812
+ # 收集參數
1813
+ skill_def = skill_mgr.skills.get(skill_name)
1814
+ skill_args = {}
1815
+ if skill_def:
1816
+ # 如果指令裡有第三段,用它作為第一個參數
1817
+ if len(parts) > 2 and skill_def.get("arguments"):
1818
+ skill_args[skill_def["arguments"][0]["name"]] = parts[2]
1819
+ else:
1820
+ for arg_def in skill_def.get("arguments", []):
1821
+ default = arg_def.get("default", "")
1822
+ val = Prompt.ask(f" {arg_def['name']} ({arg_def.get('description','')})", default=default)
1823
+ if val: skill_args[arg_def["name"]] = val
1824
+ result, inject = skill_mgr.invoke(skill_name, skill_args, model, tools, console, messages)
1825
+ if inject:
1826
+ # 注入模式:加入當前對話
1827
+ messages.append({"role": "user", "content": inject})
1828
+ with console.status("[bold cyan]執行 skill..."):
1829
+ response = chat_with_recovery(model, messages, ctx=ctx, console=console)
1830
+ console.print(f"\n[bold blue]🤖 CodePilot:[/]")
1831
+ from rich.markdown import Markdown as _Md
1832
+ console.print(_Md(TOOL_PATTERN.sub("", response).strip()))
1833
+ messages.append({"role": "assistant", "content": response})
1834
+ # 處理工具呼叫
1835
+ tcalls = parse_tool_calls(response)
1836
+ if tcalls:
1837
+ results = []
1838
+ for call in tcalls:
1839
+ console.print(f" [dim]🔧 {call['tool']}[/]")
1840
+ r = execute_tool(tools, call)
1841
+ results.append(f"[{call['tool']}] {r}")
1842
+ messages.append({"role": "user", "content": "Tool results:\n" + "\n\n".join(results)})
1843
+ continue
1844
+
1845
  elif cmd.startswith("/agent"):
1846
  # P1-3: 自訂代理
1847
  parts = cmd.split(None, 2)
 
1899
  console.print("[dim]/bg list | /bg run <cmd> | /bg check <id> | /bg kill <id>[/]")
1900
  continue
1901
 
1902
+ elif cmd.startswith("/web "):
1903
+ # P2-1: 快速網頁搜尋/抓取
1904
+ query = cmd[5:].strip()
1905
+ if query.startswith("http"):
1906
+ console.print(f"[dim]🌐 抓取 {query}...[/]")
1907
+ result = web_fetch(query)
1908
+ else:
1909
+ console.print(f"[dim]🔍 搜尋: {query}...[/]")
1910
+ result = web_search(query)
1911
+ console.print(result[:2000])
1912
+ continue
1913
+
1914
+ elif cmd.startswith("/mcp"):
1915
+ # P2-5: MCP 伺服器管理
1916
+ parts = cmd.split(None, 3)
1917
+ if len(parts) < 2 or parts[1] == "list":
1918
+ console.print(f"[bold]🔌 MCP 伺服器[/]")
1919
+ console.print(mcp.list_servers())
1920
+ elif len(parts) >= 3:
1921
+ server = parts[1]
1922
+ method = parts[2]
1923
+ params = json.loads(parts[3]) if len(parts) > 3 else {}
1924
+ console.print(f"[dim]🔌 {server}.{method}...[/]")
1925
+ result = mcp.call(server, method, params)
1926
+ console.print(result[:1000])
1927
+ else:
1928
+ console.print("[dim]/mcp list | /mcp <server> <method> [json_params][/]")
1929
+ continue
1930
+
1931
+ elif cmd == "/stream on":
1932
+ use_streaming = (provider_key == "local")
1933
+ console.print(f"[green]{'✅ Streaming ON' if use_streaming else '❌ Streaming 只支援本地模型'}[/]")
1934
+ continue
1935
+ elif cmd == "/stream off":
1936
+ use_streaming = False; console.print("[dim]Streaming OFF[/]"); continue
1937
+
1938
  elif cmd.startswith("/approval"):
1939
  # P1-1: 切換審批模式
1940
  parts = cmd.split()
 
2029
  tools_used_this_turn = [] # 追蹤這輪用了哪些工具
2030
 
2031
  for rnd in range(10):
2032
+ try:
2033
+ if use_streaming and rnd == 0 and provider_key == "local":
2034
+ # P2-2: Streaming 輸出(第一輪,本地模型)
2035
+ response = stream_local_chat(model, messages, console)
2036
+ else:
2037
+ with console.status(f"[bold cyan]{'思考中' if rnd == 0 else f'工具 round {rnd+1}'}..."):
2038
+ response = chat_with_recovery(
2039
+ model, messages, ctx=ctx, console=console,
2040
+ fallback_model=local_model_ref if provider_key != "local" else None)
2041
+ except Exception as e:
2042
+ console.print(f"[red]❌ 所有重試失敗: {e}[/]")
2043
+ break
2044
 
2045
  tool_calls = parse_tool_calls(response)
2046
  text_parts = TOOL_PATTERN.sub("", response).strip()
2047
+ if text_parts and not (use_streaming and rnd == 0):
2048
+ # streaming 模式已經顯示過了,不��複
2049
  console.print(f"\n[bold blue]🤖 CodePilot:[/]")
2050
  console.print(Markdown(text_parts))
2051
  full_response += response + "\n"
 
2121
 
2122
  ctx.save_session(messages)
2123
 
2124
+ mcp.cleanup()
2125
  console.print("\n[cyan]👋[/]")
2126
 
2127
 
 
2178
  p.add_argument("--distill", action="store_true")
2179
  p.add_argument("--grind", action="store_true", help="LeetCode 自動刷題")
2180
  p.add_argument("--grind-count", type=int, default=100, help="刷幾題")
2181
+ p.add_argument("--stream", action="store_true", help="啟用 streaming 輸出(本地模型)")
2182
  p.add_argument("--stats", action="store_true"); p.add_argument("--train", action="store_true")
2183
  a = p.parse_args()
2184
  if a.stats: show_stats()