vox-code 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. voxcli/web/zhipu.py +55 -0
voxcli/plan/planner.py ADDED
@@ -0,0 +1,198 @@
1
+ """规划器 - 使用LLM将复杂任务分解为执行计划"""
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ import logging
7
+ from typing import List, Optional
8
+
9
+ from ..llm.base import LlmClient, Message
10
+ from .task import Task, TaskType
11
+ from .execution_plan import ExecutionPlan
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _PLANNING_PROMPT = """你是一个任务规划专家。请将用户的复杂任务分解为一系列可执行的子任务。
16
+
17
+ 可用任务类型:
18
+ - FILE_READ: 读取文件内容
19
+ - FILE_WRITE: 写入文件内容
20
+ - COMMAND: 执行Shell命令
21
+ - ANALYSIS: 分析结果并做出决策
22
+ - VERIFICATION: 验证结果是否正确
23
+
24
+ 请按以下JSON格式输出执行计划:
25
+ {
26
+ "summary": "任务摘要",
27
+ "tasks": [
28
+ {
29
+ "id": "task_1",
30
+ "description": "任务描述",
31
+ "type": "FILE_READ",
32
+ "dependencies": []
33
+ },
34
+ {
35
+ "id": "task_2",
36
+ "description": "任务描述",
37
+ "type": "FILE_WRITE",
38
+ "dependencies": ["task_1"]
39
+ }
40
+ ]
41
+ }
42
+
43
+ 规则:
44
+ 1. 每个任务必须有唯一的id(如 task_1, task_2)
45
+ 2. dependencies列出依赖的任务id
46
+ 3. 任务应该按执行顺序排列
47
+ 4. 任务描述要具体明确
48
+ 5. 简单任务(如列目录、读取单个文件、执行单条命令)允许只生成1-3个任务;不要为了凑步数引入无关步骤
49
+ 6. 复杂任务再拆分为5-10个子任务
50
+ 7. 不要为了"保存中间结果"而额外创建 FILE_WRITE / FILE_READ,除非用户明确要求落盘
51
+ 8. 如果一个任务一步就能完成,就保持最短计划
52
+
53
+ 只输出JSON,不要有其他内容。"""
54
+
55
+ _SIMPLE_GOAL_CUES = ["列出", "查看", "读取", "显示", "执行", "运行", "搜索", "当前目录", "文件"]
56
+ _MULTI_STEP_CUES = ["然后", "并且", "并", "再", "最后", "同时", "先", "之后", "接着", "以及"]
57
+
58
+
59
+ class Planner:
60
+ def __init__(self, llm_client: LlmClient):
61
+ self._llm = llm_client
62
+ self._plan_id_counter = 0
63
+
64
+ def create_plan(self, goal: str) -> ExecutionPlan:
65
+ logger.info("Planning for goal: %s", goal)
66
+ print(f"📋 正在规划任务: {goal}\n")
67
+
68
+ if self._is_simple_goal(goal):
69
+ return self._create_minimal_plan(goal)
70
+
71
+ messages = [
72
+ Message.system(_PLANNING_PROMPT),
73
+ Message.user(f"请为以下任务制定执行计划:\n{goal}"),
74
+ ]
75
+
76
+ renderer = PlanningStreamRenderer()
77
+ response = self._llm.chat(messages, listener=renderer)
78
+ renderer.finish()
79
+ plan_json = response.content or ""
80
+
81
+ return self._parse_plan(goal, plan_json)
82
+
83
+ def _parse_plan(self, goal: str, plan_json: str) -> ExecutionPlan:
84
+ cleaned = re.sub(r"```(?:json)?\s*", "", plan_json).strip()
85
+ root = json.loads(cleaned)
86
+
87
+ summary = root.get("summary", "")
88
+ tasks_node = root.get("tasks", [])
89
+
90
+ plan = ExecutionPlan(self._generate_plan_id(), goal)
91
+ plan.summary = summary
92
+
93
+ id_mapping = {}
94
+ for i, task_node in enumerate(tasks_node, 1):
95
+ original_id = task_node.get("id", f"task_{i}")
96
+ new_id = f"task_{i}"
97
+ id_mapping[original_id] = new_id
98
+ desc = task_node.get("description", "")
99
+ task_type = self._parse_task_type(task_node.get("type", "ANALYSIS"))
100
+ plan.add_task(Task(new_id, desc, task_type))
101
+
102
+ for i, task_node in enumerate(tasks_node, 1):
103
+ new_id = f"task_{i}"
104
+ task = plan.get_task(new_id)
105
+ if task is None:
106
+ continue
107
+ for dep in task_node.get("dependencies", []):
108
+ mapped = id_mapping.get(dep, dep)
109
+ dep_task = plan.get_task(mapped)
110
+ if dep_task is not None:
111
+ task.add_dependency(mapped)
112
+ dep_task.add_dependent(task.id)
113
+
114
+ if not plan.compute_execution_order():
115
+ raise ValueError("计划中存在循环依赖")
116
+
117
+ return plan
118
+
119
+ @staticmethod
120
+ def _parse_task_type(type_str: str) -> TaskType:
121
+ mapping = {
122
+ "FILE_READ": TaskType.FILE_READ,
123
+ "FILE_WRITE": TaskType.FILE_WRITE,
124
+ "COMMAND": TaskType.COMMAND,
125
+ "ANALYSIS": TaskType.ANALYSIS,
126
+ "VERIFICATION": TaskType.VERIFICATION,
127
+ }
128
+ return mapping.get(type_str.upper(), TaskType.ANALYSIS)
129
+
130
+ def _generate_plan_id(self) -> str:
131
+ self._plan_id_counter += 1
132
+ return f"plan_{int(time.time())}_{self._plan_id_counter}"
133
+
134
+ def replan(self, failed_plan: ExecutionPlan, failure_reason: str) -> ExecutionPlan:
135
+ print(f"🔄 重新规划,原因: {failure_reason}\n")
136
+ context_parts = [f"原任务: {failed_plan.goal}", f"失败原因: {failure_reason}",
137
+ "已完成的任务:"]
138
+ for task in failed_plan.get_all_tasks():
139
+ if task.status.value == "COMPLETED":
140
+ context_parts.append(f"- {task.id}: {task.description}")
141
+ context_parts.append("\n请制定新的执行计划,避开之前的问题。")
142
+ return self.create_plan("\n".join(context_parts))
143
+
144
+ @staticmethod
145
+ def _is_simple_goal(goal: str) -> bool:
146
+ if not goal or not goal.strip():
147
+ return False
148
+ normalized = goal.strip()
149
+ if any(cue in normalized for cue in _MULTI_STEP_CUES):
150
+ return False
151
+ if len(normalized) > 30:
152
+ return False
153
+ return any(cue in normalized for cue in _SIMPLE_GOAL_CUES)
154
+
155
+ def _create_minimal_plan(self, goal: str) -> ExecutionPlan:
156
+ plan = ExecutionPlan(self._generate_plan_id(), goal)
157
+ plan.summary = f"直接执行简单任务:{goal.strip()}"
158
+ plan.add_task(Task("task_1", goal.strip(), self._infer_simple_task_type(goal)))
159
+ if not plan.compute_execution_order():
160
+ raise ValueError("简单计划不应出现循环依赖")
161
+ return plan
162
+
163
+ @staticmethod
164
+ def _infer_simple_task_type(goal: str) -> TaskType:
165
+ normalized = goal.strip()
166
+ if "读取" in normalized or "打开" in normalized or ("查看" in normalized and "文件" in normalized):
167
+ return TaskType.FILE_READ
168
+ if "写入" in normalized or "修改" in normalized or "创建文件" in normalized:
169
+ return TaskType.FILE_WRITE
170
+ if "分析" in normalized or "总结" in normalized or "解释" in normalized:
171
+ return TaskType.ANALYSIS
172
+ if "验证" in normalized or "检查" in normalized:
173
+ return TaskType.VERIFICATION
174
+ return TaskType.COMMAND
175
+
176
+
177
+ class PlanningStreamRenderer:
178
+ """规划流式渲染器 - 实现 StreamListener 协议"""
179
+ def __init__(self):
180
+ self._buffer = ""
181
+ self._started = False
182
+
183
+ def on_reasoning_delta(self, delta: str):
184
+ if delta:
185
+ if not self._started:
186
+ print("🧠 规划思考")
187
+ self._started = True
188
+ print(delta, end="", flush=True)
189
+ self._buffer += delta
190
+
191
+ def on_content_delta(self, delta: str):
192
+ if delta:
193
+ print(delta, end="", flush=True)
194
+ self._buffer += delta
195
+
196
+ def finish(self):
197
+ if self._started:
198
+ print("\n")
voxcli/plan/task.py ADDED
@@ -0,0 +1,123 @@
1
+ """任务节点 - 表示一个可执行的任务单元"""
2
+
3
+ import time
4
+ from enum import Enum
5
+ from typing import List, Optional, Dict
6
+
7
+
8
+ class TaskType(Enum):
9
+ PLANNING = "PLANNING"
10
+ FILE_READ = "FILE_READ"
11
+ FILE_WRITE = "FILE_WRITE"
12
+ COMMAND = "COMMAND"
13
+ ANALYSIS = "ANALYSIS"
14
+ VERIFICATION = "VERIFICATION"
15
+
16
+
17
+ class TaskStatus(Enum):
18
+ PENDING = "PENDING"
19
+ RUNNING = "RUNNING"
20
+ COMPLETED = "COMPLETED"
21
+ FAILED = "FAILED"
22
+ SKIPPED = "SKIPPED"
23
+
24
+
25
+ class Task:
26
+ def __init__(self, id: str, description: str, type: TaskType,
27
+ dependencies: Optional[List[str]] = None):
28
+ self._id = id
29
+ self._description = description
30
+ self._type = type
31
+ self._status = TaskStatus.PENDING
32
+ self._result: Optional[str] = None
33
+ self._error: Optional[str] = None
34
+ self._dependencies: List[str] = dependencies or []
35
+ self._dependents: List[str] = []
36
+ self._start_time: float = 0.0
37
+ self._end_time: float = 0.0
38
+
39
+ @property
40
+ def id(self) -> str:
41
+ return self._id
42
+
43
+ @property
44
+ def description(self) -> str:
45
+ return self._description
46
+
47
+ @property
48
+ def type(self) -> TaskType:
49
+ return self._type
50
+
51
+ @property
52
+ def status(self) -> TaskStatus:
53
+ return self._status
54
+
55
+ @property
56
+ def result(self) -> Optional[str]:
57
+ return self._result
58
+
59
+ @property
60
+ def error(self) -> Optional[str]:
61
+ return self._error
62
+
63
+ @property
64
+ def dependencies(self) -> List[str]:
65
+ return list(self._dependencies)
66
+
67
+ @property
68
+ def dependents(self) -> List[str]:
69
+ return list(self._dependents)
70
+
71
+ @property
72
+ def start_time(self) -> float:
73
+ return self._start_time
74
+
75
+ @property
76
+ def end_time(self) -> float:
77
+ return self._end_time
78
+
79
+ def add_dependent(self, task_id: str):
80
+ if task_id not in self._dependents:
81
+ self._dependents.append(task_id)
82
+
83
+ def add_dependency(self, task_id: str):
84
+ if task_id not in self._dependencies:
85
+ self._dependencies.append(task_id)
86
+
87
+ def mark_started(self):
88
+ self._status = TaskStatus.RUNNING
89
+ self._start_time = time.time()
90
+
91
+ def mark_completed(self, result: str):
92
+ self._status = TaskStatus.COMPLETED
93
+ self._result = result
94
+ self._end_time = time.time()
95
+
96
+ def mark_failed(self, error: str):
97
+ self._status = TaskStatus.FAILED
98
+ self._error = error
99
+ self._end_time = time.time()
100
+
101
+ def mark_skipped(self):
102
+ self._status = TaskStatus.SKIPPED
103
+ self._end_time = time.time()
104
+
105
+ @property
106
+ def duration(self) -> float:
107
+ if self._start_time == 0:
108
+ return 0.0
109
+ if self._end_time == 0:
110
+ return time.time() - self._start_time
111
+ return self._end_time - self._start_time
112
+
113
+ def is_executable(self, all_tasks: Dict[str, "Task"]) -> bool:
114
+ if self._status != TaskStatus.PENDING:
115
+ return False
116
+ for dep_id in self._dependencies:
117
+ dep = all_tasks.get(dep_id)
118
+ if dep is None or dep.status != TaskStatus.COMPLETED:
119
+ return False
120
+ return True
121
+
122
+ def __repr__(self) -> str:
123
+ return f"Task[{self._id}: {self._description}] ({self._status.value})"
@@ -0,0 +1,111 @@
1
+ """危险工具调用的结构化审计日志(JSONL 格式)"""
2
+
3
+ import json
4
+ import os
5
+ from datetime import date, datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ _AUDIT_DIR: Optional[Path] = None
11
+
12
+
13
+ def _audit_dir() -> Path:
14
+ global _AUDIT_DIR
15
+ if _AUDIT_DIR is not None:
16
+ return _AUDIT_DIR
17
+ env_dir = os.environ.get("VOX_CODE_AUDIT_DIR", "")
18
+ if env_dir.strip():
19
+ _AUDIT_DIR = Path(env_dir.strip())
20
+ else:
21
+ _AUDIT_DIR = Path.home() / ".vox-code" / "audit"
22
+ return _AUDIT_DIR
23
+
24
+
25
+ def _today_file() -> Path:
26
+ d = date.today().isoformat()
27
+ return _audit_dir() / f"audit-{d}.jsonl"
28
+
29
+
30
+ _MAX_FIELD_CHARS = 1000
31
+
32
+
33
+ def _truncate(s: Optional[str]) -> Optional[str]:
34
+ if s is None:
35
+ return None
36
+ return s if len(s) <= _MAX_FIELD_CHARS else s[:_MAX_FIELD_CHARS] + "...(truncated)"
37
+
38
+
39
+ class AuditEntry:
40
+ def __init__(self, tool: str, args: str, outcome: str,
41
+ reason: Optional[str] = None, duration_ms: float = 0):
42
+ self.tool = tool
43
+ self.args = args
44
+ self.outcome = outcome
45
+ self.reason = reason
46
+ self.duration_ms = duration_ms
47
+
48
+
49
+ class AuditLog:
50
+ @staticmethod
51
+ def allow(tool: str, args: str, duration_ms: float) -> AuditEntry:
52
+ return AuditEntry(tool, args, "allow", duration_ms=duration_ms)
53
+
54
+ @staticmethod
55
+ def deny_by_policy(tool: str, args: str, reason: str, duration_ms: float) -> AuditEntry:
56
+ return AuditEntry(tool, args, "deny", reason=reason, duration_ms=duration_ms)
57
+
58
+ @staticmethod
59
+ def error(tool: str, args: str, message: str, duration_ms: float) -> AuditEntry:
60
+ return AuditEntry(tool, args, "error", reason=message, duration_ms=duration_ms)
61
+
62
+ def record(self, entry: AuditEntry):
63
+ _write_entry({
64
+ "timestamp": datetime.utcnow().isoformat() + "Z",
65
+ "tool": entry.tool,
66
+ "args": _truncate(entry.args),
67
+ "outcome": entry.outcome,
68
+ "reason": entry.reason,
69
+ "approver": "none",
70
+ "durationMs": int(entry.duration_ms),
71
+ })
72
+
73
+ @staticmethod
74
+ def recent(n: int = 10) -> list:
75
+ if n <= 0:
76
+ return []
77
+ file_path = _today_file()
78
+ if not file_path.exists():
79
+ return []
80
+ try:
81
+ lines = file_path.read_text(encoding="utf-8").strip().split("\n")
82
+ entries = []
83
+ for raw in lines[-n:]:
84
+ raw = raw.strip()
85
+ if not raw:
86
+ continue
87
+ try:
88
+ data = json.loads(raw)
89
+ entries.append(AuditEntry(
90
+ tool=data.get("tool", ""),
91
+ args=data.get("args", ""),
92
+ outcome=data.get("outcome", ""),
93
+ reason=data.get("reason"),
94
+ duration_ms=data.get("durationMs", 0),
95
+ ))
96
+ except json.JSONDecodeError:
97
+ pass
98
+ return entries
99
+ except OSError:
100
+ return []
101
+
102
+
103
+ def _write_entry(entry: dict):
104
+ try:
105
+ log_dir = _audit_dir()
106
+ log_dir.mkdir(parents=True, exist_ok=True)
107
+ file_path = _today_file()
108
+ with open(file_path, "a", encoding="utf-8") as f:
109
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
110
+ except OSError as e:
111
+ print(f"⚠️ 审计日志写入失败: {e}")
@@ -0,0 +1,34 @@
1
+ """命令快速拒绝:在命令执行前的黑名单 fast-fail"""
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ _RULES: list[tuple[str, re.Pattern]] = [
7
+ ("禁止 sudo 提权", re.compile(r"(?i)\bsudo\b")),
8
+ (
9
+ "禁止 rm -rf 删除全盘或用户目录",
10
+ re.compile(
11
+ r"(?i)\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(/|~|\$home)|"
12
+ r"\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+(/|~|\$home)"
13
+ ),
14
+ ),
15
+ ("禁止 mkfs 格式化磁盘", re.compile(r"(?i)\bmkfs(\.|\b)")),
16
+ ("禁止 dd 写入裸设备", re.compile(r"(?i)\bdd\b[^\n]*\bof=/dev/")),
17
+ ("识别为 fork bomb", re.compile(r":\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:")),
18
+ ("禁止 curl / wget 管道直接执行远端脚本", re.compile(r"(?i)\b(curl|wget)\b[^|\n]*\|\s*(sh|bash|zsh|fish|ksh)\b")),
19
+ ("不允许扫描 /、~ 或整个文件系统", re.compile(r"(?i)\bfind\s+(/|~|\$home)")),
20
+ ("禁止 chmod 777 全盘", re.compile(r"(?i)\bchmod\s+-R\s+777\s+(/|~)")),
21
+ ("禁止 shutdown / reboot / halt", re.compile(r"(?i)\b(shutdown|reboot|halt|poweroff)\b")),
22
+ ]
23
+
24
+
25
+ class CommandGuard:
26
+ @staticmethod
27
+ def check(command: str) -> Optional[str]:
28
+ if not command or not command.strip():
29
+ return None
30
+ normalized = re.sub(r"\s+", " ", command.strip())
31
+ for reason, pattern in _RULES:
32
+ if pattern.search(normalized):
33
+ return reason
34
+ return None
@@ -0,0 +1,5 @@
1
+ """安全策略拦截异常"""
2
+
3
+ class PolicyException(Exception):
4
+ """路径围栏、命令围栏等安全策略拦截时抛出"""
5
+ pass
@@ -0,0 +1,32 @@
1
+ """路径围栏:文件类工具调用的路径合法性检查"""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from .exception import PolicyException
7
+
8
+
9
+ class PathGuard:
10
+ def __init__(self, root: str):
11
+ if not root or not root.strip():
12
+ raise ValueError("项目根路径不能为空")
13
+ candidate = Path(root).resolve()
14
+ self._root_path = candidate
15
+
16
+ @property
17
+ def root_path(self) -> Path:
18
+ return self._root_path
19
+
20
+ def resolve_safe(self, input_path: str) -> Path:
21
+ if not input_path or not input_path.strip():
22
+ raise PolicyException("路径不能为空")
23
+
24
+ raw = Path(input_path)
25
+ resolved = raw if raw.is_absolute() else (self._root_path / raw)
26
+ resolved = resolved.resolve()
27
+
28
+ if not str(resolved).startswith(str(self._root_path)):
29
+ raise PolicyException(
30
+ f"路径越界: {input_path} 不在项目根 {self._root_path} 之内"
31
+ )
32
+ return resolved
@@ -0,0 +1,7 @@
1
+ from .presenter import PresentationMode, PresentationResult, ResponsePresenter
2
+
3
+ __all__ = [
4
+ "PresentationMode",
5
+ "PresentationResult",
6
+ "ResponsePresenter",
7
+ ]
@@ -0,0 +1,154 @@
1
+ """Presentation layer for user-facing reply rendering."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ import logging
6
+ import re
7
+ from typing import Optional, Union
8
+
9
+ from ..config import pai_config
10
+ from ..llm.base import LlmClient, Message
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ PRESENTER_GUARD_PROMPT = """你不是任务执行者,你是结果转述者。
15
+
16
+ 你的任务是:基于“原始回复”进行人格化重述,让它更像桌面宠物 Vox 在向主人汇报。
17
+
18
+ 硬规则:
19
+ 1. 只能基于提供的原始回复重述,不得新增事实、步骤、结论。
20
+ 2. 不得伪造任何工具调用、文件读取、联网搜索、代码修改行为。
21
+ 3. 所有代码块、命令、路径、URL、JSON、错误正文必须原样保留。
22
+ 4. 可以调整语气、顺序和解释方式,但不能改变技术含义。
23
+ 5. 如果原始回复已经很短,只做轻度自然化改写。
24
+ 6. 如果原始回复较长,可以“人格化开场 + 专业正文保留”。
25
+ 7. 默认只输出给主人看的最终回复,不要解释你如何改写。
26
+ """
27
+
28
+ _STATS_RE = re.compile(r"(?:\n|\A)📊 Token:.*$", re.S)
29
+
30
+
31
+ class PresentationMode(str, Enum):
32
+ WORK = "work"
33
+ PET = "pet"
34
+
35
+ @classmethod
36
+ def normalize(cls, value: Optional[str]) -> "PresentationMode":
37
+ if not value:
38
+ return cls.WORK
39
+ normalized = value.strip().lower()
40
+ return cls.PET if normalized == cls.PET.value else cls.WORK
41
+
42
+ @classmethod
43
+ def is_valid(cls, value: Optional[str]) -> bool:
44
+ if not value:
45
+ return False
46
+ return value.strip().lower() in {cls.WORK.value, cls.PET.value}
47
+
48
+
49
+ @dataclass
50
+ class PresentationResult:
51
+ raw_response: str
52
+ display_response: str
53
+ used_presenter: bool = False
54
+
55
+
56
+ class ResponsePresenter:
57
+ def __init__(self, llm_client: LlmClient):
58
+ self._llm = llm_client
59
+
60
+ def present(self, user_input: str, raw_response: str,
61
+ mode: Union[PresentationMode, str]) -> PresentationResult:
62
+ normalized_mode = PresentationMode.normalize(
63
+ mode.value if isinstance(mode, PresentationMode) else mode
64
+ )
65
+ if not raw_response:
66
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
67
+
68
+ if normalized_mode == PresentationMode.WORK:
69
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
70
+
71
+ stats = _extract_stats(raw_response)
72
+ body = _strip_stats(raw_response)
73
+ source = _extract_presentable_body(body)
74
+
75
+ if _looks_strictly_structured(source):
76
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
77
+
78
+ prompt = _build_presenter_input(user_input, source)
79
+ persona_prompt = pai_config.get_active_persona_prompt()
80
+
81
+ try:
82
+ response = self._llm.chat([
83
+ Message.system(persona_prompt + "\n\n" + PRESENTER_GUARD_PROMPT),
84
+ Message.user(prompt),
85
+ ])
86
+ display = (response.content or "").strip()
87
+ if not display:
88
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
89
+ if _contains_code_block(source) and not _preserves_code_block(source, display):
90
+ logger.warning("Presenter output dropped code block, falling back to raw response")
91
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
92
+ final = display if not stats else f"{display}\n\n{stats}"
93
+ return PresentationResult(
94
+ raw_response=raw_response,
95
+ display_response=final,
96
+ used_presenter=True,
97
+ )
98
+ except Exception as e:
99
+ logger.warning("Presenter LLM failed, falling back to raw response: %s", e)
100
+ return PresentationResult(raw_response=raw_response, display_response=raw_response)
101
+
102
+
103
+ def _build_presenter_input(user_input: str, raw_response: str) -> str:
104
+ return (
105
+ "请将下面的原始回复改写成桌面宠物 Vox 对主人的自然汇报。\n\n"
106
+ f"用户原始输入:\n{user_input}\n\n"
107
+ "要求:\n"
108
+ "- 可爱但克制,别太吵\n"
109
+ "- 优先准确,别改事实\n"
110
+ "- 需要保留所有技术正文中的代码、命令、路径、URL、错误、JSON\n"
111
+ "- 不要输出多余解释\n\n"
112
+ f"原始回复:\n{raw_response}"
113
+ )
114
+
115
+
116
+ def _strip_stats(text: str) -> str:
117
+ return _STATS_RE.sub("", text).strip()
118
+
119
+
120
+ def _extract_stats(text: str) -> str:
121
+ match = _STATS_RE.search(text)
122
+ return match.group(0).strip() if match else ""
123
+
124
+
125
+ def _extract_presentable_body(text: str) -> str:
126
+ marker = "\n\n🤖 回复:\n"
127
+ if marker in text:
128
+ return text.split(marker, 1)[1].strip()
129
+ return text.strip()
130
+
131
+
132
+ def _looks_strictly_structured(text: str) -> bool:
133
+ stripped = text.strip()
134
+ if not stripped:
135
+ return True
136
+ if stripped.startswith("{") and stripped.endswith("}"):
137
+ return True
138
+ if stripped.startswith("[") and stripped.endswith("]"):
139
+ return True
140
+ if stripped.startswith("```json") or stripped.startswith("```yaml"):
141
+ return True
142
+ if '"approved"' in stripped and stripped.count("{") >= 1:
143
+ return True
144
+ if '"steps"' in stripped or '"tasks"' in stripped:
145
+ return True
146
+ return False
147
+
148
+
149
+ def _contains_code_block(text: str) -> bool:
150
+ return "```" in text
151
+
152
+
153
+ def _preserves_code_block(source: str, display: str) -> bool:
154
+ return source.count("```") == display.count("```")
voxcli/rag/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from .chunk import CodeChunk
2
+ from .chunker import CodeChunker
3
+ from .embedding import EmbeddingClient
4
+ from .store import VectorStore
5
+ from .index import CodeIndex
6
+ from .retriever import CodeRetriever
7
+ from .formatter import SearchResultFormatter
8
+ from .tokenizer import RagQueryTokenizer
9
+ from .relation import CodeRelation
10
+ from .analyzer import CodeAnalyzer
11
+
12
+ __all__ = [
13
+ "CodeChunk", "CodeChunker", "EmbeddingClient", "VectorStore",
14
+ "CodeIndex", "CodeRetriever", "SearchResultFormatter",
15
+ "RagQueryTokenizer", "CodeRelation", "CodeAnalyzer",
16
+ ]