my-pi-agent 0.1.0

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 (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,134 @@
1
+ # pyright: reportUnusedCallResult=false
2
+ """demo 入口:跑三个固定问题,展示原生异步 ReAct 循环、流式打字机与并发工具执行。
3
+
4
+ 运行:uv run python -m my_agent_core.main(在项目根目录执行,需要 .env 里的 OPENAI_API_KEY)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import os
12
+ import sys
13
+ from datetime import datetime
14
+ from typing import Any
15
+
16
+ from dotenv import find_dotenv, load_dotenv
17
+
18
+ from my_agent_core.agent import Agent
19
+ from my_agent_core.events import (
20
+ AgentEnd,
21
+ Event,
22
+ MessageUpdate,
23
+ ToolExecutionEnd,
24
+ ToolExecutionStart,
25
+ TurnStart,
26
+ )
27
+ from my_agent_core.session import SessionStore
28
+ from my_agent_core.tools import tool
29
+ from my_agent_llm import LLM, Config # pyright: ignore[reportMissingImports]
30
+
31
+ QUESTIONS = [
32
+ "Use the multiply tool to calculate 37 times 19.",
33
+ "What time is it now?",
34
+ "What's the weather like in Tokyo and Paris?",
35
+ ]
36
+
37
+
38
+ @tool(is_parallel_safe=True)
39
+ def multiply(a: int, b: int) -> int:
40
+ """Multiply two integers."""
41
+ return a * b
42
+
43
+
44
+ @tool(is_parallel_safe=True)
45
+ def get_current_time() -> str:
46
+ """Get the current date and time."""
47
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
48
+
49
+
50
+ @tool(is_parallel_safe=True)
51
+ def get_weather(city: str) -> str:
52
+ """Get the weather for a city (simulated data)."""
53
+ return f"{city}: sunny, 22°C (simulated)"
54
+
55
+
56
+ TOOLS = [multiply, get_current_time, get_weather]
57
+
58
+ # 演示层的系统提示词:my_agent_core 库层没有默认值,
59
+ # 给什么提示词是应用层(本 demo)的选择。
60
+ DEMO_SYSTEM_PROMPT = (
61
+ "You are a helpful assistant. Use the available tools when they help; "
62
+ "answer directly when they don't."
63
+ )
64
+
65
+
66
+ def print_events(event: Event) -> None:
67
+ """把循环事件打印成 demo 过程输出(Agent 不内置 print,输出是应用层的选择)。"""
68
+ if isinstance(event, TurnStart):
69
+ print(f"\n[round {event.iteration}]")
70
+ elif isinstance(event, MessageUpdate):
71
+ # 流式 Token 打字机增量打印
72
+ if event.chunk and getattr(event.chunk, "content", None):
73
+ sys.stdout.write(event.chunk.content)
74
+ sys.stdout.flush()
75
+ elif isinstance(event, ToolExecutionStart):
76
+ print(f"\n [Tool] {event.tool_name}({event.args})")
77
+ elif isinstance(event, ToolExecutionEnd):
78
+ print(f" [Obs] {event.result}")
79
+ elif isinstance(event, AgentEnd):
80
+ print(f"\n[End] stop_reason={event.stop_reason}, iterations={event.iterations}")
81
+ return None # 纯观察,不干预
82
+
83
+
84
+ def build_llm() -> LLM:
85
+ load_dotenv(find_dotenv(usecwd=True))
86
+ api_key = os.getenv("OPENAI_API_KEY")
87
+ if not api_key:
88
+ raise RuntimeError("OPENAI_API_KEY is missing. Add it to .env.")
89
+ options: dict[str, Any] = {"provider": "openai", "api_key": api_key}
90
+ if base_url := os.getenv("OPENAI_BASE_URL"):
91
+ options["base_url"] = base_url
92
+ if model := os.getenv("OPENAI_MODEL"):
93
+ options["model"] = model
94
+ return LLM(config=Config(**options))
95
+
96
+
97
+ async def amain() -> None:
98
+ load_dotenv(find_dotenv(usecwd=True))
99
+ llm = build_llm()
100
+ store = SessionStore() # 默认 workspace=cwd
101
+ for question in QUESTIONS:
102
+ print(f"\n{'=' * 20} 问题: {question} {'=' * 20}")
103
+ session = store.create()
104
+ agent = Agent(
105
+ llm=llm,
106
+ tools=TOOLS,
107
+ session=session,
108
+ system_prompt=DEMO_SYSTEM_PROMPT,
109
+ )
110
+ agent.subscribe(print_events)
111
+ answer = await agent.run(question)
112
+ if answer is None:
113
+ print("(达到 max_iterations 上限,未得到最终回答)")
114
+ else:
115
+ print(f"\n[Answer] {answer}")
116
+
117
+
118
+ def _force_utf8_streams() -> None:
119
+ for stream in (sys.stdout, sys.stderr):
120
+ if (
121
+ stream
122
+ and getattr(stream, "encoding", "").lower().replace("-", "") != "utf8"
123
+ ):
124
+ with contextlib.suppress(Exception):
125
+ stream.reconfigure(encoding="utf-8", errors="replace") # pyright: ignore[reportAttributeAccessIssue]
126
+
127
+
128
+ def main() -> None:
129
+ _force_utf8_streams()
130
+ asyncio.run(amain())
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
@@ -0,0 +1,241 @@
1
+ """MemoryStore - 条目化长期记忆存储与 System Prompt 冻结快照管理。
2
+
3
+ 采用 Markdown 文件(MEMORY.md / USER.md)持久化存储,支持 `\\n§\\n` 条目分隔、
4
+ utf-8-sig 编码容错、唯原子串定位增删改、字符上限约束与原子落盘。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from my_agent_core.tools import Tool, tool
15
+
16
+ ENTRY_DELIMITER = "\n§\n"
17
+ MEMORY_CHAR_LIMIT = 2200
18
+ USER_CHAR_LIMIT = 1375
19
+
20
+ TargetType = Literal["memory", "user"]
21
+
22
+
23
+ class MemoryStore:
24
+ """管理 MEMORY.md 与 USER.md 的持久化记忆存储仓储(支持 Frozen Snapshot 与原子落盘)。"""
25
+
26
+ def __init__(
27
+ self,
28
+ mem_dir: Path | str,
29
+ memory_char_limit: int = MEMORY_CHAR_LIMIT,
30
+ user_char_limit: int = USER_CHAR_LIMIT,
31
+ ) -> None:
32
+ self.mem_dir = Path(mem_dir)
33
+ self.limits: dict[str, int] = {
34
+ "memory": memory_char_limit,
35
+ "user": user_char_limit,
36
+ }
37
+ self.files: dict[str, str] = {
38
+ "memory": "MEMORY.md",
39
+ "user": "USER.md",
40
+ }
41
+ self._entries: dict[str, list[str]] = {"memory": [], "user": []}
42
+ self._snapshot: dict[str, str] = {"memory": "", "user": ""}
43
+
44
+ def load_from_disk(self) -> None:
45
+ """从磁盘读取 MEMORY.md 和 USER.md,解析并冻结 System Prompt 快照。"""
46
+ self.mem_dir.mkdir(parents=True, exist_ok=True)
47
+ for target, filename in self.files.items():
48
+ path = self.mem_dir / filename
49
+ entries: list[str] = []
50
+ if path.exists():
51
+ try:
52
+ content = path.read_text(encoding="utf-8-sig").strip()
53
+ if content:
54
+ raw_parts = [p.strip() for p in content.split(ENTRY_DELIMITER)]
55
+ # 去重且保持插入顺序
56
+ entries = list(dict.fromkeys(p for p in raw_parts if p))
57
+ except Exception:
58
+ entries = []
59
+ self._entries[target] = entries
60
+ self._snapshot[target] = ENTRY_DELIMITER.join(entries) if entries else ""
61
+
62
+ def _atomic_save(self, target: str) -> None:
63
+ """将指定 target 的 entries 原子写入对应文件。"""
64
+ self.mem_dir.mkdir(parents=True, exist_ok=True)
65
+ path = self.mem_dir / self.files[target]
66
+ text = ENTRY_DELIMITER.join(self._entries[target])
67
+ tmp_fd, tmp_path = tempfile.mkstemp(
68
+ prefix=f".{self.files[target]}.", dir=str(self.mem_dir)
69
+ )
70
+ try:
71
+ with os.fdopen(tmp_fd, "w", encoding="utf-8-sig") as f:
72
+ f.write(text)
73
+ f.flush()
74
+ os.fsync(f.fileno())
75
+ os.replace(tmp_path, str(path))
76
+ except Exception:
77
+ if os.path.exists(tmp_path):
78
+ os.remove(tmp_path)
79
+ raise
80
+
81
+ def add(self, target: str, content: str) -> str:
82
+ """追加一条记忆条目并落盘。"""
83
+ if target not in self.files:
84
+ return f"Invalid target '{target}'. Must be 'memory' or 'user'."
85
+ text = content.strip()
86
+ if not text:
87
+ return "Content cannot be empty."
88
+
89
+ entries = self._entries[target]
90
+ if text in entries:
91
+ return f"Entry already exists in {target}."
92
+
93
+ candidate = entries + [text]
94
+ full_text = ENTRY_DELIMITER.join(candidate)
95
+ limit = self.limits[target]
96
+ if len(full_text) > limit:
97
+ current_dump = "\n---\n".join(entries) if entries else "(empty)"
98
+ return (
99
+ f"Cannot add: total length ({len(full_text)}) exceeds limit ({limit}) for {target}.\n"
100
+ f"Please consolidate or remove older entries first.\n"
101
+ f"Current entries:\n{current_dump}"
102
+ )
103
+
104
+ self._entries[target] = candidate
105
+ self._atomic_save(target)
106
+ return f"Added to {target} ({len(full_text)}/{limit} chars used)."
107
+
108
+ def replace(self, target: str, old_text: str, new_content: str) -> str:
109
+ """根据 old_text 唯原子串匹配定位替换条目。"""
110
+ if target not in self.files:
111
+ return f"Invalid target '{target}'. Must be 'memory' or 'user'."
112
+ old_needle = old_text.strip()
113
+ if not old_needle:
114
+ return "old_text cannot be empty."
115
+ new_text = new_content.strip()
116
+ if not new_text:
117
+ return "new_content cannot be empty."
118
+
119
+ entries = self._entries[target]
120
+ matches = [i for i, entry in enumerate(entries) if old_needle in entry]
121
+
122
+ if not matches:
123
+ return f"Text '{old_needle}' not found in {target}."
124
+ if len(matches) > 1:
125
+ matching_texts = "\n---\n".join(entries[i] for i in matches)
126
+ return (
127
+ f"Ambiguous match: found {len(matches)} entries matching '{old_needle}' in {target}.\n"
128
+ f"Please provide a more specific old_text.\nMatching entries:\n{matching_texts}"
129
+ )
130
+
131
+ idx = matches[0]
132
+ candidate = list(entries)
133
+ candidate[idx] = new_text
134
+
135
+ full_text = ENTRY_DELIMITER.join(candidate)
136
+ limit = self.limits[target]
137
+ if len(full_text) > limit:
138
+ return (
139
+ f"Cannot replace: total length ({len(full_text)}) exceeds limit ({limit}) for {target}.\n"
140
+ f"Please consolidate or shorten entries."
141
+ )
142
+
143
+ self._entries[target] = candidate
144
+ self._atomic_save(target)
145
+ return f"Replaced in {target} ({len(full_text)}/{limit} chars used)."
146
+
147
+ def remove(self, target: str, old_text: str) -> str:
148
+ """根据 old_text 唯原子串匹配定位删除条目。"""
149
+ if target not in self.files:
150
+ return f"Invalid target '{target}'. Must be 'memory' or 'user'."
151
+ old_needle = old_text.strip()
152
+ if not old_needle:
153
+ return "old_text cannot be empty."
154
+
155
+ entries = self._entries[target]
156
+ matches = [i for i, entry in enumerate(entries) if old_needle in entry]
157
+
158
+ if not matches:
159
+ return f"Text '{old_needle}' not found in {target}."
160
+ if len(matches) > 1:
161
+ matching_texts = "\n---\n".join(entries[i] for i in matches)
162
+ return (
163
+ f"Ambiguous match: found {len(matches)} entries matching '{old_needle}' in {target}.\n"
164
+ f"Please provide a more specific old_text.\nMatching entries:\n{matching_texts}"
165
+ )
166
+
167
+ idx = matches[0]
168
+ candidate = [e for i, e in enumerate(entries) if i != idx]
169
+ self._entries[target] = candidate
170
+ self._atomic_save(target)
171
+ limit = self.limits[target]
172
+ curr_len = len(ENTRY_DELIMITER.join(candidate)) if candidate else 0
173
+ return f"Removed from {target} ({curr_len}/{limit} chars used)."
174
+
175
+ def format_for_system_prompt(self, target: str) -> str | None:
176
+ """获取冻结的快照字符串(返回 None 代表无内容)。"""
177
+ snapshot = self._snapshot.get(target, "")
178
+ return snapshot if snapshot else None
179
+
180
+ def format_all_for_system_prompt(self) -> str | None:
181
+ """将冻结的快照格式化为注入 System Prompt 的 XML 块。"""
182
+ blocks = []
183
+ mem = self.format_for_system_prompt("memory")
184
+ if mem:
185
+ blocks.append(f"## MEMORY.md (Agent Notes)\n{mem}")
186
+ usr = self.format_for_system_prompt("user")
187
+ if usr:
188
+ blocks.append(f"## USER.md (User Profile)\n{usr}")
189
+
190
+ if not blocks:
191
+ return None
192
+ joined = "\n\n".join(blocks)
193
+ return (
194
+ "<MEMORY_CONTEXT>\n"
195
+ "The following is your long-term memory across sessions. "
196
+ "Use the `memory` tool to update it when learning new facts or preferences.\n\n"
197
+ f"{joined}\n"
198
+ "</MEMORY_CONTEXT>"
199
+ )
200
+
201
+
202
+ def make_memory_tool(store: MemoryStore) -> Tool:
203
+ """生成受控的 memory 工具,供 Agent 维护长期记忆(add/replace/remove)。"""
204
+
205
+ @tool(
206
+ name="memory",
207
+ description=(
208
+ "Manage long-term memory across sessions. "
209
+ "Target 'memory' for agent knowledge/notes, 'user' for user preferences/profile. "
210
+ "Keep entries concise, high-signal, and consolidate when approaching limits."
211
+ ),
212
+ )
213
+ def memory(
214
+ target: Literal["memory", "user"],
215
+ action: Literal["add", "replace", "remove"],
216
+ content: str | None = None,
217
+ old_text: str | None = None,
218
+ new_content: str | None = None,
219
+ ) -> str:
220
+ """执行记忆的增删改操作。"""
221
+ if action == "add":
222
+ if not content:
223
+ raise ValueError("`content` is required when action is 'add'.")
224
+ return store.add(target, content)
225
+ elif action == "replace":
226
+ if not old_text:
227
+ raise ValueError("`old_text` is required when action is 'replace'.")
228
+ effective_new = new_content or content
229
+ if not effective_new:
230
+ raise ValueError("`new_content` is required when action is 'replace'.")
231
+ return store.replace(target, old_text, effective_new)
232
+ elif action == "remove":
233
+ if not old_text:
234
+ raise ValueError("`old_text` is required when action is 'remove'.")
235
+ return store.remove(target, old_text)
236
+ else:
237
+ raise ValueError(
238
+ f"Unknown action '{action}'. Must be 'add', 'replace', or 'remove'."
239
+ )
240
+
241
+ return memory
@@ -0,0 +1,110 @@
1
+ """动态干预消息队列 —— 驱动 Pi 风格的 Steer 转向与 Follow-up 追问。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import Literal
9
+
10
+
11
+ class MessageType(str, Enum):
12
+ """排队干预消息类型。"""
13
+
14
+ STEERING = "steering" # 内层循环即时转向(安全点打断)
15
+ FOLLOWUP = "followup" # 外层循环排队追问(任务完成后驱动)
16
+
17
+
18
+ @dataclass
19
+ class QueuedMessage:
20
+ """队列中的一条干预消息。"""
21
+
22
+ content: str
23
+ type: MessageType
24
+ created_at: float = field(default_factory=time.time)
25
+
26
+
27
+ class MessageQueue:
28
+ """管理运行期动态干预消息的队列。"""
29
+
30
+ def __init__(
31
+ self,
32
+ steering_mode: Literal["one-at-a-time", "all"] = "one-at-a-time",
33
+ followup_mode: Literal["one-at-a-time", "all"] = "one-at-a-time",
34
+ ) -> None:
35
+ self.queue: list[QueuedMessage] = []
36
+ self.steering_mode = steering_mode
37
+ self.followup_mode = followup_mode
38
+
39
+ def add_steering(self, message: str) -> None:
40
+ """追加一条 Steering 转向消息。"""
41
+ self.queue.append(QueuedMessage(content=message, type=MessageType.STEERING))
42
+
43
+ def add_followup(self, message: str) -> None:
44
+ """追加一条 Follow-up 追问消息。"""
45
+ self.queue.append(QueuedMessage(content=message, type=MessageType.FOLLOWUP))
46
+
47
+ def get_steering_messages(self) -> list[QueuedMessage]:
48
+ """获取并弹出待消费的 steering 消息。"""
49
+ steering = [m for m in self.queue if m.type == MessageType.STEERING]
50
+ if not steering:
51
+ return []
52
+
53
+ if self.steering_mode == "one-at-a-time":
54
+ first = steering[0]
55
+ self.queue.remove(first)
56
+ return [first]
57
+
58
+ self.queue = [m for m in self.queue if m.type != MessageType.STEERING]
59
+ return steering
60
+
61
+ def get_followup_messages(self) -> list[QueuedMessage]:
62
+ """获取并弹出待消费的 followup 消息。"""
63
+ followup = [m for m in self.queue if m.type == MessageType.FOLLOWUP]
64
+ if not followup:
65
+ return []
66
+
67
+ if self.followup_mode == "one-at-a-time":
68
+ first = followup[0]
69
+ self.queue.remove(first)
70
+ return [first]
71
+
72
+ self.queue = [m for m in self.queue if m.type != MessageType.FOLLOWUP]
73
+ return followup
74
+
75
+ def has_steering(self) -> bool:
76
+ """检查是否存在未决的 steering 消息。"""
77
+ return any(m.type == MessageType.STEERING for m in self.queue)
78
+
79
+ def has_followup(self) -> bool:
80
+ """检查是否存在未决的 followup 消息。"""
81
+ return any(m.type == MessageType.FOLLOWUP for m in self.queue)
82
+
83
+ def peek(self) -> QueuedMessage | None:
84
+ """查看队首消息但不弹出。"""
85
+ return self.queue[0] if self.queue else None
86
+
87
+ def clear(self) -> list[QueuedMessage]:
88
+ """清空队列并返回被清除的消息列表。"""
89
+ cleared = list(self.queue)
90
+ self.queue.clear()
91
+ return cleared
92
+
93
+ def get_status(self) -> str:
94
+ """获取当前队列的可读状态。"""
95
+ if not self.queue:
96
+ return "Queue empty"
97
+ steering_count = sum(1 for m in self.queue if m.type == MessageType.STEERING)
98
+ followup_count = sum(1 for m in self.queue if m.type == MessageType.FOLLOWUP)
99
+ parts = []
100
+ if steering_count:
101
+ parts.append(f"{steering_count} steering")
102
+ if followup_count:
103
+ parts.append(f"{followup_count} follow-up")
104
+ return f"Queued: {', '.join(parts)}"
105
+
106
+ def __len__(self) -> int:
107
+ return len(self.queue)
108
+
109
+ def __bool__(self) -> bool:
110
+ return len(self.queue) > 0
@@ -0,0 +1,212 @@
1
+ """Claude Code Plugin system - 自包含功能聚合与分发包管理。
2
+
3
+ 对齐 Claude Code 官方 Plugin 标准规范与 OpenHands 实践:
4
+ - Manifest 解析:支持 .claude-plugin/plugin.json 与 .plugin/plugin.json,支持无 manifest 时的目录名推断兜底;
5
+ - 组件目录映射:skills/(或 commands/ 兼容,或根级 SKILL.md 简写)、agents/、.mcp.json;
6
+ - 资源解构与分发:PluginManager 扫描后将解构目录无缝提供给 SkillManager、SubagentManager 及 MCP 客户端。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import re
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ PLUGIN_MANIFEST_DIRS = [".claude-plugin", ".plugin"]
22
+ PLUGIN_MANIFEST_FILE = "plugin.json"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class PluginAuthor:
27
+ """插件作者信息。支持字符串或字典输入。"""
28
+
29
+ name: str
30
+ email: str | None = None
31
+ url: str | None = None
32
+
33
+ @classmethod
34
+ def from_value(cls, value: Any) -> PluginAuthor:
35
+ if isinstance(value, str):
36
+ m = re.match(r"^(.*?)(?:\s*<([^>]+)>)?$", value.strip())
37
+ if m:
38
+ name = m.group(1).strip()
39
+ email = m.group(2).strip() if m.group(2) else None
40
+ return cls(name=name, email=email)
41
+ return cls(name=value)
42
+ if isinstance(value, dict):
43
+ return cls(
44
+ name=value.get("name", ""),
45
+ email=value.get("email"),
46
+ url=value.get("url"),
47
+ )
48
+ return cls(name="unknown")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PluginManifest:
53
+ """插件元数据清单。对齐 Claude Code plugin.json 结构。"""
54
+
55
+ name: str
56
+ version: str = "1.0.0"
57
+ description: str = ""
58
+ author: PluginAuthor | None = None
59
+ homepage: str | None = None
60
+ repository: str | None = None
61
+ license: str | None = None
62
+ keywords: list[str] = field(default_factory=list)
63
+
64
+
65
+ @dataclass
66
+ class Plugin:
67
+ """表示一个已发现的 Claude Code 插件包。"""
68
+
69
+ name: str
70
+ path: Path
71
+ manifest: PluginManifest
72
+ enabled: bool = True
73
+
74
+ @classmethod
75
+ def from_directory(cls, plugin_dir: Path | str) -> Plugin:
76
+ """从目录加载插件,优先查找清单文件,缺失或损坏时按目录名兜底推断。"""
77
+ p = Path(plugin_dir).resolve()
78
+ manifest_path = None
79
+ for m_dir in PLUGIN_MANIFEST_DIRS:
80
+ candidate = p / m_dir / PLUGIN_MANIFEST_FILE
81
+ if candidate.is_file():
82
+ manifest_path = candidate
83
+ break
84
+ if manifest_path is None:
85
+ root_candidate = p / PLUGIN_MANIFEST_FILE
86
+ if root_candidate.is_file():
87
+ manifest_path = root_candidate
88
+
89
+ if manifest_path:
90
+ try:
91
+ data = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
92
+ author = None
93
+ if "author" in data:
94
+ author = PluginAuthor.from_value(data["author"])
95
+ manifest = PluginManifest(
96
+ name=data.get("name") or p.name,
97
+ version=data.get("version", "1.0.0"),
98
+ description=data.get("description", ""),
99
+ author=author,
100
+ homepage=data.get("homepage"),
101
+ repository=data.get("repository"),
102
+ license=data.get("license"),
103
+ keywords=data.get("keywords", []),
104
+ )
105
+ return cls(name=manifest.name, path=p, manifest=manifest)
106
+ except Exception as e:
107
+ logger.warning(f"Failed to parse manifest at {manifest_path}: {e}")
108
+
109
+ # 智能兜底推断(无清单或清单损坏)
110
+ fallback_manifest = PluginManifest(
111
+ name=p.name,
112
+ version="1.0.0",
113
+ description=f"Plugin loaded from {p.name}",
114
+ )
115
+ return cls(name=p.name, path=p, manifest=fallback_manifest)
116
+
117
+ @property
118
+ def skills_dir(self) -> Path | None:
119
+ """返回技能目录:优先 skills/,次选 commands/,单技能简写直接返回插件根目录。"""
120
+ skills = self.path / "skills"
121
+ if skills.is_dir():
122
+ return skills
123
+ commands = self.path / "commands"
124
+ if commands.is_dir():
125
+ return commands
126
+ # 官方单 skill 简写:根目录直接有 SKILL.md
127
+ if (self.path / "SKILL.md").is_file():
128
+ return self.path
129
+ return None
130
+
131
+ @property
132
+ def agents_dir(self) -> Path | None:
133
+ """返回子代理目录 agents/。"""
134
+ agents = self.path / "agents"
135
+ return agents if agents.is_dir() else None
136
+
137
+ @property
138
+ def mcp_config_path(self) -> Path | None:
139
+ """返回 MCP 配置文件路径 .mcp.json。"""
140
+ mcp = self.path / ".mcp.json"
141
+ return mcp if mcp.is_file() else None
142
+
143
+
144
+ class PluginManager:
145
+ """管理 Claude Code 格式插件的发现、解析与子资源目录解构提取。"""
146
+
147
+ def __init__(self, dirs: Sequence[str | Path] | None = None) -> None:
148
+ """构造即发现:None → 探测 <cwd>/.agents/plugins;[] → 禁用;非空 → 显式目录。"""
149
+ self.plugins: dict[str, Plugin] = {}
150
+ if dirs is None:
151
+ default_dir = Path.cwd() / ".agents" / "plugins"
152
+ if default_dir.is_dir():
153
+ self._discover_from_dir(default_dir)
154
+ elif dirs:
155
+ for d in dirs:
156
+ p = Path(d).resolve()
157
+ if p.is_dir():
158
+ if self._is_single_plugin(p):
159
+ self._load_one_plugin(p)
160
+ else:
161
+ self._discover_from_dir(p)
162
+
163
+ def _is_single_plugin(self, p: Path) -> bool:
164
+ """判断是否为单个插件的根目录。"""
165
+ for m_dir in PLUGIN_MANIFEST_DIRS:
166
+ if (p / m_dir / PLUGIN_MANIFEST_FILE).is_file():
167
+ return True
168
+ if (p / PLUGIN_MANIFEST_FILE).is_file():
169
+ return True
170
+ return (
171
+ (p / "skills").is_dir()
172
+ or (p / "agents").is_dir()
173
+ or (p / "commands").is_dir()
174
+ or (p / "SKILL.md").is_file()
175
+ )
176
+
177
+ def _load_one_plugin(self, p: Path) -> None:
178
+ """安全加载单个插件,隔离异常。"""
179
+ try:
180
+ plugin = Plugin.from_directory(p)
181
+ self.plugins[plugin.name] = plugin
182
+ except Exception as e:
183
+ logger.warning(f"Failed to load plugin from {p}: {e}")
184
+
185
+ def _discover_from_dir(self, root: Path) -> None:
186
+ """扫描目录下的直接子目录。"""
187
+ try:
188
+ for item in sorted(root.iterdir()):
189
+ if item.is_dir() and not item.name.startswith((".", "_")):
190
+ self._load_one_plugin(item)
191
+ except Exception as e:
192
+ logger.warning(f"Failed to discover plugins from {root}: {e}")
193
+
194
+ def get_skill_dirs(self) -> list[Path]:
195
+ """收集所有启用插件的 skills 目录。"""
196
+ return [
197
+ p.skills_dir for p in self.plugins.values() if p.enabled and p.skills_dir
198
+ ]
199
+
200
+ def get_subagent_dirs(self) -> list[Path]:
201
+ """收集所有启用插件的 agents 目录。"""
202
+ return [
203
+ p.agents_dir for p in self.plugins.values() if p.enabled and p.agents_dir
204
+ ]
205
+
206
+ def get_mcp_config_paths(self) -> list[Path]:
207
+ """收集所有启用插件的 .mcp.json 配置文件路径。"""
208
+ return [
209
+ p.mcp_config_path
210
+ for p in self.plugins.values()
211
+ if p.enabled and p.mcp_config_path
212
+ ]