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.
Files changed (130) hide show
  1. xg/__init__.py +3 -0
  2. xg/__main__.py +3 -0
  3. xg/adaptive/__init__.py +27 -0
  4. xg/adaptive/calibrate.py +191 -0
  5. xg/adaptive/feedback.py +170 -0
  6. xg/adaptive/learned_rules.py +284 -0
  7. xg/adaptive/signals.py +125 -0
  8. xg/adaptive/store.py +164 -0
  9. xg/agent/__init__.py +0 -0
  10. xg/agent/plan.py +631 -0
  11. xg/agent/react.py +268 -0
  12. xg/agent/team.py +1793 -0
  13. xg/assets/router.lgb +0 -0
  14. xg/assets/router_semantics.json +21278 -0
  15. xg/assets/router_semantics.onnx +0 -0
  16. xg/cli/__init__.py +0 -0
  17. xg/cli/app.py +1372 -0
  18. xg/cli/commands.py +921 -0
  19. xg/cli/completion.py +740 -0
  20. xg/cli/help.py +185 -0
  21. xg/cli/train.py +200 -0
  22. xg/config/__init__.py +0 -0
  23. xg/config/env_writer.py +121 -0
  24. xg/config/manager.py +446 -0
  25. xg/config/mcp.py +229 -0
  26. xg/config/provider_service.py +267 -0
  27. xg/config/providers.py +46 -0
  28. xg/config/settings.py +258 -0
  29. xg/config/skills.py +100 -0
  30. xg/config/smart_router_service.py +121 -0
  31. xg/config/web.py +140 -0
  32. xg/input_history/__init__.py +7 -0
  33. xg/input_history/models.py +28 -0
  34. xg/input_history/persistence.py +126 -0
  35. xg/input_history/policy.py +39 -0
  36. xg/input_history/prompt_toolkit.py +36 -0
  37. xg/input_history/store.py +118 -0
  38. xg/llm/__init__.py +0 -0
  39. xg/llm/client.py +49 -0
  40. xg/llm/factory.py +40 -0
  41. xg/llm/openai_compat.py +275 -0
  42. xg/llm/types.py +98 -0
  43. xg/mcp/__init__.py +4 -0
  44. xg/mcp/http.py +192 -0
  45. xg/mcp/manager.py +726 -0
  46. xg/mcp/models.py +86 -0
  47. xg/mcp/protocol.py +62 -0
  48. xg/mcp/resources.py +72 -0
  49. xg/mcp/schema.py +137 -0
  50. xg/mcp/stdio.py +210 -0
  51. xg/mcp/transport.py +66 -0
  52. xg/memory/__init__.py +15 -0
  53. xg/memory/context.py +327 -0
  54. xg/memory/manager.py +111 -0
  55. xg/memory/models.py +41 -0
  56. xg/memory/project.py +187 -0
  57. xg/memory/store.py +144 -0
  58. xg/router/__init__.py +124 -0
  59. xg/router/features.py +66 -0
  60. xg/router/keywords.py +50 -0
  61. xg/router/ml_router.py +178 -0
  62. xg/router/model_tiers.py +73 -0
  63. xg/router/postprocess.py +167 -0
  64. xg/router/rule_router.py +77 -0
  65. xg/router/semantic.py +138 -0
  66. xg/safety/__init__.py +0 -0
  67. xg/safety/audit.py +96 -0
  68. xg/safety/guards.py +106 -0
  69. xg/safety/hitl.py +73 -0
  70. xg/skill/__init__.py +9 -0
  71. xg/skill/errors.py +45 -0
  72. xg/skill/loader.py +42 -0
  73. xg/skill/models.py +57 -0
  74. xg/skill/parser.py +93 -0
  75. xg/skill/policy.py +40 -0
  76. xg/skill/prompt.py +45 -0
  77. xg/skill/registry.py +169 -0
  78. xg/tool/__init__.py +0 -0
  79. xg/tool/builtin.py +356 -0
  80. xg/tool/registry.py +228 -0
  81. xg/tui/__init__.py +34 -0
  82. xg/tui/app.py +612 -0
  83. xg/tui/controller.py +1296 -0
  84. xg/tui/diagrams/__init__.py +22 -0
  85. xg/tui/diagrams/layout.py +110 -0
  86. xg/tui/diagrams/markdown.py +39 -0
  87. xg/tui/diagrams/model.py +36 -0
  88. xg/tui/diagrams/parser.py +119 -0
  89. xg/tui/diagrams/renderer.py +551 -0
  90. xg/tui/i18n.py +169 -0
  91. xg/tui/messages.py +45 -0
  92. xg/tui/plan_renderables.py +147 -0
  93. xg/tui/reducer.py +1029 -0
  94. xg/tui/renderables.py +252 -0
  95. xg/tui/state.py +240 -0
  96. xg/tui/theme.tcss +208 -0
  97. xg/tui/widgets/__init__.py +1 -0
  98. xg/tui/widgets/action_card.py +94 -0
  99. xg/tui/widgets/agent_group_card.py +39 -0
  100. xg/tui/widgets/approval_modal.py +59 -0
  101. xg/tui/widgets/collapsible_card.py +40 -0
  102. xg/tui/widgets/command_suggestions.py +128 -0
  103. xg/tui/widgets/composer.py +151 -0
  104. xg/tui/widgets/config_panel.py +198 -0
  105. xg/tui/widgets/confirm_modal.py +31 -0
  106. xg/tui/widgets/footer.py +9 -0
  107. xg/tui/widgets/header.py +118 -0
  108. xg/tui/widgets/inspector.py +378 -0
  109. xg/tui/widgets/plan_modal.py +53 -0
  110. xg/tui/widgets/provider_form.py +141 -0
  111. xg/tui/widgets/queue_status.py +30 -0
  112. xg/tui/widgets/smart_router_form.py +95 -0
  113. xg/tui/widgets/transcript.py +354 -0
  114. xg/tui/workers.py +14 -0
  115. xg/web/__init__.py +21 -0
  116. xg/web/errors.py +57 -0
  117. xg/web/extract.py +127 -0
  118. xg/web/fetch.py +106 -0
  119. xg/web/markdown.py +77 -0
  120. xg/web/models.py +91 -0
  121. xg/web/providers.py +79 -0
  122. xg/web/search.py +118 -0
  123. xg/web/searxng.py +27 -0
  124. xg/web/serpapi.py +29 -0
  125. xg/web/url_policy.py +110 -0
  126. xg/web/zhipu.py +27 -0
  127. xg_cli-1.0.dist-info/METADATA +284 -0
  128. xg_cli-1.0.dist-info/RECORD +130 -0
  129. xg_cli-1.0.dist-info/WHEEL +4 -0
  130. 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