refactor(aibox): 抽离工具类与子系统,附单测基线

A 批(tools/basic_tools.py):迁移 7 个无外部依赖工具类(FileToolBase/ReadFileTool/WriteFileTool/ListFilesTool/SimpleWebCrawlerTool/GetCurrentTimeTool/CalculatorTool)

Wave1(subsystems/):迁移 5 个叶子子系统(CommandExecutor/SubprocessCommandExecutor/PromptManager/MemoryDatabase/MemoDatabase/SkillManager),aibox.py 减少 ~1440 行

新增 tests/ 单测 113 个用例全绿,依赖方向维持 common -> subsystems -> tools -> aibox 单向
This commit is contained in:
qing committed 2026-08-13 15:48:55 +08:00
1 parent 8af8dec1b0
commit eadba81c26
15 files changed
+3216 -1883

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
"""
subsystems - aibox 子系统模块(包)
抽离自 aibox.py 的基础设施子系统,按职责分文件。
当前包含(Wave 1):
- executors.py : CommandExecutor + SubprocessCommandExecutor
- prompt_manager.py : PromptManager(单例)
- db.py : MemoryDatabase + MemoDatabase
- skill_manager.py : SkillManager
依赖方向:common <- subsystems <- aibox
不反向依赖 aibox 任何符号。
采用"重导出"策略:外部统一用 `from subsystems import MemoryDatabase`,
调用点无需关心具体子模块所在文件。
"""
from .executors import CommandExecutor, SubprocessCommandExecutor
from .prompt_manager import PromptManager
from .db import MemoryDatabase, MemoDatabase
from .skill_manager import SkillManager
__all__ = [
"CommandExecutor",
"SubprocessCommandExecutor",
"PromptManager",
"MemoryDatabase",
"MemoDatabase",
"SkillManager",
]
+639
View File
@@ -0,0 +1,639 @@
#!/usr/bin/env python3
"""
subsystems.db - 数据库子系统
抽离自 aibox.py,提供两个独立的 SQLite 数据库类:
- MemoryDatabase : 记忆数据库(FTS5 全文检索),存储重要对话记忆
- MemoDatabase : 备忘录/任务数据库,支持提醒、重复、即时任务
依赖:common (constants, i18n, Logger) + 标准库 sqlite3
不反向依赖 aibox 任何符号。
"""
import os
import sqlite3
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from common import constants, i18n, Logger
# ==================== 记忆数据库 ====================
class MemoryDatabase:
"""记忆数据库 - 存储重要的对话记忆"""
def __init__(self, db_path: str = None):
if db_path is None:
db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB)
# 确保目录存在
os.makedirs(constants.SAVE_DIR, exist_ok=True)
self.db_path = db_path
self.logger = Logger("MemoryDB")
self._init_database()
def _init_database(self):
"""初始化数据库表"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
summary TEXT NOT NULL,
content TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
created_at TEXT NOT NULL,
tags TEXT,
importance INTEGER DEFAULT 1
)
''')
cursor.execute('''
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(topic, summary, content, tags, content=memories)
''')
conn.commit()
def add_memory(self, topic: str, summary: str, content: str = None,
start_time: str = None, end_time: str = None,
tags: List[str] = None, importance: int = None) -> int:
"""添加记忆到数据库"""
if start_time is None:
start_time = datetime.now().strftime(constants.DATETIME_FORMAT)
if end_time is None:
end_time = datetime.now().strftime(constants.DATETIME_FORMAT)
if importance is None:
importance = constants.MEMORY_DEFAULT_IMPORTANCE
tags_str = ",".join(tags) if tags else ""
created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO memories (topic, summary, content, start_time, end_time, created_at, tags, importance)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (topic, summary, content, start_time, end_time, created_at, tags_str, importance))
memory_id = cursor.lastrowid
cursor.execute('''
INSERT INTO memories_fts (rowid, topic, summary, content, tags)
VALUES (?, ?, ?, ?, ?)
''', (memory_id, topic, summary, content or "", tags_str))
conn.commit()
self.logger.info(f"已添加记忆: {topic} (ID: {memory_id})")
return memory_id
def delete_memory(self, memory_id: int) -> bool:
"""删除记忆"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM memories WHERE id = ?', (memory_id,))
deleted = cursor.rowcount > 0
if deleted:
cursor.execute('DELETE FROM memories_fts WHERE rowid = ?', (memory_id,))
conn.commit()
self.logger.info(f"已删除记忆 ID: {memory_id}")
return deleted
def search_memories(self, keywords: List[str], match_all: bool = False, limit: int = None) -> List[Dict]:
"""多关键词模糊搜索记忆"""
if not keywords:
return []
if limit is None:
limit = constants.MEMORY_MAX_SEARCH_RESULTS
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if match_all:
query = " AND ".join([f'"{kw}"' for kw in keywords])
else:
query = " OR ".join([f'"{kw}"' for kw in keywords])
cursor.execute('''
SELECT m.*, rank
FROM memories m
JOIN memories_fts f ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY m.end_time DESC, rank, m.importance DESC
LIMIT ?
''', (query, limit))
results = []
for row in cursor.fetchall():
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
return results
def get_recent_memories(self, limit: int = 10) -> List[Dict]:
"""获取最近的记忆"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM memories ORDER BY end_time DESC LIMIT ?', (limit,))
results = []
for row in cursor.fetchall():
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
results.append(memory)
return results
def get_memory_by_id(self, memory_id: int) -> Optional[Dict]:
"""根据ID获取记忆"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM memories WHERE id = ?', (memory_id,))
row = cursor.fetchone()
if row:
memory = dict(row)
if memory.get('tags'):
memory['tags'] = memory['tags'].split(',')
return memory
return None
def delete_memories_by_ids(self, memory_ids: List[int]) -> int:
"""批量删除记忆"""
if not memory_ids:
return 0
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
placeholders = ','.join(['?'] * len(memory_ids))
cursor.execute(f'DELETE FROM memories WHERE id IN ({placeholders})', memory_ids)
deleted_count = cursor.rowcount
if deleted_count > 0:
cursor.execute(f'DELETE FROM memories_fts WHERE rowid IN ({placeholders})', memory_ids)
conn.commit()
self.logger.info(f"已批量删除 {deleted_count} 条记忆")
return deleted_count
# ==================== 备忘录数据库 ====================
class MemoDatabase:
"""备忘录数据库 - 存储和管理任务"""
def __init__(self, db_path: str = None):
if db_path is None:
db_path = os.path.join(constants.SAVE_DIR, constants.MEMORY_DB)
# 确保目录存在
os.makedirs(constants.SAVE_DIR, exist_ok=True)
self.db_path = db_path
self.logger = Logger("MemoDB")
self._init_database()
def _init_database(self):
"""初始化备忘录表"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT,
created_at TEXT NOT NULL,
reminder_time TEXT,
completed_at TEXT,
is_completed INTEGER DEFAULT 0,
tags TEXT,
priority INTEGER DEFAULT 1,
created_by TEXT,
source TEXT,
repeat_type TEXT DEFAULT 'none',
repeat_interval INTEGER DEFAULT 0,
repeat_interval_unit TEXT DEFAULT 'days',
repeat_end_time TEXT,
last_triggered_at TEXT,
trigger_count INTEGER DEFAULT 0,
is_immediate INTEGER DEFAULT 0
)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_memos_reminder
ON memos(reminder_time) WHERE is_completed = 0
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_memos_immediate
ON memos(is_immediate) WHERE is_immediate = 1 AND is_completed = 0
''')
conn.commit()
self.logger.info("任务表初始化完成")
def add_memo(self, title: str, content: str = None, reminder_time: str = None,
tags: List[str] = None, priority: int = 1, created_by: str = None,
source: str = None,
repeat_type: str = 'none', repeat_interval: int = 0,
repeat_interval_unit: str = 'days', repeat_end_time: str = None,
is_immediate: bool = False) -> int:
"""添加任务 - 所有任务都是对话任务,不再需要metadata和任务类型"""
created_at = datetime.now().strftime(constants.DATETIME_FORMAT)
tags_str = ",".join(tags) if tags else ""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO memos
(title, content, created_at, reminder_time, tags, priority, created_by,
is_completed, source, repeat_type, repeat_interval, repeat_interval_unit,
repeat_end_time, is_immediate)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
''', (title, content, created_at, reminder_time, tags_str, priority, created_by,
source, repeat_type, repeat_interval, repeat_interval_unit,
repeat_end_time, 1 if is_immediate else 0))
memo_id = cursor.lastrowid
conn.commit()
repeat_info = f" (重复: {repeat_type}"
if repeat_type == 'custom':
repeat_info += f", 每{repeat_interval} {repeat_interval_unit}"
repeat_info += ")"
immediate_info = " (即时任务)" if is_immediate else ""
self.logger.info(i18n.get('task_added', title, memo_id) + repeat_info + immediate_info)
return memo_id
def get_memo(self, memo_id: int) -> Optional[Dict]:
"""获取单个任务"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = cursor.fetchone()
if row:
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
return memo
return None
def get_pending_memos(self, limit: int = None, before_time: str = None, include_immediate: bool = False) -> List[Dict]:
"""获取待执行的任务 - 严格使用当前时间比较"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
# 使用当前时间作为基准
now = datetime.now().strftime(constants.DATETIME_FORMAT)
# 如果传入了 before_time,使用它,否则使用当前时间
compare_time = before_time if before_time else now
query = '''
SELECT * FROM memos
WHERE is_completed = 0
AND reminder_time IS NOT NULL
AND reminder_time <= ? -- 严格小于等于比较时间
AND (repeat_end_time IS NULL OR repeat_end_time >= ?)
'''
if not include_immediate:
query += ' AND is_immediate = 0'
query += ' ORDER BY reminder_time ASC, priority DESC'
params = [compare_time, compare_time]
if limit:
query += f' LIMIT {limit}'
cursor.execute(query, params)
results = []
for row in cursor.fetchall():
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
# 【关键修复】二次验证:确保 reminder_time 确实 <= 当前时间
if memo.get('reminder_time'):
try:
reminder_time = datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT)
current_time = datetime.strptime(compare_time, constants.DATETIME_FORMAT)
if reminder_time <= current_time:
results.append(memo)
else:
# 调试日志
self.logger.debug(f"过滤掉未到期任务 #{memo['id']}: {memo['reminder_time']} > {compare_time}")
except (ValueError, TypeError) as e:
self.logger.warning(f"解析任务 #{memo['id']} 的提醒时间失败: {e}")
else:
# 没有提醒时间的任务不应该在待执行列表中
self.logger.warning(f"任务 #{memo['id']} 没有提醒时间,但被返回为待执行")
return results
def get_immediate_memos(self, limit: int = None) -> List[Dict]:
"""获取待执行的即时任务"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
query = '''
SELECT * FROM memos
WHERE is_completed = 0
AND is_immediate = 1
ORDER BY created_at ASC
'''
if limit:
query += f' LIMIT {limit}'
cursor.execute(query)
results = []
for row in cursor.fetchall():
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
results.append(memo)
return results
def get_overdue_memos(self, limit: int = None) -> List[Dict]:
"""获取已过期的任务"""
now = datetime.now().strftime(constants.DATETIME_FORMAT)
return self.get_pending_memos(limit=limit, before_time=now)
def get_all_memos(self, include_completed: bool = False, limit: int = 50) -> List[Dict]:
"""获取所有任务"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
if include_completed:
cursor.execute('''
SELECT * FROM memos
ORDER BY
CASE WHEN is_completed = 0 THEN 0 ELSE 1 END,
reminder_time ASC,
created_at DESC
LIMIT ?
''', (limit,))
else:
cursor.execute('''
SELECT * FROM memos
WHERE is_completed = 0
ORDER BY reminder_time ASC, priority DESC, created_at DESC
LIMIT ?
''', (limit,))
results = []
for row in cursor.fetchall():
memo = dict(row)
if memo.get('tags'):
memo['tags'] = memo['tags'].split(',')
results.append(memo)
return results
def mark_as_triggered(self, memo_id: int) -> Optional[str]:
"""标记任务已被触发,并根据重复规则计算下一次提醒时间"""
memo = self.get_memo(memo_id)
if not memo:
return None
now = datetime.now()
next_reminder = None
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE memos
SET last_triggered_at = ?, trigger_count = trigger_count + 1
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
# 处理重复任务
if memo['repeat_type'] != 'none' and not self._should_end_repeat(memo, now):
# 使用当前时间作为基准来计算下一次执行时间
next_reminder = self._calculate_next_reminder(memo, now)
if next_reminder:
cursor.execute('''
UPDATE memos
SET reminder_time = ?
WHERE id = ?
''', (next_reminder.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已更新下一次执行时间: {next_reminder}")
else:
# 重复结束,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 重复结束,已标记为完成")
elif memo['is_immediate']:
# 即时任务执行后标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"即时任务 #{memo_id} 已完成")
else:
# 一次性任务,标记为完成
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ?
''', (now.strftime(constants.DATETIME_FORMAT), memo_id))
self.logger.info(f"任务 #{memo_id} 已完成")
conn.commit()
return next_reminder.strftime(constants.DATETIME_FORMAT) if next_reminder else None
def _should_end_repeat(self, memo: Dict, current_time: datetime) -> bool:
"""检查是否应该结束重复"""
if memo['repeat_end_time']:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
if current_time > end_time:
return True
return False
def _calculate_next_reminder(self, memo: Dict, current_time: datetime) -> Optional[datetime]:
"""
计算下一次提醒时间,使用当前时间作为基准
支持分钟、小时、天为单位
"""
if memo['repeat_type'] == 'daily':
# 每天重复:当前时间 + 1天
next_time = current_time + timedelta(days=1)
elif memo['repeat_type'] == 'weekly':
# 每周重复:当前时间 + 7天
next_time = current_time + timedelta(weeks=1)
elif memo['repeat_type'] == 'monthly':
# 每月重复:尝试在当前时间上加一个月
try:
# 处理月份进位
if current_time.month == 12:
next_time = current_time.replace(year=current_time.year + 1, month=1)
else:
next_time = current_time.replace(month=current_time.month + 1)
except ValueError:
# 处理月份天数问题(如1月31日加一个月)
if current_time.month == 12:
next_time = current_time.replace(year=current_time.year + 1, month=1, day=1) - timedelta(days=1)
else:
next_time = current_time.replace(month=current_time.month + 1, day=1) - timedelta(days=1)
elif memo['repeat_type'] == 'custom' and memo['repeat_interval'] > 0:
unit = memo.get('repeat_interval_unit', 'days')
if unit == 'minutes':
# 每N分钟重复:当前时间 + N分钟
next_time = current_time + timedelta(minutes=memo['repeat_interval'])
elif unit == 'hours':
# 每N小时重复:当前时间 + N小时
next_time = current_time + timedelta(hours=memo['repeat_interval'])
else: # days
# 每N天重复:当前时间 + N天
next_time = current_time + timedelta(days=memo['repeat_interval'])
else:
return None
# 检查是否超过重复结束时间
if memo['repeat_end_time']:
end_time = datetime.strptime(memo['repeat_end_time'], constants.DATETIME_FORMAT)
if next_time > end_time:
return None
return next_time
def complete_memo(self, memo_id: int, force_complete: bool = False) -> bool:
"""完成任务"""
memo = self.get_memo(memo_id)
if not memo:
return False
if memo['repeat_type'] != 'none' and not force_complete:
self.mark_as_triggered(memo_id)
return True
completed_at = datetime.now().strftime(constants.DATETIME_FORMAT)
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
UPDATE memos
SET is_completed = 1, completed_at = ?
WHERE id = ? AND is_completed = 0
''', (completed_at, memo_id))
affected = cursor.rowcount
conn.commit()
if affected > 0:
self.logger.info(i18n.get('memo_completed', memo_id))
return True
return False
def get_memo_stats(self) -> Dict:
"""获取任务统计信息"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('SELECT COUNT(*) FROM memos')
total = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 0')
pending = cursor.fetchone()[0]
now = datetime.now().strftime(constants.DATETIME_FORMAT)
cursor.execute('''
SELECT COUNT(*) FROM memos
WHERE is_completed = 0
AND reminder_time <= ?
AND is_immediate = 0
''', (now,))
overdue = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_completed = 1')
completed = cursor.fetchone()[0]
cursor.execute('SELECT COUNT(*) FROM memos WHERE is_immediate = 1 AND is_completed = 0')
immediate = cursor.fetchone()[0]
return {
"total": total,
"pending": pending,
"overdue": overdue,
"completed": completed,
"immediate": immediate
}
def add_user_task(self, title: str, content: str = None, reminder_time: str = None,
repeat_type: str = 'none', repeat_interval: int = 0,
repeat_interval_unit: str = 'days', is_immediate: bool = False) -> int:
"""添加用户任务(前端直接调用的任务)- 所有任务都是对话任务"""
if not constants.MEMO_ALLOW_USER_TASKS:
raise Exception("用户任务功能已禁用")
return self.add_memo(
title=title,
content=content,
reminder_time=reminder_time,
repeat_type=repeat_type,
repeat_interval=repeat_interval,
repeat_interval_unit=repeat_interval_unit,
created_by="user",
source="websocket",
is_immediate=is_immediate
)
def get_pending_tasks_for_user(self) -> List[Dict]:
"""获取待执行任务列表(用于前端显示)"""
pending = self.get_pending_memos(include_immediate=False)
immediate = self.get_immediate_memos()
result = []
now = datetime.now()
for memo in pending + immediate:
result.append({
"id": memo['id'],
"title": memo['title'],
"content": memo['content'],
"reminder_time": memo['reminder_time'],
"priority": memo['priority'],
"is_overdue": datetime.strptime(memo['reminder_time'], constants.DATETIME_FORMAT) < now if memo['reminder_time'] else False,
"repeat_type": memo['repeat_type'],
"repeat_interval": memo.get('repeat_interval', 0),
"repeat_interval_unit": memo.get('repeat_interval_unit', 'days'),
"trigger_count": memo.get('trigger_count', 0),
"is_immediate": memo.get('is_immediate', False)
})
return result
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env python3
"""
subsystems.executors - 命令执行器
抽离自 aibox.py,提供命令执行的抽象基类与 subprocess 实现。
- CommandExecutor : 抽象基类,定义 execute / execute_streaming / cancel
- SubprocessCommandExecutor : 基于 subprocess.Popen 的实现,支持超时/工作目录/
1MB 输出上限/线程安全取消
依赖:common (ConfigManager, constants, Logger) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import queue
import subprocess
import threading
import time
from abc import ABC, abstractmethod
from typing import Dict, Optional, Tuple
from common import ConfigManager, constants, Logger
class CommandExecutor(ABC):
"""命令执行器抽象基类 - 负责执行系统命令"""
def __init__(self, logger: Optional[Logger] = None):
self.logger = logger or Logger("CommandExecutor")
self.config = ConfigManager()
self.process = None
@abstractmethod
def execute(self, command: str, timeout: int = 30, **kwargs) -> str:
"""执行命令并返回结果(非流式)"""
pass
@abstractmethod
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, **kwargs):
"""流式执行命令,通过队列输出结果"""
pass
def is_enabled(self) -> bool:
"""检查命令执行是否启用"""
return constants.COMMANDS_ENABLED
def format_result(self, success: bool, message: str, data: Dict = None) -> str:
"""统一结果格式化"""
result = []
emoji = "✅" if success else "❌"
result.append(f"{emoji} {message}")
if data:
for key, value in data.items():
if isinstance(value, str) and "\n" in value:
result.append(f" {key}:")
for line in value.split("\n"):
result.append(f" {line}")
else:
result.append(f" {key}: {value}")
return "\n".join(result)
class SubprocessCommandExecutor(CommandExecutor):
"""基于subprocess的命令执行器 - 支持超时、工作目录、输出大小限制和取消"""
# 命令输出最大字节数(1MB),超出截断
MAX_OUTPUT_BYTES = 1 * 1024 * 1024
def __init__(self, logger=None):
super().__init__(logger)
self._current_process = None
self._process_lock = threading.Lock()
def cancel(self):
"""取消当前正在运行的命令(kill子进程)。线程安全。"""
with self._process_lock:
proc = self._current_process
if proc is not None:
try:
if proc.poll() is None:
proc.kill()
self.logger.info("已终止正在运行的子进程")
except Exception:
pass
def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]:
"""从进程读取输出,限制总大小和超时。
返回 (output_str, timeout_occurred, truncated)
"""
output_chunks = []
total_bytes = 0
timeout_occurred = False
truncated = False
while True:
# 检查超时
if timeout > 0 and time.time() - start_time > timeout:
process.terminate()
timeout_occurred = True
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
process.terminate()
break
# 读取一行
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace'))
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
break
output_chunks.append(line)
total_bytes += line_bytes
if output_queue:
output_queue.put(("line", line.rstrip('\n') + "\n"))
return ''.join(output_chunks), timeout_occurred, truncated
def execute(self, command: str, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs) -> str:
"""执行命令并返回完整结果(带输出大小限制)"""
if not self.is_enabled():
return "❌ 命令执行功能已禁用"
# 默认工作目录为workspace,避免在启动目录散落文件
if work_dir is None or work_dir == ".":
work_dir = constants.WORKSPACE_DIR
self.logger.info(f"执行命令: {command} (work_dir={work_dir})")
# 准备环境变量
exec_env = os.environ.copy()
if env:
exec_env.update(env)
process = None
try:
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
env=exec_env,
cwd=os.path.expanduser(work_dir)
)
# 保存进程引用以便cancel()可以kill它
with self._process_lock:
self._current_process = process
start_time = time.time()
output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time)
# 确保进程终止
if process.poll() is None:
if timeout_occurred or truncated:
process.kill()
else:
process.terminate()
try:
process.wait(timeout=2)
except:
process.kill()
return_code = process.returncode if process.returncode is not None else -1
data = {
"输出": output_str.strip() if output_str.strip() else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
return self.format_result(False, f"命令执行超时({timeout}秒)", data)
if truncated:
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
return self.format_result(False, "命令执行完成(输出已截断)", data)
return self.format_result(return_code == 0, "命令执行完成", data)
except subprocess.TimeoutExpired:
return f"❌ 命令执行超时({timeout}秒)"
except FileNotFoundError as e:
return f"❌ 命令执行错误: 命令未找到 - {str(e)}"
except PermissionError as e:
return f"❌ 命令执行错误: 权限不足 - {str(e)}"
except Exception as e:
return f"❌ 执行错误: {str(e)}"
finally:
if process and process.poll() is None:
process.kill()
# 清除当前进程引用
with self._process_lock:
self._current_process = None
def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30,
work_dir: str = None, env: Dict = None, **kwargs):
"""流式执行命令,支持超时控制、工作目录和输出大小限制"""
if not self.is_enabled():
output_queue.put(("line", "❌ 命令执行功能已禁用\n"))
output_queue.put(("complete", "❌ 命令执行功能已禁用"))
return
# 默认工作目录为workspace,避免在启动目录散落文件
if work_dir is None or work_dir == ".":
work_dir = constants.WORKSPACE_DIR
self.logger.info(f"流式执行命令: {command} (work_dir={work_dir})")
output_queue.put(("line", f"\n🔧 执行命令: {command}\n"))
output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n"))
output_queue.put(("line", f"📦 输出限制: {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB\n"))
output_queue.put(("line", f"📁 工作目录: {work_dir}\n"))
output_queue.put(("line", "-" * 50 + "\n"))
# 准备环境变量
exec_env = os.environ.copy()
if env:
exec_env.update(env)
process = None
try:
process = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
text=True,
bufsize=1,
universal_newlines=True,
env=exec_env,
cwd=os.path.expanduser(work_dir)
)
self.process = process
# 保存进程引用以便cancel()可以kill它
with self._process_lock:
self._current_process = process
start_time = time.time()
output_lines = []
total_bytes = 0
timeout_occurred = False
truncated = False
# 实时读取输出
while True:
# 检查超时
if timeout > 0 and time.time() - start_time > timeout:
process.terminate()
timeout_occurred = True
output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n"))
break
# 检查输出大小
if total_bytes >= self.MAX_OUTPUT_BYTES:
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
# 读取一行输出
line = process.stdout.readline()
if not line and process.poll() is not None:
break
if line:
line_bytes = len(line.encode('utf-8', errors='replace'))
if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated:
allowed = self.MAX_OUTPUT_BYTES - total_bytes
if allowed > 0:
truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')
output_queue.put(("line", truncated_line))
output_lines.append(truncated_line)
total_bytes = self.MAX_OUTPUT_BYTES
truncated = True
process.terminate()
output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n"))
break
stripped = line.rstrip('\n')
output_queue.put(("line", stripped + "\n"))
output_lines.append(line)
total_bytes += line_bytes
# 如果超时或截断,确保进程被终止
if (timeout_occurred or truncated) and process.poll() is None:
process.kill()
try:
process.wait(timeout=2)
except:
pass
return_code = process.returncode if process.returncode is not None else -1
output_queue.put(("line", "-" * 50 + "\n"))
if timeout_occurred:
output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n"))
elif truncated:
output_queue.put(("line", f"📦 输出已截断,返回码: {return_code}\n"))
else:
output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n"))
# 准备完整结果
full_output = "".join(output_lines)
data = {
"输出": full_output.strip() if full_output.strip() else "(无输出)",
"返回码": return_code
}
if timeout_occurred:
result = self.format_result(False, f"命令执行超时({timeout}秒)", data)
elif truncated:
data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断"
result = self.format_result(False, "命令执行完成(输出已截断)", data)
else:
result = self.format_result(return_code == 0, "命令执行完成", data)
output_queue.put(("complete", result))
except Exception as e:
output_queue.put(("line", f"❌ 执行错误: {str(e)}\n"))
output_queue.put(("complete", f"❌ 执行错误: {str(e)}"))
finally:
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=2)
except:
process.kill()
self.process = None
with self._process_lock:
self._current_process = None
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""
subsystems.prompt_manager - 提示词管理器
抽离自 aibox.py,负责从 prompts 目录加载系统提示词。
单例模式(__new__ + 类变量 _instance),多实例共享同一份 _prompts。
文件命名规范:
- 00_IDENTITY.md : 用户身份信息
- N_*.md/txt : 基础技能提示词(N 为数字),按数字升序加载
- 99_SOUL.md : 智能体灵魂文件(角色定位、身份定义、性格等)
- 100_STATUS.md : 智能体状态记录
依赖:common (ConfigManager, constants) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import re
import shutil
from datetime import datetime
from typing import Dict, List, Optional
from common import ConfigManager
class PromptManager:
"""提示词管理器 - 负责从 prompts 目录加载系统提示词
文件命名规范:
- 00_IDENTITY.md: 用户身份信息
- N_*.md/txt: 基础技能提示词(N 为数字,如 1_memory_search.md、10_task_handling.md),按数字升序加载
- 99_SOUL.md: 智能体灵魂文件(角色定位、身份定义、性格等)
- 100_STATUS.md: 智能体状态记录
"""
_instance = None
_prompts = []
_prompts_dir = None
# 核心文件
IDENTITY_FILE = "00_IDENTITY.md"
STATUS_FILE = "100_STATUS.md"
SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容
# 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md)
_SKILL_FILE_RE = re.compile(r'^(\d+)_')
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not self._prompts:
self._load_prompts()
def _get_prompts_dir(self) -> str:
config = ConfigManager()
prompts_dir = config.get("system.prompts_dir", "~/.aibox/prompts")
return os.path.expanduser(prompts_dir)
def _load_prompts(self):
self._prompts_dir = self._get_prompts_dir()
os.makedirs(self._prompts_dir, exist_ok=True)
self._prompts = []
# 确保核心文件存在
self._ensure_identity_file()
self._ensure_status_file()
self._ensure_soul_file() # 智能体灵魂文件
# 确保基础技能文件存在
self._ensure_skill_files()
prompt_files = []
if os.path.exists(self._prompts_dir):
# 按文件名排序,确保1_开头的技能文件在99_SOUL.md之前加载
prompt_files = sorted([f for f in os.listdir(self._prompts_dir)
if os.path.isfile(os.path.join(self._prompts_dir, f))
and not f.startswith('.')])
for filename in prompt_files:
filepath = os.path.join(self._prompts_dir, filename)
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read().strip()
if content:
self._prompts.append({
'file': filename,
'content': content
})
except Exception as e:
print(f"⚠️ 读取提示词文件 {filename} 失败: {e}")
essential_files = [self.IDENTITY_FILE, self.STATUS_FILE, self.SOUL_FILE]
other_files = [f for f in prompt_files if f not in essential_files] if prompt_files else []
if not self._prompts:
self._create_default_prompts()
def _get_template_dir(self) -> str:
"""获取仓库中的提示词模板目录(aibox/prompts/)"""
script_dir = os.path.dirname(os.path.abspath(__file__))
# subsystems/ 上一层是 aibox/,模板在其下的 prompts/
aibox_dir = os.path.dirname(script_dir)
return os.path.join(aibox_dir, 'prompts')
def _ensure_from_template(self, filename: str):
"""从模板目录复制提示词文件到运行目录。
模板目录为 aibox/prompts/,若其中存在同名文件则复制过去;
若模板不存在则跳过(由调用方保证模板齐全)。
已存在的运行时文件不会被覆盖。
"""
target_path = os.path.join(self._prompts_dir, filename)
if os.path.exists(target_path):
return # 已存在,不覆盖
template_path = os.path.join(self._get_template_dir(), filename)
if os.path.exists(template_path):
try:
shutil.copy2(template_path, target_path)
print(f"✅ 已从模板创建: {filename}")
except Exception as e:
print(f"⚠️ 从模板复制 {filename} 失败: {e}")
def _ensure_identity_file(self):
"""确保用户身份文件存在"""
self._ensure_from_template(self.IDENTITY_FILE)
def _ensure_status_file(self):
"""确保状态文件存在"""
self._ensure_from_template(self.STATUS_FILE)
def _ensure_soul_file(self):
"""确保智能体灵魂文件存在"""
self._ensure_from_template(self.SOUL_FILE)
def _ensure_skill_files(self):
"""确保基础技能文件存在 - 从模板目录复制所有 N_*.md 文件"""
template_dir = self._get_template_dir()
if not os.path.isdir(template_dir):
return
for filename in sorted(os.listdir(template_dir)):
if self._SKILL_FILE_RE.match(filename): # N_ 开头的文件
self._ensure_from_template(filename)
def update_identity(self, content: str) -> bool:
"""更新用户身份信息"""
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 用户身份信息
{content}
【更新记录】
- 上次更新:{timestamp}
"""
with open(identity_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新身份文件失败: {e}")
return False
def update_status(self, content: str) -> bool:
"""更新智能体状态"""
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 智能体状态记录
{content}
【更新记录】
- 上次更新:{timestamp}
"""
with open(status_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新状态文件失败: {e}")
return False
def update_soul(self, content: str) -> bool:
"""更新智能体灵魂定义 - 所有角色定位内容"""
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try:
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
updated_content = f"""# 智能体灵魂定义 - 我是谁
{content}
【更新记录】
- 上次更新:{timestamp}
"""
with open(soul_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
self.reload()
return True
except Exception as e:
print(f"⚠️ 更新灵魂文件失败: {e}")
return False
def get_identity(self) -> str:
"""获取用户身份信息"""
identity_path = os.path.join(self._prompts_dir, self.IDENTITY_FILE)
try:
if os.path.exists(identity_path):
with open(identity_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取身份文件失败: {e}")
return "尚不清楚 - 无法读取身份信息"
def get_status(self) -> str:
"""获取智能体状态"""
status_path = os.path.join(self._prompts_dir, self.STATUS_FILE)
try:
if os.path.exists(status_path):
with open(status_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取状态文件失败: {e}")
return "这是第一次对话 - 无法读取状态信息"
def get_soul(self) -> str:
"""获取智能体灵魂定义 - 角色定位核心"""
soul_path = os.path.join(self._prompts_dir, self.SOUL_FILE)
try:
if os.path.exists(soul_path):
with open(soul_path, 'r', encoding='utf-8') as f:
return f.read().strip()
except Exception as e:
print(f"⚠️ 读取灵魂文件失败: {e}")
return """我是DeepSeek,一个智能AI助手。
请创建 99_SOUL.md 文件来定义我的完整身份、角色、性格和能力边界。"""
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(以1_开头的文件)"""
return [p for p in self._prompts if p['file'].startswith(self.SKILL_PREFIX)]
def get_soul_prompt(self) -> Optional[Dict]:
"""获取灵魂提示词(99_SOUL.md)"""
for p in self._prompts:
if p['file'] == self.SOUL_FILE:
return p
return None
def _create_default_prompts(self):
"""创建默认提示词(已由各 _ensure_* 方法处理)"""
pass
@classmethod
def _skill_sort_key(cls, filename: str):
"""技能文件按前缀数字排序,非技能文件返回 None。"""
m = cls._SKILL_FILE_RE.match(filename)
return int(m.group(1)) if m else None
def _prompt_sort_key(self, p):
"""排序规则:00_IDENTITY → 技能文件(按数字升序) → 99_SOUL → 100_STATUS → 其他"""
file = p['file']
if file == self.IDENTITY_FILE:
return (0,)
skill_num = self._skill_sort_key(file)
if skill_num is not None:
return (1, skill_num)
if file == self.SOUL_FILE:
return (2,)
if file == self.STATUS_FILE:
return (4,)
return (3, file) # 其他文件按名字排序
def get_prompts(self, join_with: str = "\n\n") -> str:
"""获取所有提示词,按顺序拼接(identity → 技能文件 → soul → status)"""
if not self._prompts:
return ""
sorted_prompts = sorted(self._prompts, key=self._prompt_sort_key)
contents = [p['content'] for p in sorted_prompts]
return join_with.join(contents)
def get_sorted_prompts_list(self) -> List[Dict]:
"""获取按正确顺序排列的提示词列表(用于 system 消息构建)。"""
return sorted(self._prompts, key=self._prompt_sort_key)
def get_prompts_list(self) -> List[Dict]:
"""获取所有提示词列表(原始加载顺序,已弃用,请用 get_sorted_prompts_list)。"""
return self._prompts.copy()
def get_skill_prompts(self) -> List[Dict]:
"""获取所有基础技能提示词(N_ 开头的文件)。"""
return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None]
def reload(self):
"""重新加载所有提示词"""
self._prompts = []
self._load_prompts()
return len(self._prompts)
def get_prompt_count(self) -> int:
return len(self._prompts)
def get_prompts_dir_path(self) -> str:
return self._prompts_dir
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
subsystems.skill_manager - 扩展技能管理器
抽离自 aibox.py,收集并管理 ~/.aibox/skills/ 目录下的技能文件。
支持两种技能格式:
1. 可执行脚本/程序:执行 --help,第一行为简介
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
依赖:common (constants, Logger) + 标准库
不反向依赖 aibox 任何符号。
"""
import os
import subprocess
from typing import Dict, List, Optional, Tuple
from common import constants, Logger
class SkillManager:
"""扩展技能管理器 - 收集并管理 ~/.aibox/skills/ 目录下的技能文件。
支持两种技能格式:
1. 可执行脚本/程序:执行 --help,第一行为简介
2. Markdown 文件 (.md):第一行为简介(# 标题或普通文本),其余为技能内容
"""
def __init__(self, logger: Optional[Logger] = None):
self.logger = logger or Logger("SkillManager")
# name -> {path, file, summary, content, is_markdown}
self.skills: Dict[str, Dict] = {}
self._load_skills()
# ------------------------------------------------------------------
# 内部辅助
# ------------------------------------------------------------------
def _run_help(self, skill_path: str) -> str:
"""执行 skill_path --help 并返回完整输出(stdout 优先,否则 stderr)。"""
try:
result = subprocess.run(
[skill_path, "--help"],
capture_output=True,
text=True,
timeout=10,
env=os.environ.copy()
)
output = result.stdout.strip()
if not output:
output = result.stderr.strip()
return output
except Exception as e:
self.logger.warning(f"运行 {skill_path} --help 失败: {e}")
return ""
def _read_markdown_skill(self, skill_path: str) -> Tuple[str, str]:
"""读取 Markdown 技能文件,返回 (简介, 完整内容)"""
try:
with open(skill_path, 'r', encoding='utf-8') as f:
content = f.read().strip()
if not content:
return "(空文件)", ""
# 提取第一行非空内容作为简介
lines = content.splitlines()
summary = ""
for line in lines:
stripped = line.strip()
if stripped:
# 如果是 Markdown 标题,去掉 # 前缀
if stripped.startswith('#'):
summary = stripped.lstrip('#').strip()
else:
summary = stripped
break
if not summary:
summary = os.path.basename(skill_path).replace('.md', '')
return summary, content
except Exception as e:
self.logger.warning(f"读取 Markdown 技能文件失败 {skill_path}: {e}")
return "(读取失败)", ""
@staticmethod
def _first_nonempty_line(text: str) -> str:
"""返回文本中第一行非空内容。"""
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return ""
def _is_markdown_file(self, filename: str) -> bool:
"""判断是否为 Markdown 文件"""
return filename.lower().endswith('.md')
def _is_executable(self, filepath: str) -> bool:
"""检查文件是否可执行"""
if os.name == 'nt': # Windows
# Windows 下检查扩展名
executable_extensions = {'.exe', '.bat', '.cmd', '.ps1', '.py', '.sh'}
ext = os.path.splitext(filepath)[1].lower()
return ext in executable_extensions
else: # Unix/Linux/Mac
return os.access(filepath, os.X_OK)
# ------------------------------------------------------------------
# 加载 / 重载
# ------------------------------------------------------------------
def _load_skills(self):
"""扫描技能目录,加载所有技能文件(可执行文件或 Markdown 文件)。"""
skills_dir = constants.SKILLS_DIR
self.skills = {}
if not os.path.exists(skills_dir):
self.logger.info(f"技能目录不存在: {skills_dir}")
return
skill_files = [
f for f in os.listdir(skills_dir)
if os.path.isfile(os.path.join(skills_dir, f))
and not f.startswith('_')
and not f.startswith('.')
]
for skill_file in skill_files:
skill_path = os.path.join(skills_dir, skill_file)
skill_name = os.path.splitext(skill_file)[0]
is_markdown = self._is_markdown_file(skill_file)
if is_markdown:
# Markdown 格式技能:直接读取内容
summary, content = self._read_markdown_skill(skill_path)
if content:
self.skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
"content": content,
"is_markdown": True,
"is_executable": False
}
self.logger.info(f"已加载 Markdown 技能: {skill_name} — {summary}")
else:
self.logger.warning(f"Markdown 技能文件为空(跳过): {skill_file}")
else:
# 可执行文件格式技能:执行 --help 获取简介
if not self._is_executable(skill_path):
self.logger.warning(f"技能文件不可执行(跳过): {skill_file}")
continue
help_output = self._run_help(skill_path)
summary = self._first_nonempty_line(help_output) or f"扩展技能: {skill_name}"
self.skills[skill_name] = {
"path": skill_path,
"file": skill_file,
"summary": summary,
"content": help_output, # 完整的 --help 输出作为内容
"is_markdown": False,
"is_executable": True
}
self.logger.info(f"已加载可执行技能: {skill_name} — {summary}")
self.logger.info(f"共加载 {len(self.skills)} 个技能({len([s for s in self.skills.values() if s['is_markdown']])} 个 Markdown 技能)")
def reload(self) -> int:
"""重新扫描技能目录,返回加载的技能数量。"""
self._load_skills()
return len(self.skills)
# ------------------------------------------------------------------
# 查询
# ------------------------------------------------------------------
def search(self, keyword: str) -> List[Dict]:
"""在技能名称、简介和内容中搜索关键字,返回匹配项列表。"""
kw = keyword.lower()
results = []
for name, info in self.skills.items():
match = (
kw in name.lower() or
kw in info["summary"].lower() or
kw in info.get("content", "").lower()
)
if match:
results.append({
"name": name,
"path": info["path"],
"summary": info["summary"],
"content": info.get("content", ""),
"is_markdown": info.get("is_markdown", False),
"is_executable": info.get("is_executable", False)
})
return results
def get_skill_content(self, skill_name: str) -> Optional[str]:
"""获取技能的内容(Markdown 技能返回完整内容,可执行技能返回 --help 输出)"""
info = self.skills.get(skill_name)
if info:
return info.get("content")
return None
def get_skill(self, skill_name: str) -> Optional[Dict]:
"""获取单个技能的完整信息"""
return self.skills.get(skill_name)
def get_all(self) -> List[Dict]:
"""返回所有技能的列表(含名称、路径、简介和内容)。"""
return [
{
"name": name,
"path": info["path"],
"summary": info["summary"],
"content": info.get("content", ""),
"is_markdown": info.get("is_markdown", False),
"is_executable": info.get("is_executable", False)
}
for name, info in self.skills.items()
]
def count(self) -> int:
return len(self.skills)