- 新增 modules/i18n.py:tr() + 进程级语言缓存 + modules/i18n_messages/ 域文案包自动聚合 - 新增 29 个域文案包,共 1153 条双语 key;90+ 源文件 1146 处用户可见消息 tr 化 - ui_locale 存入 personalization.json(用户级共享),前后端双向同步 - 前端匹配点双语兼容(history/shared/ChatArea/taskPolling/upload 等正则) - 修复语言判等陷阱:审批等待加稳定 code 字段;conversation.py 不存在判等改双语 helper - 边界:日志/prompt 注入/子智能体工具回填/容器内嵌脚本不迁移
584 lines
26 KiB
Python
584 lines
26 KiB
Python
import asyncio
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Set
|
||
|
||
try:
|
||
from config import (
|
||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||
READ_TOOL_MAX_FILE_SIZE,
|
||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||
TERMINAL_SANDBOX_MODE,
|
||
TERMINAL_SANDBOX_CPUS,
|
||
TERMINAL_SANDBOX_MEMORY,
|
||
PROJECT_MAX_STORAGE_MB,
|
||
CUSTOM_TOOLS_ENABLED,
|
||
WORKSPACE_SKILLS_DIRNAME,
|
||
WORKSPACE_MEMORY_DIRNAME,
|
||
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||
)
|
||
except ImportError:
|
||
import sys
|
||
project_root = Path(__file__).resolve().parents[2]
|
||
if str(project_root) not in sys.path:
|
||
sys.path.insert(0, str(project_root))
|
||
from config import (
|
||
OUTPUT_FORMATS, DATA_DIR, PROMPTS_DIR, NEED_CONFIRMATION,
|
||
MAX_TERMINALS, TERMINAL_BUFFER_SIZE, TERMINAL_DISPLAY_SIZE,
|
||
MAX_READ_FILE_CHARS, READ_TOOL_DEFAULT_MAX_CHARS,
|
||
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
|
||
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
|
||
READ_TOOL_MAX_FILE_SIZE,
|
||
TERMINAL_SANDBOX_MOUNT_PATH,
|
||
TERMINAL_SANDBOX_MODE,
|
||
TERMINAL_SANDBOX_CPUS,
|
||
TERMINAL_SANDBOX_MEMORY,
|
||
PROJECT_MAX_STORAGE_MB,
|
||
CUSTOM_TOOLS_ENABLED,
|
||
WORKSPACE_SKILLS_DIRNAME,
|
||
WORKSPACE_MEMORY_DIRNAME,
|
||
PROJECT_AGENTS_SKILLS_DIRNAME,
|
||
)
|
||
|
||
from modules.file_manager import FileManager
|
||
from modules.search_engine import SearchEngine
|
||
from modules.terminal_ops import TerminalOperator
|
||
from modules.memory_manager import MemoryManager
|
||
from modules.terminal_manager import TerminalManager
|
||
from modules.todo_manager import TodoManager
|
||
from modules.sub_agent import SubAgentManager
|
||
from modules.webpage_extractor import extract_webpage_content, tavily_extract
|
||
from modules.ocr_client import OCRClient
|
||
from modules.easter_egg_manager import EasterEggManager
|
||
from modules.personalization_manager import (
|
||
load_personalization_config,
|
||
build_personalization_prompt,
|
||
)
|
||
from modules.skills_manager import (
|
||
get_skills_catalog,
|
||
build_skills_list,
|
||
merge_enabled_skills,
|
||
build_skills_prompt,
|
||
infer_private_skills_dir,
|
||
)
|
||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||
from modules.custom_tool_executor import CustomToolExecutor
|
||
|
||
|
||
from modules.container_monitor import collect_stats, inspect_state
|
||
from core.tool_config import TOOL_CATEGORIES
|
||
from utils.api_client import APIClient
|
||
from utils.context_manager import ContextManager
|
||
from utils.tool_result_formatter import format_tool_result_for_context
|
||
from utils.logger import setup_logger
|
||
from config.model_profiles import (
|
||
get_model_profile,
|
||
get_model_prompt_replacements,
|
||
get_model_context_window,
|
||
)
|
||
|
||
from modules.i18n import tr
|
||
|
||
logger = setup_logger(__name__)
|
||
DISABLE_LENGTH_CHECK = True
|
||
|
||
class MainTerminalToolsReadMixin:
|
||
@staticmethod
|
||
def _normalize_skill_name(skill_name: Any) -> str:
|
||
raw = str(skill_name or "").strip()
|
||
if not raw:
|
||
return ""
|
||
normalized = raw.replace("\\", "/").strip("/")
|
||
for prefix in (f"{WORKSPACE_SKILLS_DIRNAME}/", f"{PROJECT_AGENTS_SKILLS_DIRNAME}/"):
|
||
if normalized.lower().startswith(prefix):
|
||
normalized = normalized[len(prefix):]
|
||
break
|
||
if normalized.lower().endswith("/skill.md"):
|
||
normalized = normalized[: -len("/SKILL.md")]
|
||
return normalized.strip()
|
||
|
||
def _resolve_skill_id(self, skill_name: Any) -> Dict[str, Any]:
|
||
normalized_input = self._normalize_skill_name(skill_name)
|
||
if not normalized_input:
|
||
return {"success": False, "error": tr("tools_read.skill_name_empty")}
|
||
|
||
try:
|
||
personalization = load_personalization_config(self.data_dir)
|
||
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=infer_private_skills_dir(self.data_dir),
|
||
project_path=self.project_path,
|
||
scan_project_agents=scan_project_agents,
|
||
)
|
||
enabled_skills = merge_enabled_skills(
|
||
personalization.get("enabled_skills") if isinstance(personalization, dict) else None,
|
||
catalog,
|
||
personalization.get("skills_catalog_snapshot") if isinstance(personalization, dict) else None,
|
||
)
|
||
enabled_set = set(enabled_skills or [])
|
||
filtered_catalog = [item for item in catalog if item.get("id") in enabled_set] if enabled_set else list(catalog)
|
||
|
||
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": tr(
|
||
"tools_read.skill_name_conflict",
|
||
sid=sid,
|
||
primary_dir=primary_dir,
|
||
conflict_dir=conflict_dir,
|
||
),
|
||
}
|
||
return {"success": True, "skill_id": sid, "display_dir": primary_dir}
|
||
|
||
# 1) 优先按 skill id 精确匹配(忽略大小写)
|
||
id_map = {str(item.get("id", "")).lower(): item for item in filtered_catalog if item.get("id")}
|
||
if normalized_lower in id_map:
|
||
return _resolve_item(id_map[normalized_lower])
|
||
|
||
# 2) 再按 label 匹配(忽略大小写)
|
||
label_matches: List[str] = []
|
||
for item in filtered_catalog:
|
||
label = str(item.get("label") or "").strip().lower()
|
||
if label and label == normalized_lower and item.get("id"):
|
||
label_matches.append(item["id"])
|
||
if len(label_matches) == 1:
|
||
return _resolve_item(id_map[label_matches[0].lower()])
|
||
if len(label_matches) > 1:
|
||
return {
|
||
"success": False,
|
||
"error": tr("tools_read.skill_name_ambiguous", matches=', '.join(sorted(label_matches)))
|
||
}
|
||
|
||
return {"success": False, "error": tr("tools_read.skill_not_found", name=normalized_input)}
|
||
|
||
def _handle_read_skill_tool(self, arguments: Dict) -> Dict:
|
||
skill_name = arguments.get("skill_name")
|
||
resolved = self._resolve_skill_id(skill_name)
|
||
if not resolved.get("success"):
|
||
return resolved
|
||
|
||
skill_id = resolved["skill_id"]
|
||
display_dir = str(resolved.get("display_dir") or WORKSPACE_SKILLS_DIRNAME).strip("/")
|
||
read_args = {
|
||
"path": f"{display_dir}/{skill_id}/SKILL.md",
|
||
"type": "read",
|
||
}
|
||
result = self._handle_read_tool(read_args)
|
||
if not result.get("success"):
|
||
return result
|
||
|
||
result["skill_id"] = skill_id
|
||
result["skill_name"] = skill_name
|
||
return result
|
||
|
||
def _handle_recall_project_memory(self, name: str) -> Dict:
|
||
"""处理 recall_project_memory:读取 .astrion/memory/{name}.md"""
|
||
safe_name = str(name).strip()
|
||
if not safe_name or "/" in safe_name or "\\" in safe_name:
|
||
return {"success": False, "error": tr("tools_read.memory_name_invalid", name=name)}
|
||
file_path = f"{WORKSPACE_MEMORY_DIRNAME}/{safe_name}.md"
|
||
read_args = {
|
||
"path": file_path,
|
||
"type": "read",
|
||
}
|
||
result = self._handle_read_tool(read_args)
|
||
if result.get("success"):
|
||
result["memory_name"] = safe_name
|
||
return result
|
||
|
||
def _handle_search_project_memory(self, keywords: List[str], max_results: int = 5) -> Dict:
|
||
"""处理 search_project_memory:在 .astrion/memory/*.md 中做关键词全文检索。
|
||
|
||
评分:名称命中 +10,描述命中 +5,正文每行命中 +1(单关键词最多计 5 行)。
|
||
返回 top-N 结果,附匹配行片段(行号基于完整文件,可直接配合 read_file extract 使用)。
|
||
"""
|
||
clean_keywords: List[str] = []
|
||
for kw in keywords or []:
|
||
kw_text = str(kw or "").strip()
|
||
if kw_text and kw_text not in clean_keywords:
|
||
clean_keywords.append(kw_text)
|
||
clean_keywords = clean_keywords[:5]
|
||
if not clean_keywords:
|
||
return {"success": False, "error": tr("tools_read.search_memory_needs_keywords")}
|
||
|
||
max_results = self._clamp_int(max_results, 5, 1, 10)
|
||
memory_dir = Path(self.project_path) / WORKSPACE_MEMORY_DIRNAME
|
||
if not memory_dir.exists() or not memory_dir.is_dir():
|
||
empty_text = tr("tools_read.memory_dir_not_exists")
|
||
return {
|
||
"success": True,
|
||
"count": 0,
|
||
"keywords": clean_keywords,
|
||
"results": [],
|
||
"content": empty_text,
|
||
"summary": empty_text,
|
||
}
|
||
|
||
lowered = [(kw, kw.lower()) for kw in clean_keywords]
|
||
scored: List[Dict[str, Any]] = []
|
||
for md_file in sorted(memory_dir.glob("*.md")):
|
||
try:
|
||
text = md_file.read_text(encoding="utf-8")
|
||
except Exception:
|
||
continue
|
||
|
||
lines = text.split("\n")
|
||
name = md_file.stem
|
||
description = ""
|
||
body_start_idx = 0 # 0-based,正文起始行(跳过 frontmatter)
|
||
if lines and lines[0].strip() == "---":
|
||
for i in range(1, len(lines)):
|
||
if lines[i].strip() == "---":
|
||
for fm_line in lines[1:i]:
|
||
fm_stripped = fm_line.strip()
|
||
if fm_stripped.startswith("name:"):
|
||
name = fm_stripped.split(":", 1)[1].strip() or name
|
||
elif fm_stripped.startswith("description:"):
|
||
description = fm_stripped.split(":", 1)[1].strip()
|
||
body_start_idx = i + 1
|
||
break
|
||
|
||
name_lower = name.lower()
|
||
desc_lower = description.lower()
|
||
body_lines = lines[body_start_idx:]
|
||
|
||
score = 0
|
||
matched_keywords: List[str] = []
|
||
for kw, kw_lower in lowered:
|
||
kw_score = 0
|
||
if kw_lower in name_lower:
|
||
kw_score += 10
|
||
if kw_lower in desc_lower:
|
||
kw_score += 5
|
||
body_hits = sum(1 for line in body_lines if kw_lower in line.lower())
|
||
kw_score += min(body_hits, 5)
|
||
if kw_score > 0:
|
||
score += kw_score
|
||
matched_keywords.append(kw)
|
||
|
||
if score <= 0:
|
||
continue
|
||
|
||
snippets: List[Dict[str, Any]] = []
|
||
for idx in range(body_start_idx, len(lines)):
|
||
line_stripped = lines[idx].strip()
|
||
if not line_stripped:
|
||
continue
|
||
line_lower = line_stripped.lower()
|
||
if any(kw_lower in line_lower for _, kw_lower in lowered):
|
||
snippet_text = line_stripped if len(line_stripped) <= 120 else line_stripped[:117] + "..."
|
||
snippets.append({"line": idx + 1, "text": snippet_text})
|
||
if len(snippets) >= 3:
|
||
break
|
||
|
||
scored.append({
|
||
"file": md_file.name,
|
||
"name": name,
|
||
"description": description,
|
||
"score": score,
|
||
"matched_keywords": matched_keywords,
|
||
"snippets": snippets,
|
||
})
|
||
|
||
scored.sort(key=lambda item: (-item["score"], -len(item["matched_keywords"]), item["name"]))
|
||
top = scored[:max_results]
|
||
|
||
if not top:
|
||
empty_text = tr(
|
||
"tools_read.search_no_match_content",
|
||
keywords="、".join(clean_keywords),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"count": 0,
|
||
"keywords": clean_keywords,
|
||
"results": [],
|
||
"content": empty_text,
|
||
"summary": tr("tools_read.search_no_match_summary"),
|
||
}
|
||
|
||
content_lines = [
|
||
tr(
|
||
"tools_read.search_found_header",
|
||
count=len(top),
|
||
keywords="、".join(clean_keywords),
|
||
),
|
||
"",
|
||
]
|
||
for rank, item in enumerate(top, start=1):
|
||
content_lines.append(tr("tools_read.search_result_item", rank=rank, name=item['name'], file=item['file']))
|
||
if item["description"]:
|
||
content_lines.append(tr("tools_read.search_result_desc", description=item['description']))
|
||
if item["snippets"]:
|
||
content_lines.append(tr("tools_read.search_result_snippets_label"))
|
||
for snippet in item["snippets"]:
|
||
content_lines.append(f" L{snippet['line']}: {snippet['text']}")
|
||
content_lines.append("")
|
||
content_lines.append(tr("tools_read.search_read_full_hint"))
|
||
content_text = "\n".join(content_lines).strip()
|
||
|
||
return {
|
||
"success": True,
|
||
"count": len(top),
|
||
"keywords": clean_keywords,
|
||
"results": top,
|
||
"content": content_text,
|
||
"summary": tr("tools_read.search_found_summary", count=len(top)),
|
||
}
|
||
|
||
@staticmethod
|
||
def _clamp_int(value, default, min_value=None, max_value=None):
|
||
"""将输入转换为整数并限制范围。"""
|
||
if value is None:
|
||
return default
|
||
try:
|
||
num = int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
if min_value is not None:
|
||
num = max(min_value, num)
|
||
if max_value is not None:
|
||
num = min(max_value, num)
|
||
return num
|
||
|
||
@staticmethod
|
||
def _parse_optional_line(value, field_name: str):
|
||
"""解析可选的行号参数。"""
|
||
if value is None:
|
||
return None, None
|
||
try:
|
||
number = int(value)
|
||
except (TypeError, ValueError):
|
||
return None, tr("tools_read.param_must_be_int", field_name=field_name)
|
||
if number < 1:
|
||
return None, tr("tools_read.param_must_be_gte_1", field_name=field_name)
|
||
return number, None
|
||
|
||
@staticmethod
|
||
def _truncate_text_block(text: str, max_chars: int):
|
||
"""对单段文本应用字符限制。"""
|
||
if max_chars and len(text) > max_chars:
|
||
return text[:max_chars], True, max_chars
|
||
return text, False, len(text)
|
||
|
||
@staticmethod
|
||
def _limit_text_chunks(chunks: List[Dict], text_key: str, max_chars: int):
|
||
"""对多个文本片段应用全局字符限制。"""
|
||
if max_chars is None or max_chars <= 0:
|
||
return chunks, False, sum(len(chunk.get(text_key, "") or "") for chunk in chunks)
|
||
|
||
remaining = max_chars
|
||
limited_chunks: List[Dict] = []
|
||
truncated = False
|
||
consumed = 0
|
||
|
||
for chunk in chunks:
|
||
snippet = chunk.get(text_key, "") or ""
|
||
snippet_len = len(snippet)
|
||
chunk_copy = dict(chunk)
|
||
|
||
if remaining <= 0:
|
||
truncated = True
|
||
break
|
||
|
||
if snippet_len > remaining:
|
||
chunk_copy[text_key] = snippet[:remaining]
|
||
chunk_copy["truncated"] = True
|
||
consumed += remaining
|
||
limited_chunks.append(chunk_copy)
|
||
truncated = True
|
||
remaining = 0
|
||
break
|
||
|
||
limited_chunks.append(chunk_copy)
|
||
consumed += snippet_len
|
||
remaining -= snippet_len
|
||
|
||
return limited_chunks, truncated, consumed
|
||
|
||
def _handle_read_tool(self, arguments: Dict) -> Dict:
|
||
"""集中处理 read_file 工具的三种模式。"""
|
||
file_path = arguments.get("path")
|
||
if not file_path:
|
||
return {"success": False, "error": tr("tools_read.missing_file_path")}
|
||
|
||
read_type = (arguments.get("type") or "read").lower()
|
||
if read_type not in {"read", "search", "extract"}:
|
||
return {"success": False, "error": tr("tools_read.unknown_read_type", read_type=read_type)}
|
||
|
||
max_chars = self._clamp_int(
|
||
arguments.get("max_chars"),
|
||
READ_TOOL_DEFAULT_MAX_CHARS,
|
||
1,
|
||
MAX_READ_FILE_CHARS
|
||
)
|
||
|
||
base_result = {
|
||
"success": True,
|
||
"type": read_type,
|
||
"path": None,
|
||
"encoding": "utf-8",
|
||
"max_chars": max_chars,
|
||
"truncated": False
|
||
}
|
||
|
||
if read_type == "read":
|
||
start_line, error = self._parse_optional_line(arguments.get("start_line"), "start_line")
|
||
if error:
|
||
return {"success": False, "error": error}
|
||
end_line_val = arguments.get("end_line")
|
||
end_line = None
|
||
if end_line_val is not None:
|
||
end_line, error = self._parse_optional_line(end_line_val, "end_line")
|
||
if error:
|
||
return {"success": False, "error": error}
|
||
if start_line and end_line < start_line:
|
||
return {"success": False, "error": tr("tools_read.end_line_ge_start_line")}
|
||
|
||
read_result = self.file_manager.read_text_segment(
|
||
file_path,
|
||
start_line=start_line,
|
||
end_line=end_line,
|
||
size_limit=READ_TOOL_MAX_FILE_SIZE
|
||
)
|
||
if not read_result.get("success"):
|
||
return read_result
|
||
|
||
content, truncated, char_count = self._truncate_text_block(read_result["content"], max_chars)
|
||
base_result.update({
|
||
"path": read_result["path"],
|
||
"content": content,
|
||
"line_start": read_result["line_start"],
|
||
"line_end": read_result["line_end"],
|
||
"total_lines": read_result["total_lines"],
|
||
"file_size": read_result["size"],
|
||
"char_count": char_count,
|
||
"message": tr(
|
||
"tools_read.read_success",
|
||
path=read_result["path"],
|
||
line_start=read_result["line_start"],
|
||
line_end=read_result["line_end"],
|
||
)
|
||
})
|
||
base_result["truncated"] = truncated
|
||
self.context_manager.load_file(read_result["path"])
|
||
return base_result
|
||
|
||
if read_type == "search":
|
||
query = arguments.get("query")
|
||
if not query:
|
||
return {"success": False, "error": tr("tools_read.search_requires_query")}
|
||
|
||
max_matches = self._clamp_int(
|
||
arguments.get("max_matches"),
|
||
READ_TOOL_DEFAULT_MAX_MATCHES,
|
||
1,
|
||
READ_TOOL_MAX_MATCHES
|
||
)
|
||
context_before = self._clamp_int(
|
||
arguments.get("context_before"),
|
||
READ_TOOL_DEFAULT_CONTEXT_BEFORE,
|
||
0,
|
||
READ_TOOL_MAX_CONTEXT_BEFORE
|
||
)
|
||
context_after = self._clamp_int(
|
||
arguments.get("context_after"),
|
||
READ_TOOL_DEFAULT_CONTEXT_AFTER,
|
||
0,
|
||
READ_TOOL_MAX_CONTEXT_AFTER
|
||
)
|
||
case_sensitive = bool(arguments.get("case_sensitive"))
|
||
|
||
search_result = self.file_manager.search_text(
|
||
file_path,
|
||
query=query,
|
||
max_matches=max_matches,
|
||
context_before=context_before,
|
||
context_after=context_after,
|
||
case_sensitive=case_sensitive,
|
||
size_limit=READ_TOOL_MAX_FILE_SIZE
|
||
)
|
||
if not search_result.get("success"):
|
||
return search_result
|
||
|
||
matches = search_result["matches"]
|
||
limited_matches, truncated, char_count = self._limit_text_chunks(matches, "snippet", max_chars)
|
||
|
||
base_result.update({
|
||
"path": search_result["path"],
|
||
"file_size": search_result["size"],
|
||
"query": query,
|
||
"max_matches": max_matches,
|
||
"actual_matches": len(matches),
|
||
"returned_matches": len(limited_matches),
|
||
"context_before": context_before,
|
||
"context_after": context_after,
|
||
"case_sensitive": case_sensitive,
|
||
"matches": limited_matches,
|
||
"char_count": char_count,
|
||
"message": tr(
|
||
"tools_read.search_success",
|
||
path=search_result["path"],
|
||
query=query,
|
||
count=len(limited_matches),
|
||
)
|
||
})
|
||
base_result["truncated"] = truncated
|
||
return base_result
|
||
|
||
# extract
|
||
segments = arguments.get("segments")
|
||
if not isinstance(segments, list) or not segments:
|
||
return {"success": False, "error": tr("tools_read.extract_requires_segments")}
|
||
|
||
extract_result = self.file_manager.extract_segments(
|
||
file_path,
|
||
segments=segments,
|
||
size_limit=READ_TOOL_MAX_FILE_SIZE
|
||
)
|
||
if not extract_result.get("success"):
|
||
return extract_result
|
||
|
||
limited_segments, truncated, char_count = self._limit_text_chunks(
|
||
extract_result["segments"],
|
||
"content",
|
||
max_chars
|
||
)
|
||
|
||
base_result.update({
|
||
"path": extract_result["path"],
|
||
"segments": limited_segments,
|
||
"file_size": extract_result["size"],
|
||
"total_lines": extract_result["total_lines"],
|
||
"segment_count": len(limited_segments),
|
||
"char_count": char_count,
|
||
"message": tr(
|
||
"tools_read.extract_success",
|
||
path=extract_result["path"],
|
||
count=len(limited_segments),
|
||
)
|
||
})
|
||
base_result["truncated"] = truncated
|
||
self.context_manager.load_file(extract_result["path"])
|
||
return base_result
|