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.
- vox_code-2.0.0.dist-info/METADATA +258 -0
- vox_code-2.0.0.dist-info/RECORD +88 -0
- vox_code-2.0.0.dist-info/WHEEL +4 -0
- vox_code-2.0.0.dist-info/entry_points.txt +3 -0
- voxcli/__init__.py +3 -0
- voxcli/__main__.py +5 -0
- voxcli/agent/__init__.py +12 -0
- voxcli/agent/agent.py +449 -0
- voxcli/agent/agent_budget.py +133 -0
- voxcli/agent/agent_orchestrator.py +414 -0
- voxcli/agent/plan_execute_agent.py +514 -0
- voxcli/agent/roles.py +80 -0
- voxcli/agent/sub_agent.py +351 -0
- voxcli/catalog.py +477 -0
- voxcli/chat.py +91 -0
- voxcli/cli/__init__.py +4 -0
- voxcli/cli/main.py +452 -0
- voxcli/cli/parser.py +71 -0
- voxcli/config.py +518 -0
- voxcli/gui/__main__.py +3 -0
- voxcli/gui/main.py +22 -0
- voxcli/gui/pet/__init__.py +5 -0
- voxcli/gui/pet/base.py +62 -0
- voxcli/gui/pet/coordinator.py +888 -0
- voxcli/gui/pet/data.py +430 -0
- voxcli/gui/pet/widgets.py +683 -0
- voxcli/gui/pet/windows.py +2298 -0
- voxcli/gui/pet/workers.py +54 -0
- voxcli/gui/pet_app.py +7 -0
- voxcli/hitl/__init__.py +11 -0
- voxcli/hitl/handler.py +11 -0
- voxcli/hitl/policy.py +32 -0
- voxcli/hitl/request.py +13 -0
- voxcli/hitl/result.py +11 -0
- voxcli/hitl/terminal_handler.py +64 -0
- voxcli/hitl/tool_registry.py +64 -0
- voxcli/llm/base.py +93 -0
- voxcli/llm/factory.py +178 -0
- voxcli/llm/ollama_client.py +137 -0
- voxcli/llm/openai_compatible.py +249 -0
- voxcli/memory/base.py +16 -0
- voxcli/memory/budget.py +53 -0
- voxcli/memory/compressor.py +198 -0
- voxcli/memory/entry.py +36 -0
- voxcli/memory/long_term.py +126 -0
- voxcli/memory/manager.py +101 -0
- voxcli/memory/retriever.py +72 -0
- voxcli/memory/short_term.py +84 -0
- voxcli/memory/tokenizer.py +21 -0
- voxcli/plan/__init__.py +5 -0
- voxcli/plan/execution_plan.py +225 -0
- voxcli/plan/planner.py +198 -0
- voxcli/plan/task.py +123 -0
- voxcli/policy/audit_log.py +111 -0
- voxcli/policy/command_guard.py +34 -0
- voxcli/policy/exception.py +5 -0
- voxcli/policy/path_guard.py +32 -0
- voxcli/prompting/__init__.py +7 -0
- voxcli/prompting/presenter.py +154 -0
- voxcli/rag/__init__.py +16 -0
- voxcli/rag/analyzer.py +89 -0
- voxcli/rag/chunk.py +17 -0
- voxcli/rag/chunker.py +137 -0
- voxcli/rag/embedding.py +75 -0
- voxcli/rag/formatter.py +40 -0
- voxcli/rag/index.py +96 -0
- voxcli/rag/relation.py +14 -0
- voxcli/rag/retriever.py +58 -0
- voxcli/rag/store.py +155 -0
- voxcli/rag/tokenizer.py +26 -0
- voxcli/runtime/__init__.py +6 -0
- voxcli/runtime/session_controller.py +386 -0
- voxcli/tool/__init__.py +3 -0
- voxcli/tool/tool_registry.py +433 -0
- voxcli/util/animation.py +219 -0
- voxcli/util/ansi.py +82 -0
- voxcli/util/markdown.py +98 -0
- voxcli/web/__init__.py +17 -0
- voxcli/web/base.py +20 -0
- voxcli/web/extractor.py +77 -0
- voxcli/web/factory.py +38 -0
- voxcli/web/fetch_result.py +27 -0
- voxcli/web/fetcher.py +42 -0
- voxcli/web/network_policy.py +49 -0
- voxcli/web/result.py +23 -0
- voxcli/web/searxng.py +55 -0
- voxcli/web/serpapi.py +53 -0
- 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,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,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
|
+
]
|