xg-cli 1.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.
- xg/__init__.py +3 -0
- xg/__main__.py +3 -0
- xg/adaptive/__init__.py +27 -0
- xg/adaptive/calibrate.py +191 -0
- xg/adaptive/feedback.py +170 -0
- xg/adaptive/learned_rules.py +284 -0
- xg/adaptive/signals.py +125 -0
- xg/adaptive/store.py +164 -0
- xg/agent/__init__.py +0 -0
- xg/agent/plan.py +631 -0
- xg/agent/react.py +268 -0
- xg/agent/team.py +1793 -0
- xg/assets/router.lgb +0 -0
- xg/assets/router_semantics.json +21278 -0
- xg/assets/router_semantics.onnx +0 -0
- xg/cli/__init__.py +0 -0
- xg/cli/app.py +1372 -0
- xg/cli/commands.py +921 -0
- xg/cli/completion.py +740 -0
- xg/cli/help.py +185 -0
- xg/cli/train.py +200 -0
- xg/config/__init__.py +0 -0
- xg/config/env_writer.py +121 -0
- xg/config/manager.py +446 -0
- xg/config/mcp.py +229 -0
- xg/config/provider_service.py +267 -0
- xg/config/providers.py +46 -0
- xg/config/settings.py +258 -0
- xg/config/skills.py +100 -0
- xg/config/smart_router_service.py +121 -0
- xg/config/web.py +140 -0
- xg/input_history/__init__.py +7 -0
- xg/input_history/models.py +28 -0
- xg/input_history/persistence.py +126 -0
- xg/input_history/policy.py +39 -0
- xg/input_history/prompt_toolkit.py +36 -0
- xg/input_history/store.py +118 -0
- xg/llm/__init__.py +0 -0
- xg/llm/client.py +49 -0
- xg/llm/factory.py +40 -0
- xg/llm/openai_compat.py +275 -0
- xg/llm/types.py +98 -0
- xg/mcp/__init__.py +4 -0
- xg/mcp/http.py +192 -0
- xg/mcp/manager.py +726 -0
- xg/mcp/models.py +86 -0
- xg/mcp/protocol.py +62 -0
- xg/mcp/resources.py +72 -0
- xg/mcp/schema.py +137 -0
- xg/mcp/stdio.py +210 -0
- xg/mcp/transport.py +66 -0
- xg/memory/__init__.py +15 -0
- xg/memory/context.py +327 -0
- xg/memory/manager.py +111 -0
- xg/memory/models.py +41 -0
- xg/memory/project.py +187 -0
- xg/memory/store.py +144 -0
- xg/router/__init__.py +124 -0
- xg/router/features.py +66 -0
- xg/router/keywords.py +50 -0
- xg/router/ml_router.py +178 -0
- xg/router/model_tiers.py +73 -0
- xg/router/postprocess.py +167 -0
- xg/router/rule_router.py +77 -0
- xg/router/semantic.py +138 -0
- xg/safety/__init__.py +0 -0
- xg/safety/audit.py +96 -0
- xg/safety/guards.py +106 -0
- xg/safety/hitl.py +73 -0
- xg/skill/__init__.py +9 -0
- xg/skill/errors.py +45 -0
- xg/skill/loader.py +42 -0
- xg/skill/models.py +57 -0
- xg/skill/parser.py +93 -0
- xg/skill/policy.py +40 -0
- xg/skill/prompt.py +45 -0
- xg/skill/registry.py +169 -0
- xg/tool/__init__.py +0 -0
- xg/tool/builtin.py +356 -0
- xg/tool/registry.py +228 -0
- xg/tui/__init__.py +34 -0
- xg/tui/app.py +612 -0
- xg/tui/controller.py +1296 -0
- xg/tui/diagrams/__init__.py +22 -0
- xg/tui/diagrams/layout.py +110 -0
- xg/tui/diagrams/markdown.py +39 -0
- xg/tui/diagrams/model.py +36 -0
- xg/tui/diagrams/parser.py +119 -0
- xg/tui/diagrams/renderer.py +551 -0
- xg/tui/i18n.py +169 -0
- xg/tui/messages.py +45 -0
- xg/tui/plan_renderables.py +147 -0
- xg/tui/reducer.py +1029 -0
- xg/tui/renderables.py +252 -0
- xg/tui/state.py +240 -0
- xg/tui/theme.tcss +208 -0
- xg/tui/widgets/__init__.py +1 -0
- xg/tui/widgets/action_card.py +94 -0
- xg/tui/widgets/agent_group_card.py +39 -0
- xg/tui/widgets/approval_modal.py +59 -0
- xg/tui/widgets/collapsible_card.py +40 -0
- xg/tui/widgets/command_suggestions.py +128 -0
- xg/tui/widgets/composer.py +151 -0
- xg/tui/widgets/config_panel.py +198 -0
- xg/tui/widgets/confirm_modal.py +31 -0
- xg/tui/widgets/footer.py +9 -0
- xg/tui/widgets/header.py +118 -0
- xg/tui/widgets/inspector.py +378 -0
- xg/tui/widgets/plan_modal.py +53 -0
- xg/tui/widgets/provider_form.py +141 -0
- xg/tui/widgets/queue_status.py +30 -0
- xg/tui/widgets/smart_router_form.py +95 -0
- xg/tui/widgets/transcript.py +354 -0
- xg/tui/workers.py +14 -0
- xg/web/__init__.py +21 -0
- xg/web/errors.py +57 -0
- xg/web/extract.py +127 -0
- xg/web/fetch.py +106 -0
- xg/web/markdown.py +77 -0
- xg/web/models.py +91 -0
- xg/web/providers.py +79 -0
- xg/web/search.py +118 -0
- xg/web/searxng.py +27 -0
- xg/web/serpapi.py +29 -0
- xg/web/url_policy.py +110 -0
- xg/web/zhipu.py +27 -0
- xg_cli-1.0.dist-info/METADATA +284 -0
- xg_cli-1.0.dist-info/RECORD +130 -0
- xg_cli-1.0.dist-info/WHEEL +4 -0
- xg_cli-1.0.dist-info/entry_points.txt +2 -0
xg/memory/context.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""短期对话上下文、动态预算和滚动摘要。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Awaitable, Callable
|
|
8
|
+
|
|
9
|
+
from xg.config.settings import Settings
|
|
10
|
+
from xg.llm.client import LlmClient
|
|
11
|
+
from xg.llm.types import Message
|
|
12
|
+
from xg.memory.models import CompressionResult, SharedSection
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
SharedProvider = Callable[[], list[SharedSection]]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _head_tail(text: str, limit: int) -> str:
|
|
19
|
+
if len(text) <= limit:
|
|
20
|
+
return text
|
|
21
|
+
marker = "\n... [context truncated] ...\n"
|
|
22
|
+
if limit <= len(marker) + 2:
|
|
23
|
+
return text[:limit]
|
|
24
|
+
left = (limit - len(marker)) // 2
|
|
25
|
+
right = limit - len(marker) - left
|
|
26
|
+
return text[:left] + marker + text[-right:]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConversationContext:
|
|
30
|
+
"""保留兼容历史列表的上下文管理器。
|
|
31
|
+
|
|
32
|
+
``history`` 的第一个元素始终是基础 system message;项目/长期记忆和摘要
|
|
33
|
+
只在 ``build_messages`` 时装配,因此现有调用方直接追加 ``agent.messages``
|
|
34
|
+
仍然有效。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
system_prompt: str,
|
|
40
|
+
settings: Settings,
|
|
41
|
+
shared_provider: SharedProvider | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.settings = settings
|
|
44
|
+
self.base_system_prompt = Message(role="system", content=system_prompt)
|
|
45
|
+
self.history: list[Message] = [self.base_system_prompt]
|
|
46
|
+
self.summary = ""
|
|
47
|
+
self.shared_sections: list[SharedSection] = []
|
|
48
|
+
self.shared_provider = shared_provider
|
|
49
|
+
self._warned_sources: set[str] = set()
|
|
50
|
+
|
|
51
|
+
def append(self, message: Message) -> None:
|
|
52
|
+
self.history.append(message)
|
|
53
|
+
|
|
54
|
+
def clear(self) -> None:
|
|
55
|
+
self.history[:] = [self.base_system_prompt]
|
|
56
|
+
self.summary = ""
|
|
57
|
+
|
|
58
|
+
def _refresh_shared(self) -> list[str]:
|
|
59
|
+
warnings: list[str] = []
|
|
60
|
+
if self.shared_provider is None:
|
|
61
|
+
self.shared_sections = []
|
|
62
|
+
return warnings
|
|
63
|
+
try:
|
|
64
|
+
self.shared_sections = list(self.shared_provider())
|
|
65
|
+
except Exception as exc: # 记忆不可用不能阻断普通对话
|
|
66
|
+
self.shared_sections = []
|
|
67
|
+
warnings.append(f"读取共享记忆失败:{exc}")
|
|
68
|
+
return warnings
|
|
69
|
+
|
|
70
|
+
def build_messages(self) -> list[Message]:
|
|
71
|
+
messages = [self.base_system_prompt]
|
|
72
|
+
for section in self.shared_sections:
|
|
73
|
+
messages.append(
|
|
74
|
+
Message(
|
|
75
|
+
role="system",
|
|
76
|
+
content=(
|
|
77
|
+
f"[共享记忆来源:{section.source}]\n"
|
|
78
|
+
f"<project_memory source=\"{section.source}\">\n"
|
|
79
|
+
f"{section.text}\n</project_memory>"
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
if self.summary:
|
|
84
|
+
messages.append(
|
|
85
|
+
Message(
|
|
86
|
+
role="system",
|
|
87
|
+
content="[历史对话摘要;摘要可能有损,请以最近原文为准]\n" + self.summary,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
messages.extend(self.history[1:])
|
|
91
|
+
return messages
|
|
92
|
+
|
|
93
|
+
def _message_tokens(self, message: Message) -> int:
|
|
94
|
+
total = 4 + self.settings.estimate_tokens(message.content)
|
|
95
|
+
total += self.settings.estimate_tokens(message.tool_call_id)
|
|
96
|
+
for call in message.tool_calls:
|
|
97
|
+
total += 4 + self.settings.estimate_tokens(
|
|
98
|
+
call.id + call.name + call.arguments
|
|
99
|
+
)
|
|
100
|
+
return total
|
|
101
|
+
|
|
102
|
+
def estimate_request_tokens(self, tool_schemas: list[dict] | None = None) -> int:
|
|
103
|
+
total = sum(self._message_tokens(message) for message in self.build_messages())
|
|
104
|
+
if tool_schemas:
|
|
105
|
+
total += 8 + self.settings.estimate_tokens(
|
|
106
|
+
json.dumps(tool_schemas, ensure_ascii=False, sort_keys=True)
|
|
107
|
+
)
|
|
108
|
+
return total
|
|
109
|
+
|
|
110
|
+
def _budget_values(self) -> tuple[int, int, int]:
|
|
111
|
+
window = max(1, int(self.settings.context_window))
|
|
112
|
+
response_reserve = min(max(int(window * 0.15), 2_048), 16_384)
|
|
113
|
+
safety_buffer = min(max(int(window * 0.05), 1_024), 8_192)
|
|
114
|
+
summary_reserve = min(
|
|
115
|
+
max(int(window * 0.05), 512),
|
|
116
|
+
max(512, int(self.settings.context_summary_max_tokens)),
|
|
117
|
+
)
|
|
118
|
+
request_limit = min(
|
|
119
|
+
int(window * self.settings.budget_ratio),
|
|
120
|
+
max(1, window - response_reserve - safety_buffer),
|
|
121
|
+
)
|
|
122
|
+
return request_limit, max(1, request_limit - summary_reserve), summary_reserve
|
|
123
|
+
|
|
124
|
+
def _truncate_to_tokens(self, text: str, target: int) -> str:
|
|
125
|
+
if target <= 0:
|
|
126
|
+
return ""
|
|
127
|
+
if self.settings.estimate_tokens(text) <= target:
|
|
128
|
+
return text
|
|
129
|
+
low, high = 1, len(text)
|
|
130
|
+
best = ""
|
|
131
|
+
while low <= high:
|
|
132
|
+
mid = (low + high) // 2
|
|
133
|
+
candidate = _head_tail(text, mid)
|
|
134
|
+
if self.settings.estimate_tokens(candidate) <= target:
|
|
135
|
+
best = candidate
|
|
136
|
+
low = mid + 1
|
|
137
|
+
else:
|
|
138
|
+
high = mid - 1
|
|
139
|
+
return best
|
|
140
|
+
|
|
141
|
+
def _fit_shared_sections(self, tool_schemas: list[dict], request_limit: int) -> list[str]:
|
|
142
|
+
"""按请求预算限制静态项目/长期记忆,避免小窗口被其占满。"""
|
|
143
|
+
fixed = self._message_tokens(self.base_system_prompt)
|
|
144
|
+
if tool_schemas:
|
|
145
|
+
fixed += 8 + self.settings.estimate_tokens(
|
|
146
|
+
json.dumps(tool_schemas, ensure_ascii=False, sort_keys=True)
|
|
147
|
+
)
|
|
148
|
+
persistent_limit = min(
|
|
149
|
+
int(request_limit * 0.30), max(0, request_limit - fixed)
|
|
150
|
+
)
|
|
151
|
+
if not self.shared_sections or persistent_limit <= 0:
|
|
152
|
+
self.shared_sections = [] if persistent_limit <= 0 else self.shared_sections
|
|
153
|
+
return [] if persistent_limit <= 0 else []
|
|
154
|
+
|
|
155
|
+
weights = []
|
|
156
|
+
for section in self.shared_sections:
|
|
157
|
+
if section.source == "XG.md":
|
|
158
|
+
weights.append(0.50)
|
|
159
|
+
elif section.source == "XG.local.md":
|
|
160
|
+
weights.append(0.25)
|
|
161
|
+
else:
|
|
162
|
+
weights.append(0.25)
|
|
163
|
+
weight_total = sum(weights) or 1.0
|
|
164
|
+
sections: list[SharedSection] = []
|
|
165
|
+
warnings: list[str] = []
|
|
166
|
+
for section, weight in zip(self.shared_sections, weights):
|
|
167
|
+
target = max(1, int(persistent_limit * weight / weight_total))
|
|
168
|
+
clipped = self._truncate_to_tokens(section.text, target)
|
|
169
|
+
if clipped != section.text and section.source not in self._warned_sources:
|
|
170
|
+
warnings.append(
|
|
171
|
+
f"{section.source} 已按当前上下文预算截断"
|
|
172
|
+
)
|
|
173
|
+
self._warned_sources.add(section.source)
|
|
174
|
+
if clipped:
|
|
175
|
+
sections.append(replace(section, text=clipped))
|
|
176
|
+
self.shared_sections = sections
|
|
177
|
+
return warnings
|
|
178
|
+
|
|
179
|
+
def _blocks(self) -> list[list[Message]]:
|
|
180
|
+
blocks: list[list[Message]] = []
|
|
181
|
+
current: list[Message] = []
|
|
182
|
+
for message in self.history[1:]:
|
|
183
|
+
if message.role == "user" and current:
|
|
184
|
+
blocks.append(current)
|
|
185
|
+
current = []
|
|
186
|
+
current.append(message)
|
|
187
|
+
if current:
|
|
188
|
+
blocks.append(current)
|
|
189
|
+
return blocks
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _serialize_blocks(blocks: list[list[Message]]) -> str:
|
|
193
|
+
lines: list[str] = []
|
|
194
|
+
for index, block in enumerate(blocks, 1):
|
|
195
|
+
lines.append(f"\n--- 对话轮次 {index} ---")
|
|
196
|
+
for message in block:
|
|
197
|
+
payload = message.to_api()
|
|
198
|
+
lines.append(json.dumps(payload, ensure_ascii=False))
|
|
199
|
+
return "\n".join(lines)
|
|
200
|
+
|
|
201
|
+
async def _compress(
|
|
202
|
+
self,
|
|
203
|
+
llm: LlmClient,
|
|
204
|
+
blocks: list[list[Message]],
|
|
205
|
+
keep_recent: int,
|
|
206
|
+
summary_reserve: int,
|
|
207
|
+
request_limit: int,
|
|
208
|
+
) -> tuple[bool, int, str]:
|
|
209
|
+
if len(blocks) <= keep_recent:
|
|
210
|
+
return False, 0, "没有可压缩的已完成对话轮次"
|
|
211
|
+
old_blocks = blocks if keep_recent == 0 else blocks[:-keep_recent]
|
|
212
|
+
if not old_blocks:
|
|
213
|
+
return False, 0, "没有可压缩的已完成对话轮次"
|
|
214
|
+
# 分块摘要,避免极长历史本身超过摘要请求的窗口。分块只影响摘要输入,
|
|
215
|
+
# 原始消息仍按完整轮次原子保留/删除。
|
|
216
|
+
chunk_limit = max(1_024, request_limit - summary_reserve)
|
|
217
|
+
chunks: list[list[list[Message]]] = []
|
|
218
|
+
current: list[list[Message]] = []
|
|
219
|
+
for block in old_blocks:
|
|
220
|
+
candidate = self._serialize_blocks(current + [block])
|
|
221
|
+
if current and self.settings.estimate_tokens(candidate) > chunk_limit:
|
|
222
|
+
chunks.append(current)
|
|
223
|
+
current = []
|
|
224
|
+
current.append(block)
|
|
225
|
+
if current:
|
|
226
|
+
chunks.append(current)
|
|
227
|
+
|
|
228
|
+
rolling_summary = self.summary
|
|
229
|
+
for chunk in chunks:
|
|
230
|
+
old_text = self._serialize_blocks(chunk)
|
|
231
|
+
if self.settings.estimate_tokens(old_text) > chunk_limit:
|
|
232
|
+
old_text = self._truncate_to_tokens(old_text, chunk_limit)
|
|
233
|
+
prompt = (
|
|
234
|
+
"请把下面的旧对话合并为一份滚动摘要。只根据原文,不要编造。\n"
|
|
235
|
+
"必须保留用户约束、已完成/失败状态、精确路径、符号名、命令、错误和下一步。\n"
|
|
236
|
+
"不要输出密钥、token 或 Authorization 值。严格使用以下标题:\n"
|
|
237
|
+
"目标与用户约束:\n关键决定:\n已完成工作:\n"
|
|
238
|
+
"文件/接口/命令等精确信息:\n未完成事项与下一步:\n重要错误与工具结果:\n\n"
|
|
239
|
+
f"旧滚动摘要:\n{rolling_summary or '(无)'}\n\n旧对话:\n{old_text}"
|
|
240
|
+
)
|
|
241
|
+
messages = [
|
|
242
|
+
Message(
|
|
243
|
+
role="system",
|
|
244
|
+
content="你是 XG-CLI 的上下文压缩器,只输出摘要正文,不调用工具。",
|
|
245
|
+
),
|
|
246
|
+
Message(role="user", content=prompt),
|
|
247
|
+
]
|
|
248
|
+
summary = ""
|
|
249
|
+
last_error = ""
|
|
250
|
+
for _attempt in range(2):
|
|
251
|
+
try:
|
|
252
|
+
parts: list[str] = []
|
|
253
|
+
async for event in llm.stream_chat(messages, tools=None):
|
|
254
|
+
if event.kind == "content" and event.text:
|
|
255
|
+
parts.append(event.text)
|
|
256
|
+
summary = "".join(parts).strip()
|
|
257
|
+
if summary:
|
|
258
|
+
break
|
|
259
|
+
last_error = "返回为空"
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
last_error = str(exc)
|
|
262
|
+
if not summary:
|
|
263
|
+
return False, 0, f"摘要生成失败:{last_error}"
|
|
264
|
+
rolling_summary = self._truncate_to_tokens(summary, summary_reserve)
|
|
265
|
+
|
|
266
|
+
new_history = [self.base_system_prompt]
|
|
267
|
+
recent_blocks = [] if keep_recent == 0 else blocks[-keep_recent:]
|
|
268
|
+
for block in recent_blocks:
|
|
269
|
+
new_history.extend(block)
|
|
270
|
+
self.history[:] = new_history
|
|
271
|
+
self.summary = rolling_summary
|
|
272
|
+
return True, len(old_blocks), rolling_summary
|
|
273
|
+
|
|
274
|
+
async def ensure_budget(
|
|
275
|
+
self, llm: LlmClient, tool_schemas: list[dict] | None = None
|
|
276
|
+
) -> CompressionResult:
|
|
277
|
+
tool_schemas = tool_schemas or []
|
|
278
|
+
warnings = self._refresh_shared()
|
|
279
|
+
request_limit, trigger, summary_reserve = self._budget_values()
|
|
280
|
+
warnings.extend(self._fit_shared_sections(tool_schemas, request_limit))
|
|
281
|
+
before = self.estimate_request_tokens(tool_schemas)
|
|
282
|
+
if before <= trigger:
|
|
283
|
+
status = "warning" if warnings else "ready"
|
|
284
|
+
return CompressionResult(
|
|
285
|
+
status=status,
|
|
286
|
+
before_tokens=before,
|
|
287
|
+
after_tokens=before,
|
|
288
|
+
request_token_limit=request_limit,
|
|
289
|
+
message=";".join(warnings),
|
|
290
|
+
warnings=tuple(warnings),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
blocks = self._blocks()
|
|
294
|
+
keep_recent = max(0, int(self.settings.context_keep_recent_turns))
|
|
295
|
+
compressed, turns, detail = await self._compress(
|
|
296
|
+
llm, blocks, keep_recent, summary_reserve, request_limit
|
|
297
|
+
)
|
|
298
|
+
after = self.estimate_request_tokens(tool_schemas)
|
|
299
|
+
if compressed and after <= request_limit:
|
|
300
|
+
return CompressionResult(
|
|
301
|
+
status="compacted",
|
|
302
|
+
before_tokens=before,
|
|
303
|
+
after_tokens=after,
|
|
304
|
+
request_token_limit=request_limit,
|
|
305
|
+
compressed_turns=turns,
|
|
306
|
+
message=f"上下文已压缩:{before} → {after} token,合并 {turns} 轮",
|
|
307
|
+
warnings=tuple(warnings),
|
|
308
|
+
)
|
|
309
|
+
if compressed and after > request_limit:
|
|
310
|
+
detail = f"压缩后仍超出输入预算({after}/{request_limit} token)"
|
|
311
|
+
if not compressed and before <= request_limit:
|
|
312
|
+
return CompressionResult(
|
|
313
|
+
status="error",
|
|
314
|
+
before_tokens=before,
|
|
315
|
+
after_tokens=before,
|
|
316
|
+
request_token_limit=request_limit,
|
|
317
|
+
message=detail,
|
|
318
|
+
warnings=tuple(warnings),
|
|
319
|
+
)
|
|
320
|
+
return CompressionResult(
|
|
321
|
+
status="overflow",
|
|
322
|
+
before_tokens=before,
|
|
323
|
+
after_tokens=after,
|
|
324
|
+
request_token_limit=request_limit,
|
|
325
|
+
message=detail or f"上下文超出输入预算({after}/{request_limit} token)",
|
|
326
|
+
warnings=tuple(warnings),
|
|
327
|
+
)
|
xg/memory/manager.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""长期记忆与项目记忆的统一门面。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from xg.llm.client import LlmClient
|
|
8
|
+
from xg.memory.models import MemoryEntry, SharedSection
|
|
9
|
+
from xg.memory.project import (
|
|
10
|
+
ProjectMemoryLoader,
|
|
11
|
+
generate_project_memory,
|
|
12
|
+
write_project_memory,
|
|
13
|
+
)
|
|
14
|
+
from xg.memory.store import SQLiteMemoryStore
|
|
15
|
+
from xg.safety.audit import redact_text
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MemoryUnavailableError(RuntimeError):
|
|
19
|
+
"""长期记忆数据库暂时不可用。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MemoryManager:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
project_root: str | Path,
|
|
26
|
+
project_memory_max_chars: int = 32_000,
|
|
27
|
+
memory_prompt_max_chars: int = 8_000,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.project_root = Path(project_root).resolve()
|
|
30
|
+
self.project_memory_max_chars = max(256, int(project_memory_max_chars))
|
|
31
|
+
self.memory_prompt_max_chars = max(256, int(memory_prompt_max_chars))
|
|
32
|
+
self.project_loader = ProjectMemoryLoader(
|
|
33
|
+
self.project_root, max_chars=project_memory_max_chars
|
|
34
|
+
)
|
|
35
|
+
self.db_path = self.project_root / ".xg" / "memory.db"
|
|
36
|
+
self.store: SQLiteMemoryStore | None = None
|
|
37
|
+
self.store_error = ""
|
|
38
|
+
try:
|
|
39
|
+
self.store = SQLiteMemoryStore(self.db_path)
|
|
40
|
+
except Exception as exc:
|
|
41
|
+
self.store_error = str(exc)
|
|
42
|
+
|
|
43
|
+
def _require_store(self) -> SQLiteMemoryStore:
|
|
44
|
+
if self.store is None:
|
|
45
|
+
raise MemoryUnavailableError(self.store_error or "长期记忆不可用")
|
|
46
|
+
return self.store
|
|
47
|
+
|
|
48
|
+
def shared_sections(self) -> list[SharedSection]:
|
|
49
|
+
sections = list(self.project_loader.load().sections)
|
|
50
|
+
store = self.store
|
|
51
|
+
if store is None:
|
|
52
|
+
return sections
|
|
53
|
+
try:
|
|
54
|
+
entries = store.list(limit=100)
|
|
55
|
+
except Exception:
|
|
56
|
+
return sections
|
|
57
|
+
lines: list[str] = []
|
|
58
|
+
used = 0
|
|
59
|
+
for entry in entries:
|
|
60
|
+
line = f"- (#{entry.id}) {redact_text(entry.content)}"
|
|
61
|
+
extra = len(line) + (1 if lines else 0)
|
|
62
|
+
if used + extra > self.memory_prompt_max_chars:
|
|
63
|
+
continue
|
|
64
|
+
lines.append(line)
|
|
65
|
+
used += extra
|
|
66
|
+
if lines:
|
|
67
|
+
sections.append(
|
|
68
|
+
SharedSection(
|
|
69
|
+
source="长期记忆",
|
|
70
|
+
text="[项目长期记忆;内容由用户显式保存]\n" + "\n".join(reversed(lines)),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return sections
|
|
74
|
+
|
|
75
|
+
def warnings(self) -> list[str]:
|
|
76
|
+
warnings = list(self.project_loader.load().warnings)
|
|
77
|
+
if self.store_error:
|
|
78
|
+
warnings.append(f"长期记忆不可用:{self.store_error}")
|
|
79
|
+
return warnings
|
|
80
|
+
|
|
81
|
+
def save(self, content: str) -> tuple[MemoryEntry, bool, bool]:
|
|
82
|
+
safe = redact_text(content.strip())
|
|
83
|
+
if not safe:
|
|
84
|
+
raise ValueError("记忆内容不能为空")
|
|
85
|
+
entry, created = self._require_store().save(safe)
|
|
86
|
+
return entry, created, safe != content.strip()
|
|
87
|
+
|
|
88
|
+
def list(self, limit: int = 20) -> list[MemoryEntry]:
|
|
89
|
+
return self._require_store().list(limit=limit)
|
|
90
|
+
|
|
91
|
+
def search(self, query: str, limit: int = 20) -> list[MemoryEntry]:
|
|
92
|
+
return self._require_store().search(query, limit=limit)
|
|
93
|
+
|
|
94
|
+
def delete(self, memory_id: int) -> bool:
|
|
95
|
+
return self._require_store().delete(memory_id)
|
|
96
|
+
|
|
97
|
+
def clear(self) -> int:
|
|
98
|
+
return self._require_store().clear()
|
|
99
|
+
|
|
100
|
+
def count(self) -> int:
|
|
101
|
+
return self._require_store().count()
|
|
102
|
+
|
|
103
|
+
async def generate_init_draft(self, llm: LlmClient) -> str:
|
|
104
|
+
return await generate_project_memory(
|
|
105
|
+
llm, self.project_root, max_chars=self.project_memory_max_chars
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def write_init_draft(self, draft: str) -> Path:
|
|
109
|
+
path = write_project_memory(self.project_root, draft)
|
|
110
|
+
self.project_loader.load(force=True)
|
|
111
|
+
return path
|
xg/memory/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""记忆层数据模型。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class MemoryEntry:
|
|
12
|
+
id: int
|
|
13
|
+
content: str
|
|
14
|
+
source: str
|
|
15
|
+
created_at: datetime
|
|
16
|
+
updated_at: datetime
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SharedSection:
|
|
21
|
+
"""发给模型的静态上下文片段。"""
|
|
22
|
+
|
|
23
|
+
source: str
|
|
24
|
+
text: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class CompressionResult:
|
|
29
|
+
status: Literal["ready", "compacted", "warning", "error", "overflow"]
|
|
30
|
+
before_tokens: int
|
|
31
|
+
after_tokens: int
|
|
32
|
+
compressed_turns: int = 0
|
|
33
|
+
message: str = ""
|
|
34
|
+
warnings: tuple[str, ...] = ()
|
|
35
|
+
request_token_limit: int = 0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def proceed(self) -> bool:
|
|
39
|
+
return self.status in ("ready", "compacted", "warning", "error") and (
|
|
40
|
+
self.status != "overflow"
|
|
41
|
+
)
|
xg/memory/project.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""项目记忆文件加载与 /init 草稿生成。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from xg.llm.client import LlmClient
|
|
12
|
+
from xg.llm.types import Message
|
|
13
|
+
from xg.memory.models import SharedSection
|
|
14
|
+
from xg.safety.audit import redact_text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PROJECT_MEMORY_FILES = ("XG.md", "XG.local.md")
|
|
18
|
+
PROJECT_MEMORY_SYSTEM = (
|
|
19
|
+
"你正在为 XG-CLI 生成项目记忆文件。只根据提供的项目快照写事实性、可执行的说明,"
|
|
20
|
+
"不要编造不存在的命令、依赖或目录;不要输出密钥或其他敏感值。"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ProjectMemorySnapshot:
|
|
26
|
+
sections: tuple[SharedSection, ...] = ()
|
|
27
|
+
warnings: tuple[str, ...] = ()
|
|
28
|
+
signatures: tuple[tuple[str, int, int] | tuple[str, None], ...] = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _signature(path: Path) -> tuple[str, int, int] | tuple[str, None]:
|
|
32
|
+
try:
|
|
33
|
+
stat = path.stat()
|
|
34
|
+
except OSError:
|
|
35
|
+
return (str(path), None)
|
|
36
|
+
return (str(path), stat.st_mtime_ns, stat.st_size)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _truncate_lines(text: str, limit: int) -> tuple[str, bool]:
|
|
40
|
+
if len(text) <= limit:
|
|
41
|
+
return text, False
|
|
42
|
+
if limit < 80:
|
|
43
|
+
return text[:limit], True
|
|
44
|
+
left_limit = max(1, (limit - 32) // 2)
|
|
45
|
+
right_limit = max(1, limit - left_limit - 32)
|
|
46
|
+
left = text[:left_limit].rsplit("\n", 1)[0]
|
|
47
|
+
right = text[-right_limit:].split("\n", 1)[-1]
|
|
48
|
+
clipped = f"{left}\n\n... [XG memory truncated] ...\n\n{right}"
|
|
49
|
+
return clipped[:limit], True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProjectMemoryLoader:
|
|
53
|
+
def __init__(self, project_root: str | Path, max_chars: int = 32_000) -> None:
|
|
54
|
+
self.project_root = Path(project_root).resolve()
|
|
55
|
+
self.max_chars = max(256, int(max_chars))
|
|
56
|
+
self._snapshot: ProjectMemorySnapshot | None = None
|
|
57
|
+
|
|
58
|
+
def load(self, force: bool = False) -> ProjectMemorySnapshot:
|
|
59
|
+
paths = [self.project_root / name for name in PROJECT_MEMORY_FILES]
|
|
60
|
+
signatures = tuple(_signature(path) for path in paths)
|
|
61
|
+
if not force and self._snapshot is not None and self._snapshot.signatures == signatures:
|
|
62
|
+
return self._snapshot
|
|
63
|
+
|
|
64
|
+
sections: list[SharedSection] = []
|
|
65
|
+
warnings: list[str] = []
|
|
66
|
+
for name, path in zip(PROJECT_MEMORY_FILES, paths):
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
continue
|
|
69
|
+
try:
|
|
70
|
+
with path.open("r", encoding="utf-8-sig") as handle:
|
|
71
|
+
raw = handle.read(self.max_chars + 1)
|
|
72
|
+
except UnicodeError:
|
|
73
|
+
warnings.append(f"{name} 不是有效 UTF-8,已跳过")
|
|
74
|
+
continue
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
warnings.append(f"读取 {name} 失败:{exc}")
|
|
77
|
+
continue
|
|
78
|
+
raw, truncated = _truncate_lines(raw, self.max_chars)
|
|
79
|
+
safe = redact_text(raw)
|
|
80
|
+
if truncated:
|
|
81
|
+
warnings.append(f"{name} 超过 {self.max_chars} 字符,已按行截断")
|
|
82
|
+
sections.append(SharedSection(source=name, text=safe))
|
|
83
|
+
|
|
84
|
+
self._snapshot = ProjectMemorySnapshot(
|
|
85
|
+
sections=tuple(sections),
|
|
86
|
+
warnings=tuple(warnings),
|
|
87
|
+
signatures=signatures,
|
|
88
|
+
)
|
|
89
|
+
return self._snapshot
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_project_snapshot(project_root: str | Path, max_chars: int = 32_000) -> str:
|
|
93
|
+
"""构造 /init 使用的有限、只读项目快照。"""
|
|
94
|
+
root = Path(project_root).resolve()
|
|
95
|
+
excluded = {".git", ".xg", ".venv", "__pycache__", "node_modules"}
|
|
96
|
+
names: list[str] = []
|
|
97
|
+
try:
|
|
98
|
+
for child in sorted(root.iterdir(), key=lambda item: item.name.lower()):
|
|
99
|
+
if child.name in excluded or child.name.startswith("."):
|
|
100
|
+
continue
|
|
101
|
+
names.append(child.name + ("/" if child.is_dir() else ""))
|
|
102
|
+
if len(names) >= 200:
|
|
103
|
+
break
|
|
104
|
+
except OSError:
|
|
105
|
+
names = []
|
|
106
|
+
|
|
107
|
+
parts = [f"项目根目录:{root}", "\n顶层目录:", "\n".join(names) or "(无法读取)"]
|
|
108
|
+
candidates = (
|
|
109
|
+
"README.md",
|
|
110
|
+
"pyproject.toml",
|
|
111
|
+
"package.json",
|
|
112
|
+
"Cargo.toml",
|
|
113
|
+
"go.mod",
|
|
114
|
+
"Makefile",
|
|
115
|
+
)
|
|
116
|
+
remaining = max_chars - len("\n".join(parts))
|
|
117
|
+
for name in candidates:
|
|
118
|
+
if remaining <= 0:
|
|
119
|
+
break
|
|
120
|
+
path = root / name
|
|
121
|
+
if not path.is_file():
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
with path.open("r", encoding="utf-8-sig") as handle:
|
|
125
|
+
content = handle.read(min(8_000, remaining) + 1)
|
|
126
|
+
except (OSError, UnicodeError):
|
|
127
|
+
continue
|
|
128
|
+
content, _ = _truncate_lines(content, min(8_000, remaining))
|
|
129
|
+
content = redact_text(content)
|
|
130
|
+
part = f"\n\n--- {name} ---\n{content}"
|
|
131
|
+
parts.append(part)
|
|
132
|
+
remaining -= len(part)
|
|
133
|
+
return "\n".join(parts)[:max_chars]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def generate_project_memory(
|
|
137
|
+
llm: LlmClient, project_root: str | Path, max_chars: int = 32_000
|
|
138
|
+
) -> str:
|
|
139
|
+
target = Path(project_root).resolve() / "XG.md"
|
|
140
|
+
if target.exists():
|
|
141
|
+
raise FileExistsError("XG.md 已存在,不覆盖已有项目记忆")
|
|
142
|
+
messages = [
|
|
143
|
+
Message(role="system", content=PROJECT_MEMORY_SYSTEM),
|
|
144
|
+
Message(
|
|
145
|
+
role="user",
|
|
146
|
+
content=(
|
|
147
|
+
"请生成一个 Markdown 项目记忆草稿,必须包含:项目概述、技术栈、目录结构、"
|
|
148
|
+
"常用命令、代码约定、验证要求、禁止事项。只输出 Markdown 正文。\n\n"
|
|
149
|
+
+ build_project_snapshot(project_root, max_chars)
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
]
|
|
153
|
+
parts: list[str] = []
|
|
154
|
+
async for event in llm.stream_chat(messages, tools=None):
|
|
155
|
+
if event.kind == "content" and event.text:
|
|
156
|
+
parts.append(event.text)
|
|
157
|
+
draft = redact_text("".join(parts).strip())
|
|
158
|
+
if not draft:
|
|
159
|
+
raise ValueError("LLM 未生成有效的 XG.md 草稿")
|
|
160
|
+
if not draft.startswith("#"):
|
|
161
|
+
draft = "# XG Project Memory\n\n" + draft
|
|
162
|
+
return draft
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_project_memory(project_root: str | Path, content: str) -> Path:
|
|
166
|
+
"""以不覆盖已有文件为目标写入 XG.md。"""
|
|
167
|
+
root = Path(project_root).resolve()
|
|
168
|
+
target = root / "XG.md"
|
|
169
|
+
if target.exists():
|
|
170
|
+
raise FileExistsError("XG.md 已存在,不覆盖已有项目记忆")
|
|
171
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
fd, temp_name = tempfile.mkstemp(prefix=".XG.md.", suffix=".tmp", dir=root)
|
|
173
|
+
temp_path = Path(temp_name)
|
|
174
|
+
try:
|
|
175
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
176
|
+
handle.write(content.rstrip() + "\n")
|
|
177
|
+
handle.flush()
|
|
178
|
+
os.fsync(handle.fileno())
|
|
179
|
+
if target.exists():
|
|
180
|
+
raise FileExistsError("XG.md 在写入期间已被创建,不覆盖已有文件")
|
|
181
|
+
os.replace(temp_path, target)
|
|
182
|
+
finally:
|
|
183
|
+
try:
|
|
184
|
+
temp_path.unlink(missing_ok=True)
|
|
185
|
+
except OSError:
|
|
186
|
+
pass
|
|
187
|
+
return target
|