feat(context): CLAUDE.md 注入开关(默认关)+ .agents/skills 行业目录扫描(含同名冲突报错)

- personalization 新增 claude_md_auto_inject(默认关)与 agents_skills_scan_enabled(默认开),配置存 personalization.json 按工作区隔离
- prompt.py 指令文件加载器泛化,messages.py 新增 CLAUDE.md 冻结注入段(模板 claude_md_inject.txt),子目录只列路径通知
- skills_manager catalog 新增工作区 .agents/skills/ 根(原地读取不同步复制),build_skills_list 按真实路径前缀展示并附来源/冲突说明
- read_skill 支持 .agents/skills 路径解析;与 .astrion/skills 同名重复时报错并引导 read_file 按具体路径读取
- 多智能体 sub_agent_context 同步 CLAUDE.md 开关;顺带修复其 skills 段参数名写错被 except 吞掉恒为空、以及 f-string 打印列表 repr 的存量 bug
- 前端个人空间新增两个开关
This commit is contained in:
JOJO 2026-08-26 12:14:49 +08:00
parent d5a7c94c8e
commit 6fb5d10542
12 changed files with 301 additions and 44 deletions

View File

@ -168,6 +168,8 @@ CUSTOM_ROLES_DIR = str(Path(RUNTIME_ROOT) / _MODE / "mutiagents" / "agents")
# web 模式预设角色目录web 模式下使用host 模式下不用) # web 模式预设角色目录web 模式下使用host 模式下不用)
WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents") WEB_PRESET_ROLES_DIR = str(Path(RUNTIME_ROOT) / "web" / "mutiagents" / "agents")
WORKSPACE_SKILLS_DIRNAME = ".astrion/skills" WORKSPACE_SKILLS_DIRNAME = ".astrion/skills"
# 行业通用技能目录Agent Skills 开放标准,工作区根目录下,扫描不同步复制)
PROJECT_AGENTS_SKILLS_DIRNAME = ".agents/skills"
WORKSPACE_MEMORY_DIRNAME = ".astrion/memory" WORKSPACE_MEMORY_DIRNAME = ".astrion/memory"
WORKSPACE_REVIEW_DIRNAME = ".astrion/review" WORKSPACE_REVIEW_DIRNAME = ".astrion/review"
HOST_WORKSPACES_FILE = _resolve_repo_path( HOST_WORKSPACES_FILE = _resolve_repo_path(
@ -193,6 +195,7 @@ __all__ = [
"LOGS_DIR", "LOGS_DIR",
"AGENT_SKILLS_DIR", "AGENT_SKILLS_DIR",
"WORKSPACE_SKILLS_DIRNAME", "WORKSPACE_SKILLS_DIRNAME",
"PROJECT_AGENTS_SKILLS_DIRNAME",
"WORKSPACE_MEMORY_DIRNAME", "WORKSPACE_MEMORY_DIRNAME",
"WORKSPACE_REVIEW_DIRNAME", "WORKSPACE_REVIEW_DIRNAME",
"IS_HOST_MODE", "IS_HOST_MODE",

View File

@ -88,18 +88,18 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True DISABLE_LENGTH_CHECK = True
def _build_sub_agents_md_notice(sub_paths: List[str], total: int) -> str: def _build_sub_md_notice(filename: str, sub_paths: List[str], total: int) -> str:
"""构建子目录 AGENTS.md 路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。""" """构建子目录指令文件路径通知文案(只列相对路径,不注入内容)。无子目录文件时返回空串。"""
if not sub_paths: if not sub_paths:
return "" return ""
lines = ["另外,工作区以下子目录也存在 AGENTS.md 规范文件:", ""] lines = [f"另外,工作区以下子目录也存在 {filename} 规范文件:", ""]
for p in sub_paths: for p in sub_paths:
lines.append(f"- `{p}`") lines.append(f"- `{p}`")
if total > len(sub_paths): if total > len(sub_paths):
lines.append("") lines.append("")
lines.append(f"...共 {total}") lines.append(f"...共 {total}")
lines.append("") lines.append("")
lines.append("这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 AGENTS.md 了解局部规范后再动手,不得将子目录规范当作全局规范套用。") lines.append(f"这些子目录规范仅适用于其所在目录范围。若你的操作涉及上述目录,请先自行读取对应 {filename} 了解局部规范后再动手,不得将子目录规范当作全局规范套用。")
return "\n".join(lines) return "\n".join(lines)
@ -181,7 +181,7 @@ class MessagesMixin:
personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir) personalization_config = getattr(self.context_manager, "custom_personalization_config", None) or load_personalization_config(self.data_dir)
shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False shallow_replace_enabled = bool(personalization_config.get("auto_shallow_compress_enabled", False)) if isinstance(personalization_config, dict) else False
# 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → skills → 记忆 → 自定义 → 禁用提示 # 顺序主prompt → 权限模式 → 执行环境 → 最近对话 → 个性化配置 → 工作区信息 → AGENTS.md → CLAUDE.md → skills → 记忆 → 自定义 → 禁用提示
# 权限模式 # 权限模式
permission_mode_message = self._get_or_init_frozen_mode_prompt( permission_mode_message = self._get_or_init_frozen_mode_prompt(
@ -275,7 +275,7 @@ class MessagesMixin:
return "" return ""
# 子目录 AGENTS.md 只通知相对路径,不注入内容 # 子目录 AGENTS.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_agents_md_paths() sub_paths, sub_total = self._load_sub_agents_md_paths()
sub_notice = _build_sub_agents_md_notice(sub_paths, sub_total) sub_notice = _build_sub_md_notice("AGENTS.md", sub_paths, sub_total)
agents_md_template = self.load_prompt("agents_md_inject").strip() agents_md_template = self.load_prompt("agents_md_inject").strip()
if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template: if agents_md_template and "{{AGENTS_MD_CONTENT}}" in agents_md_template:
text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content) text = agents_md_template.replace("{{AGENTS_MD_CONTENT}}", agents_md_content)
@ -300,8 +300,48 @@ class MessagesMixin:
if agents_md_text: if agents_md_text:
messages.append({"role": "system", "content": agents_md_text}) messages.append({"role": "system", "content": agents_md_text})
# CLAUDE.md默认关闭开启后与 AGENTS.md 并列注入)
def _build_claude_md_prompt() -> str:
claude_md_content = self._load_root_md_content("CLAUDE.md")
if not claude_md_content:
return ""
# 子目录 CLAUDE.md 只通知相对路径,不注入内容
sub_paths, sub_total = self._load_sub_md_paths("CLAUDE.md")
sub_notice = _build_sub_md_notice("CLAUDE.md", sub_paths, sub_total)
claude_md_template = self.load_prompt("claude_md_inject").strip()
if claude_md_template and "{{CLAUDE_MD_CONTENT}}" in claude_md_template:
text = claude_md_template.replace("{{CLAUDE_MD_CONTENT}}", claude_md_content)
text = text.replace("{{CLAUDE_MD_UPDATED_AT}}", self._load_root_md_updated_at("CLAUDE.md"))
text = text.replace("{{SUB_CLAUDE_MD_NOTICE}}", sub_notice)
return text
# 模板缺失/无占位符时的兜底格式
text = f"【CLAUDE.md 项目规范】{self._load_root_md_updated_at('CLAUDE.md')}\n\n{claude_md_content}\n\n---\n请注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若有冲突请以 CLAUDE.md 为准。"
return f"{text}\n\n{sub_notice}" if sub_notice else text
# 与 AGENTS.md 同理,只在开启时生成并缓存
claude_md_inject_enabled = (
bool(personalization_config.get("claude_md_auto_inject", False))
if isinstance(personalization_config, dict)
else False
)
if claude_md_inject_enabled:
claude_md_text = self._get_or_init_frozen_prompt(
"frozen_claude_md_prompt",
_build_claude_md_prompt,
)
if claude_md_text:
messages.append({"role": "system", "content": claude_md_text})
# skills 列表 # skills 列表
skills_catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) skills_catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=(
bool(personalization_config.get("agents_skills_scan_enabled", True))
if isinstance(personalization_config, dict)
else True
),
)
enabled_skills = merge_enabled_skills( enabled_skills = merge_enabled_skills(
personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None, personalization_config.get("enabled_skills") if isinstance(personalization_config, dict) else None,
skills_catalog, skills_catalog,

View File

@ -101,23 +101,23 @@ _AGENTS_MD_SKIP_DIRS = {
class PromptMixin: class PromptMixin:
"""MainTerminalContextMixin prompt 能力 mixin。""" """MainTerminalContextMixin prompt 能力 mixin。"""
def _load_agents_md_content(self) -> Optional[str]: def _load_root_md_content(self, filename: str) -> Optional[str]:
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。""" """加载工作区根目录的指定指令文件内容(仅根目录,子目录的不注入)。"""
try: try:
project_path = Path(self.project_path) project_path = Path(self.project_path)
root_file = project_path / "AGENTS.md" root_file = project_path / filename
if not root_file.is_file(): if not root_file.is_file():
return None return None
content = root_file.read_text(encoding='utf-8') content = root_file.read_text(encoding='utf-8')
return content.strip() if content else None return content.strip() if content else None
except Exception as exc: except Exception as exc:
logger.warning(f"[AGENTS.md] 读取失败: {exc}") logger.warning(f"[{filename}] 读取失败: {exc}")
return None return None
def _load_agents_md_updated_at(self) -> str: def _load_root_md_updated_at(self, filename: str) -> str:
"""返回根目录 AGENTS.md 的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串""" """返回根目录指定指令文件的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
try: try:
root_file = Path(self.project_path) / "AGENTS.md" root_file = Path(self.project_path) / filename
if not root_file.is_file(): if not root_file.is_file():
return "" return ""
mtime = root_file.stat().st_mtime mtime = root_file.stat().st_mtime
@ -125,8 +125,8 @@ class PromptMixin:
except Exception: except Exception:
return "" return ""
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple: def _load_sub_md_paths(self, filename: str, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的 AGENTS.md,返回 (相对路径列表, 实际总数)。 """扫描工作区一级子目录中的指定指令文件,返回 (相对路径列表, 实际总数)。
- 仅扫描根目录与一级子目录不递归跳过通用非源码目录_AGENTS_MD_SKIP_DIRS - 仅扫描根目录与一级子目录不递归跳过通用非源码目录_AGENTS_MD_SKIP_DIRS
- 列表最多 max_notice 超出截断总数仍在第二项返回 - 列表最多 max_notice 超出截断总数仍在第二项返回
@ -141,19 +141,31 @@ class PromptMixin:
start = time.monotonic() start = time.monotonic()
for sub in project_path.iterdir(): for sub in project_path.iterdir():
if time.monotonic() - start >= timeout_seconds: if time.monotonic() - start >= timeout_seconds:
logger.warning(f"[AGENTS.md] 子目录扫描超过 {timeout_seconds}s返回已扫描到的 {len(found)}") logger.warning(f"[{filename}] 子目录扫描超过 {timeout_seconds}s返回已扫描到的 {len(found)}")
break break
if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS: if not sub.is_dir() or sub.name in _AGENTS_MD_SKIP_DIRS:
continue continue
candidate = sub / "AGENTS.md" candidate = sub / filename
if candidate.is_file(): if candidate.is_file():
total += 1 total += 1
if len(found) < max_notice: if len(found) < max_notice:
found.append(candidate.relative_to(project_path).as_posix()) found.append(candidate.relative_to(project_path).as_posix())
except Exception as exc: except Exception as exc:
logger.warning(f"[AGENTS.md] 子目录扫描失败: {exc}") logger.warning(f"[{filename}] 子目录扫描失败: {exc}")
return found, total return found, total
def _load_agents_md_content(self) -> Optional[str]:
"""加载工作区根目录的 AGENTS.md 文件内容(仅根目录,子目录的不注入)。"""
return self._load_root_md_content("AGENTS.md")
def _load_agents_md_updated_at(self) -> str:
"""返回根目录 AGENTS.md 的最后修改时间文案如「最后修改2026-08-12 17:54失败返回空串"""
return self._load_root_md_updated_at("AGENTS.md")
def _load_sub_agents_md_paths(self, max_notice: int = 20, timeout_seconds: float = 30.0) -> tuple:
"""扫描工作区一级子目录中的 AGENTS.md返回 (相对路径列表, 实际总数)。"""
return self._load_sub_md_paths("AGENTS.md", max_notice=max_notice, timeout_seconds=timeout_seconds)
def load_prompt(self, name: str) -> str: def load_prompt(self, name: str) -> str:
"""加载提示模板""" """加载提示模板"""
prompt_file = Path(PROMPTS_DIR) / f"{name}.txt" prompt_file = Path(PROMPTS_DIR) / f"{name}.txt"

View File

@ -119,7 +119,7 @@ class ToolsDefinitionFileToolsMixin:
"type": "function", "type": "function",
"function": { "function": {
"name": "read_skill", "name": "read_skill",
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", "description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/;内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({

View File

@ -21,6 +21,7 @@ try:
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
WORKSPACE_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME,
WORKSPACE_MEMORY_DIRNAME, WORKSPACE_MEMORY_DIRNAME,
PROJECT_AGENTS_SKILLS_DIRNAME,
) )
except ImportError: except ImportError:
import sys import sys
@ -43,6 +44,7 @@ except ImportError:
CUSTOM_TOOLS_ENABLED, CUSTOM_TOOLS_ENABLED,
WORKSPACE_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME,
WORKSPACE_MEMORY_DIRNAME, WORKSPACE_MEMORY_DIRNAME,
PROJECT_AGENTS_SKILLS_DIRNAME,
) )
from modules.file_manager import FileManager from modules.file_manager import FileManager
@ -92,9 +94,10 @@ class MainTerminalToolsReadMixin:
if not raw: if not raw:
return "" return ""
normalized = raw.replace("\\", "/").strip("/") normalized = raw.replace("\\", "/").strip("/")
prefix = f"{WORKSPACE_SKILLS_DIRNAME}/" for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
if normalized.lower().startswith(prefix): if normalized.lower().startswith(prefix):
normalized = normalized[len(prefix):] normalized = normalized[len(prefix):]
break
if normalized.lower().endswith("/skill.md"): if normalized.lower().endswith("/skill.md"):
normalized = normalized[: -len("/SKILL.md")] normalized = normalized[: -len("/SKILL.md")]
return normalized.strip() return normalized.strip()
@ -108,7 +111,16 @@ class MainTerminalToolsReadMixin:
personalization = load_personalization_config(self.data_dir) personalization = load_personalization_config(self.data_dir)
except Exception: except Exception:
personalization = {} personalization = {}
catalog = get_skills_catalog(private_dir=infer_private_skills_dir(self.data_dir)) scan_project_agents = (
bool(personalization.get("agents_skills_scan_enabled", True))
if isinstance(personalization, dict)
else True
)
catalog = get_skills_catalog(
private_dir=infer_private_skills_dir(self.data_dir),
project_path=self.project_path,
scan_project_agents=scan_project_agents,
)
enabled_skills = merge_enabled_skills( enabled_skills = merge_enabled_skills(
personalization.get("enabled_skills") if isinstance(personalization, dict) else None, personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
catalog, catalog,
@ -119,10 +131,28 @@ class MainTerminalToolsReadMixin:
normalized_lower = normalized_input.lower() normalized_lower = normalized_input.lower()
def _resolve_item(item: Dict[str, str]) -> Dict[str, Any]:
sid = item.get("id")
primary_dir = str(item.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
conflict_dir = str(item.get("conflict_dir") or "").strip("/")
if conflict_dir:
# 同名冲突:报错并引导改用 read_file 按具体路径查看
return {
"success": False,
"error": (
f"技能 '{sid}' 存在同名重复:{primary_dir}/{sid}/SKILL.md 与 "
f"{conflict_dir}/{sid}/SKILL.md 均存在read_skill 无法确定读取哪一份。\n"
f"请改用 read_file 工具按具体路径读取查看(可分别读取 "
f"{primary_dir}/{sid}/SKILL.md 与 {conflict_dir}/{sid}/SKILL.md 对比),"
f"或告知用户删除/重命名其中一份以消除重复。"
),
}
return {"success": True, "skill_id": sid, "display_dir": primary_dir}
# 1) 优先按 skill id 精确匹配(忽略大小写) # 1) 优先按 skill id 精确匹配(忽略大小写)
id_map = {str(item.get("id", "")).lower(): item.get("id") for item in filtered_catalog if item.get("id")} id_map = {str(item.get("id", "")).lower(): item for item in filtered_catalog if item.get("id")}
if normalized_lower in id_map: if normalized_lower in id_map:
return {"success": True, "skill_id": id_map[normalized_lower]} return _resolve_item(id_map[normalized_lower])
# 2) 再按 label 匹配(忽略大小写) # 2) 再按 label 匹配(忽略大小写)
label_matches: List[str] = [] label_matches: List[str] = []
@ -131,7 +161,7 @@ class MainTerminalToolsReadMixin:
if label and label == normalized_lower and item.get("id"): if label and label == normalized_lower and item.get("id"):
label_matches.append(item["id"]) label_matches.append(item["id"])
if len(label_matches) == 1: if len(label_matches) == 1:
return {"success": True, "skill_id": label_matches[0]} return _resolve_item(id_map[label_matches[0].lower()])
if len(label_matches) > 1: if len(label_matches) > 1:
return { return {
"success": False, "success": False,
@ -147,8 +177,9 @@ class MainTerminalToolsReadMixin:
return resolved return resolved
skill_id = resolved["skill_id"] skill_id = resolved["skill_id"]
display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
read_args = { read_args = {
"path": f"{WORKSPACE_SKILLS_DIRNAME}/{skill_id}/SKILL.md", "path": f"{display_dir}/{skill_id}/SKILL.md",
"type": "read", "type": "read",
} }
result = self._handle_read_tool(read_args) result = self._handle_read_tool(read_args)

View File

@ -131,27 +131,75 @@ def _build_agents_md_section(workspace_path: str) -> str:
) )
def _build_claude_md_section(workspace_path: str, data_dir: str = "") -> str:
"""构建 CLAUDE.md 项目规范段(遵循 claude_md_auto_inject 开关,默认关闭)。"""
try:
from modules.personalization_manager import load_personalization_config
try:
personalization = load_personalization_config(data_dir) if data_dir else {}
except Exception:
personalization = {}
enabled = (
bool(personalization.get("claude_md_auto_inject", False))
if isinstance(personalization, dict)
else False
)
if not enabled:
return ""
claude_file = Path(workspace_path) / "CLAUDE.md"
if not claude_file.is_file():
return ""
content = claude_file.read_text(encoding="utf-8").strip()
if not content:
return ""
return (
f"## CLAUDE.md 项目规范\n\n"
f"> 以下是工作区根目录的 CLAUDE.md 文件内容,请在回答时参考这些项目规范:\n\n"
f"{content}\n\n"
f"---\n"
f"注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。\n"
)
except Exception:
return ""
def _build_skills_section(workspace_path: str, data_dir: str = "") -> str: def _build_skills_section(workspace_path: str, data_dir: str = "") -> str:
"""构建可用 skill 列表段。""" """构建可用 skill 列表段。"""
try: try:
from modules.personalization_manager import load_personalization_config
from modules.skills_manager import ( from modules.skills_manager import (
get_skills_catalog, get_skills_catalog,
build_skills_list, build_skills_list,
resolve_enabled_skills,
infer_private_skills_dir, infer_private_skills_dir,
) )
private_dir = infer_private_skills_dir(data_dir) if data_dir else None private_dir = infer_private_skills_dir(data_dir) if data_dir else None
catalog = get_skills_catalog(private_dir=private_dir) try:
personalization = load_personalization_config(data_dir) if data_dir else {}
except Exception:
personalization = {}
scan_project_agents = (
bool(personalization.get("agents_skills_scan_enabled", True))
if isinstance(personalization, dict)
else True
)
catalog = get_skills_catalog(
private_dir=private_dir,
project_path=workspace_path,
scan_project_agents=scan_project_agents,
)
# 子智能体不区分 enabled/disabled列出全部可用 skill # 子智能体不区分 enabled/disabled列出全部可用 skill
skills_list = build_skills_list(catalog, enabled=None) skills_list = build_skills_list(catalog, resolve_enabled_skills(None, catalog))
if not skills_list: if not skills_list:
return "" return ""
return ( return (
f"## 可用 AgentSkill\n\n" "## 可用 AgentSkill\n\n"
f"agent skills 系统已启用,以下是可用的 skills含简要说明\n\n" "agent skills 系统已启用,以下是可用的 skills含简要说明\n\n"
f"{skills_list}\n\n" + "\n".join(skills_list)
f"使用技能时,优先用 read_skill 通过技能名读取 SKILL.md" + "\n\n使用技能时,优先用 read_skill 通过技能名读取 SKILL.md"
f"需要阅读 skill 目录中的其他文件时再使用 read_file\n" "需要阅读 skill 目录中的其他文件时再使用 read_file\n"
) )
except Exception: except Exception:
return "" return ""
@ -234,6 +282,11 @@ def build_sub_agent_dynamic_context(
if agents_md: if agents_md:
sections.append(agents_md) sections.append(agents_md)
# 4.5 CLAUDE.md遵循 claude_md_auto_inject 开关,默认关闭)
claude_md = _build_claude_md_section(workspace_path, data_dir=data_dir)
if claude_md:
sections.append(claude_md)
# 5. 可用 skill # 5. 可用 skill
skills = _build_skills_section(workspace_path, data_dir=data_dir) skills = _build_skills_section(workspace_path, data_dir=data_dir)
if skills: if skills:

View File

@ -112,6 +112,8 @@ DEFAULT_PERSONALIZATION_CONFIG: Dict[str, Any] = {
"versioning_backup_mode": "shallow", # 文件备份方式shallow-浅备份只备份AI编辑的文件/ full-完全备份(整个工作区) "versioning_backup_mode": "shallow", # 文件备份方式shallow-浅备份只备份AI编辑的文件/ full-完全备份(整个工作区)
"versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite "versioning_restore_mode": "overwrite", # 版本回溯模式固定为 overwrite
"agents_md_auto_inject": False, # AGENTS.md 自动注入开关 "agents_md_auto_inject": False, # AGENTS.md 自动注入开关
"claude_md_auto_inject": False, # CLAUDE.md 自动注入开关(默认关闭)
"agents_skills_scan_enabled": True, # 自动扫描工作区 .agents/skills/ 行业通用技能目录(默认开启)
"new_chat_button_behavior": "route", # 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话 "new_chat_button_behavior": "route", # 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话
"default_hide_workspace": False, # 默认隐藏工作区 "default_hide_workspace": False, # 默认隐藏工作区
"hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列) "hide_quick_dock": False, # 隐藏快捷窗口(对话区右侧的待办/子智能体/后台指令/文件窗口列)
@ -548,6 +550,18 @@ def sanitize_personalization_payload(
else: else:
base["agents_md_auto_inject"] = bool(base.get("agents_md_auto_inject", False)) base["agents_md_auto_inject"] = bool(base.get("agents_md_auto_inject", False))
# CLAUDE.md 自动注入开关(默认关闭)
if "claude_md_auto_inject" in data:
base["claude_md_auto_inject"] = bool(data.get("claude_md_auto_inject"))
else:
base["claude_md_auto_inject"] = bool(base.get("claude_md_auto_inject", False))
# .agents/skills/ 行业通用技能目录扫描开关(默认开启)
if "agents_skills_scan_enabled" in data:
base["agents_skills_scan_enabled"] = bool(data.get("agents_skills_scan_enabled"))
else:
base["agents_skills_scan_enabled"] = bool(base.get("agents_skills_scan_enabled", True))
# 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话 # 新建对话按钮行为route-跳转空白新对话页 / blank-立即创建空对话
new_chat_behavior = data.get("new_chat_button_behavior", base.get("new_chat_button_behavior")) new_chat_behavior = data.get("new_chat_button_behavior", base.get("new_chat_button_behavior"))
if isinstance(new_chat_behavior, str) and new_chat_behavior.strip().lower() in ("route", "blank"): if isinstance(new_chat_behavior, str) and new_chat_behavior.strip().lower() in ("route", "blank"):

View File

@ -12,7 +12,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Sequence from typing import Dict, List, Optional, Sequence
from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, WORKSPACE_SKILLS_DIRNAME from config import AGENT_SKILLS_DIR, CUSTOM_SKILLS_DIR, IS_HOST_MODE, PROJECT_AGENTS_SKILLS_DIRNAME, WORKSPACE_SKILLS_DIRNAME
from utils.logger import setup_logger from utils.logger import setup_logger
logger = setup_logger(__name__) logger = setup_logger(__name__)
@ -282,8 +282,15 @@ def _scan_skills_catalog(root: Path) -> List[Dict[str, str]]:
def get_skills_catalog( def get_skills_catalog(
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
private_dir: Optional[str | Path] = None, private_dir: Optional[str | Path] = None,
project_path: Optional[str | Path] = None,
scan_project_agents: bool = True,
) -> List[Dict[str, str]]: ) -> List[Dict[str, str]]:
"""List available skills from global library plus optional private library.""" """List available skills from global library plus optional private library.
来源标注item["source"] = global仓库 agentskills// private用户私有/ agents工作区 .agents/skills/
item["display_dir"] 为该 skill 在工作区内的可读路径前缀global/private 经同步复制后在 .astrion/skills/
agents 原地读取在 .agents/skills/同名冲突时保留已有条目并打 conflict_dir 标记 read_skill 报错引导
"""
roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()] roots: List[Path] = [Path(base_dir or AGENT_SKILLS_DIR).expanduser().resolve()]
if private_dir: if private_dir:
private_root = Path(private_dir).expanduser().resolve() private_root = Path(private_dir).expanduser().resolve()
@ -292,15 +299,43 @@ def get_skills_catalog(
merged: Dict[str, Dict[str, str]] = {} merged: Dict[str, Dict[str, str]] = {}
order: List[str] = [] order: List[str] = []
for root in roots: for index, root in enumerate(roots):
source = "global" if index == 0 else "private"
for item in _scan_skills_catalog(root): for item in _scan_skills_catalog(root):
skill_id = item.get("id") skill_id = item.get("id")
if not skill_id: if not skill_id:
continue continue
if skill_id not in merged: if skill_id not in merged:
order.append(skill_id) order.append(skill_id)
item["source"] = source
item["display_dir"] = WORKSPACE_SKILLS_DIRNAME
# Later roots (private) override metadata for same id. # Later roots (private) override metadata for same id.
merged[skill_id] = item merged[skill_id] = item
# 行业通用目录:工作区 .agents/skills/Agent Skills 开放标准路径;原地读取,不参与同步复制)
if project_path and scan_project_agents:
try:
agents_root = (Path(project_path).expanduser().resolve() / PROJECT_AGENTS_SKILLS_DIRNAME).resolve()
except Exception:
agents_root = None
if agents_root and agents_root not in roots:
for item in _scan_skills_catalog(agents_root):
skill_id = item.get("id")
if not skill_id:
continue
if skill_id in merged:
# 同名冲突:保留已有(.astrion/skills条目仅打冲突标记
# prompt 列表与 read_skill 据此提示用户用 read_file 按具体路径查看
merged[skill_id]["conflict_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME
logger.warning(
"[skills] 技能 %s 同时存在于 %s%s,已标记同名冲突",
skill_id, WORKSPACE_SKILLS_DIRNAME, PROJECT_AGENTS_SKILLS_DIRNAME,
)
continue
item["source"] = "agents"
item["display_dir"] = PROJECT_AGENTS_SKILLS_DIRNAME
order.append(skill_id)
merged[skill_id] = item
return [merged[skill_id] for skill_id in order if skill_id in merged] return [merged[skill_id] for skill_id in order if skill_id in merged]
@ -353,20 +388,41 @@ def build_skills_list(
catalog: Sequence[Dict[str, str]], catalog: Sequence[Dict[str, str]],
enabled_skill_ids: Sequence[str], enabled_skill_ids: Sequence[str],
) -> List[str]: ) -> List[str]:
"""Build skills list lines / 生成 skills 列表行。""" """Build skills list lines / 生成 skills 列表行。
列表行直接使用该 skill 在工作区内的真实路径前缀.astrion/skills/ .agents/skills/
.agents/skills/ 来源与同名冲突会在列表尾部附说明行
"""
if not enabled_skill_ids: if not enabled_skill_ids:
return [] return []
lookup = {item.get("id"): item for item in catalog if item.get("id")} lookup = {item.get("id"): item for item in catalog if item.get("id")}
lines: List[str] = [] lines: List[str] = []
has_agents_source = False
has_conflict = False
for skill_id in enabled_skill_ids: for skill_id in enabled_skill_ids:
meta = lookup.get(skill_id) meta = lookup.get(skill_id)
if not meta: if not meta:
continue continue
display_dir = (meta.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
if meta.get("source") == "agents":
has_agents_source = True
description = (meta.get("description") or "").strip() description = (meta.get("description") or "").strip()
if description: line = f"{display_dir}/{skill_id}{description}" if description else f"{display_dir}/{skill_id}"
lines.append(f".astrion/skills/{skill_id}{description}") conflict_dir = (meta.get("conflict_dir") or "").strip("/")
else: if conflict_dir:
lines.append(f".astrion/skills/{skill_id}") has_conflict = True
line += f"(⚠️ 同名重复:{conflict_dir}/{skill_id} 也存在read_skill 会报错,请改用 read_file 按具体路径读取)"
lines.append(line)
if has_agents_source:
lines.append(
f"注:以 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 开头的技能来自工作区 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 目录"
"(行业通用技能目录,由项目仓库提供,非 Astrion 管理),同样可用 read_skill 读取。"
)
if has_conflict:
lines.append(
f"注:标注同名重复的技能在 {WORKSPACE_SKILLS_DIRNAME}/ 与 {PROJECT_AGENTS_SKILLS_DIRNAME}/ 各有一份,"
"内容可能不同read_skill 对重复名会报错,此时请改用 read_file 读取具体路径查看。"
)
return lines return lines

View File

@ -163,7 +163,7 @@ SUB_AGENT_TOOLS: List[Dict[str, Any]] = [
"type": "function", "type": "function",
"function": { "function": {
"name": "read_skill", "name": "read_skill",
"description": "按 skill 名称读取 .astrion/skills/<name>/SKILL.md 内容;内部等价于 read_file 的 read 模式,并返回解析后的 path。", "description": "按 skill 名称读取 SKILL.md 内容(.astrion/skills/<name>/ 或 .agents/skills/<name>/;内部等价于 read_file 的 read 模式,并返回解析后的 path。若技能在 .astrion/skills/ 与 .agents/skills/ 同名重复,会报错并提示改用 read_file 按具体路径读取",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -0,0 +1,10 @@
【CLAUDE.md 项目规范】
以下是工作区根目录 CLAUDE.md 文件的内容{{CLAUDE_MD_UPDATED_AT}},请在回答时参考这些项目规范:
{{CLAUDE_MD_CONTENT}}
---
注意:以上规范来自工作区根目录的 CLAUDE.md 文件,若与未来代码冲突,以实际代码为准。
{{SUB_CLAUDE_MD_NOTICE}}

View File

@ -931,6 +931,38 @@
}) })
" /><FancyCheck :checked="form.agents_md_auto_inject" /></label> " /><FancyCheck :checked="form.agents_md_auto_inject" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">CLAUDE.md 自动注入</span
><span class="settings-row-desc"
>工作区根目录存在 CLAUDE.md 时自动注入系统提示词默认关闭 AGENTS.md 并列注入</span
></span
><input
type="checkbox"
:checked="form.claude_md_auto_inject"
@change="
personalization.updateField({
key: 'claude_md_auto_inject',
value: $event.target.checked
})
" /><FancyCheck :checked="form.claude_md_auto_inject" /></label>
<label class="settings-toggle-row"
><span class="settings-row-copy"
><span class="settings-row-title">扫描 .agents/skills/ 技能目录</span
><span class="settings-row-desc"
>自动扫描工作区 .agents/skills/ 下的行业通用技能Agent Skills 开放标准路径 .astrion/skills/ 技能一并列出同名重复时 read_skill 会报错并提示按具体路径读取</span
></span
><input
type="checkbox"
:checked="form.agents_skills_scan_enabled"
@change="
personalization.updateField({
key: 'agents_skills_scan_enabled',
value: $event.target.checked
})
" /><FancyCheck :checked="form.agents_skills_scan_enabled" /></label>
<label class="settings-toggle-row" <label class="settings-toggle-row"
><span class="settings-row-copy" ><span class="settings-row-copy"
><span class="settings-row-title">文件修改留痕</span ><span class="settings-row-title">文件修改留痕</span

View File

@ -132,6 +132,8 @@ interface PersonalForm {
deep_compress_trigger_tokens: number | null; deep_compress_trigger_tokens: number | null;
deep_compress_form: 'file' | 'inject'; deep_compress_form: 'file' | 'inject';
agents_md_auto_inject: boolean; agents_md_auto_inject: boolean;
claude_md_auto_inject: boolean;
agents_skills_scan_enabled: boolean;
new_chat_button_behavior: 'route' | 'blank'; new_chat_button_behavior: 'route' | 'blank';
group_sidebar_by_workspace: boolean; group_sidebar_by_workspace: boolean;
sidebar_pinned_workspaces: string[]; sidebar_pinned_workspaces: string[];
@ -336,6 +338,8 @@ const defaultForm = (): PersonalForm => ({
deep_compress_trigger_tokens: null, deep_compress_trigger_tokens: null,
deep_compress_form: 'file', deep_compress_form: 'file',
agents_md_auto_inject: false, agents_md_auto_inject: false,
claude_md_auto_inject: false,
agents_skills_scan_enabled: true,
new_chat_button_behavior: 'route', new_chat_button_behavior: 'route',
group_sidebar_by_workspace: false, group_sidebar_by_workspace: false,
sidebar_pinned_workspaces: [], sidebar_pinned_workspaces: [],
@ -600,6 +604,8 @@ export const usePersonalizationStore = defineStore('personalization', {
), ),
deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file', deep_compress_form: data.deep_compress_form === 'inject' ? 'inject' : 'file',
agents_md_auto_inject: !!data.agents_md_auto_inject, agents_md_auto_inject: !!data.agents_md_auto_inject,
claude_md_auto_inject: !!data.claude_md_auto_inject,
agents_skills_scan_enabled: data.agents_skills_scan_enabled === undefined ? true : !!data.agents_skills_scan_enabled,
new_chat_button_behavior: data.new_chat_button_behavior === 'blank' ? 'blank' : 'route', new_chat_button_behavior: data.new_chat_button_behavior === 'blank' ? 'blank' : 'route',
group_sidebar_by_workspace: !!data.group_sidebar_by_workspace, group_sidebar_by_workspace: !!data.group_sidebar_by_workspace,
sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces) sidebar_pinned_workspaces: Array.isArray(data.sidebar_pinned_workspaces)