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.
- package/README.md +318 -0
- package/package.json +45 -0
- package/pyproject.toml +50 -0
- package/src/my_agent_core/__init__.py +123 -0
- package/src/my_agent_core/agent.py +441 -0
- package/src/my_agent_core/background.py +121 -0
- package/src/my_agent_core/context.py +505 -0
- package/src/my_agent_core/events.py +153 -0
- package/src/my_agent_core/extensions/__init__.py +9 -0
- package/src/my_agent_core/extensions/core.py +197 -0
- package/src/my_agent_core/hooks.py +130 -0
- package/src/my_agent_core/loop.py +709 -0
- package/src/my_agent_core/main.py +134 -0
- package/src/my_agent_core/memory.py +241 -0
- package/src/my_agent_core/message_queue.py +110 -0
- package/src/my_agent_core/plugins.py +212 -0
- package/src/my_agent_core/registry.py +186 -0
- package/src/my_agent_core/session/__init__.py +79 -0
- package/src/my_agent_core/session/entries.py +197 -0
- package/src/my_agent_core/session/jsonl.py +60 -0
- package/src/my_agent_core/session/memory.py +137 -0
- package/src/my_agent_core/session/session.py +400 -0
- package/src/my_agent_core/session/storage.py +245 -0
- package/src/my_agent_core/session/store.py +131 -0
- package/src/my_agent_core/session/tree.py +86 -0
- package/src/my_agent_core/skills.py +149 -0
- package/src/my_agent_core/subagent_tasks.py +170 -0
- package/src/my_agent_core/subagents.py +148 -0
- package/src/my_agent_core/task_store.py +248 -0
- package/src/my_agent_core/tool_history.py +189 -0
- package/src/my_agent_core/tools/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/__init__.py +5 -0
- package/src/my_agent_core/tools/builtin/task.py +30 -0
- package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
- package/src/my_agent_core/tools/core.py +239 -0
- package/src/my_agent_llm/__init__.py +45 -0
- package/src/my_agent_llm/auth/__init__.py +46 -0
- package/src/my_agent_llm/auth/antigravity.py +209 -0
- package/src/my_agent_llm/auth/manager.py +259 -0
- package/src/my_agent_llm/auth/quota.py +56 -0
- package/src/my_agent_llm/auth/schema.py +94 -0
- package/src/my_agent_llm/client.py +116 -0
- package/src/my_agent_llm/config.py +17 -0
- package/src/my_agent_llm/events.py +84 -0
- package/src/my_agent_llm/models.py +195 -0
- package/src/my_agent_llm/providers/__init__.py +4 -0
- package/src/my_agent_llm/providers/_base.py +94 -0
- package/src/my_agent_llm/providers/anthropic.py +298 -0
- package/src/my_agent_llm/providers/antigravity.py +480 -0
- package/src/my_agent_llm/providers/deepseek.py +196 -0
- package/src/my_agent_llm/providers/openai.py +364 -0
- package/src/my_agent_llm/providers/registry.py +16 -0
- package/src/my_agent_llm/stream.py +218 -0
- package/src/my_coding_agent/__init__.py +66 -0
- package/src/my_coding_agent/agent.py +208 -0
- package/src/my_coding_agent/cli.py +78 -0
- package/src/my_coding_agent/file_reference.py +80 -0
- package/src/my_coding_agent/macro.py +408 -0
- package/src/my_coding_agent/mcp.py +243 -0
- package/src/my_coding_agent/mutation_queue.py +37 -0
- package/src/my_coding_agent/paths.py +119 -0
- package/src/my_coding_agent/permissions.py +84 -0
- package/src/my_coding_agent/prompt.py +54 -0
- package/src/my_coding_agent/rpc_server.py +2817 -0
- package/src/my_coding_agent/settings.py +126 -0
- package/src/my_coding_agent/tools/__init__.py +55 -0
- package/src/my_coding_agent/tools/base.py +58 -0
- package/src/my_coding_agent/tools/bash.py +206 -0
- package/src/my_coding_agent/tools/edit.py +226 -0
- package/src/my_coding_agent/tools/find.py +118 -0
- package/src/my_coding_agent/tools/grep.py +177 -0
- package/src/my_coding_agent/tools/ls.py +112 -0
- package/src/my_coding_agent/tools/read.py +113 -0
- package/src/my_coding_agent/tools/write.py +72 -0
- package/tui/README.md +27 -0
- package/tui/bin/my-agent.js +98 -0
- package/tui/dist/app.d.ts +41 -0
- package/tui/dist/app.js +110 -0
- package/tui/dist/bridge/event-translator.d.ts +92 -0
- package/tui/dist/bridge/event-translator.js +216 -0
- package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
- package/tui/dist/bridge/kernel-bridge.js +132 -0
- package/tui/dist/client.d.ts +63 -0
- package/tui/dist/client.js +239 -0
- package/tui/dist/components/assistant-message.d.ts +19 -0
- package/tui/dist/components/assistant-message.js +90 -0
- package/tui/dist/components/compaction-summary-message.d.ts +19 -0
- package/tui/dist/components/compaction-summary-message.js +46 -0
- package/tui/dist/components/custom-editor.d.ts +18 -0
- package/tui/dist/components/custom-editor.js +56 -0
- package/tui/dist/components/dynamic-border.d.ts +9 -0
- package/tui/dist/components/dynamic-border.js +14 -0
- package/tui/dist/components/footer.d.ts +39 -0
- package/tui/dist/components/footer.js +199 -0
- package/tui/dist/components/header.d.ts +4 -0
- package/tui/dist/components/header.js +21 -0
- package/tui/dist/components/keys.d.ts +5 -0
- package/tui/dist/components/keys.js +12 -0
- package/tui/dist/components/login-selector.d.ts +26 -0
- package/tui/dist/components/login-selector.js +181 -0
- package/tui/dist/components/logout-selector.d.ts +19 -0
- package/tui/dist/components/logout-selector.js +88 -0
- package/tui/dist/components/model-selector.d.ts +40 -0
- package/tui/dist/components/model-selector.js +268 -0
- package/tui/dist/components/session-selector.d.ts +54 -0
- package/tui/dist/components/session-selector.js +393 -0
- package/tui/dist/components/settings-selector.d.ts +24 -0
- package/tui/dist/components/settings-selector.js +146 -0
- package/tui/dist/components/status-indicator.d.ts +25 -0
- package/tui/dist/components/status-indicator.js +60 -0
- package/tui/dist/components/theme-selector.d.ts +14 -0
- package/tui/dist/components/theme-selector.js +77 -0
- package/tui/dist/components/thinking-selector.d.ts +21 -0
- package/tui/dist/components/thinking-selector.js +128 -0
- package/tui/dist/components/tool-execution.d.ts +31 -0
- package/tui/dist/components/tool-execution.js +206 -0
- package/tui/dist/components/tree-selector.d.ts +40 -0
- package/tui/dist/components/tree-selector.js +173 -0
- package/tui/dist/components/user-message-selector.d.ts +21 -0
- package/tui/dist/components/user-message-selector.js +103 -0
- package/tui/dist/components/user-message.d.ts +5 -0
- package/tui/dist/components/user-message.js +15 -0
- package/tui/dist/index.d.ts +11 -0
- package/tui/dist/index.js +11 -0
- package/tui/dist/interactive/chat-viewport.d.ts +19 -0
- package/tui/dist/interactive/chat-viewport.js +41 -0
- package/tui/dist/interactive/components.d.ts +1 -0
- package/tui/dist/interactive/components.js +1 -0
- package/tui/dist/interactive/interactive-mode.d.ts +89 -0
- package/tui/dist/interactive/interactive-mode.js +1625 -0
- package/tui/dist/interactive/theme.d.ts +1 -0
- package/tui/dist/interactive/theme.js +1 -0
- package/tui/dist/interactive/tui-renderer.d.ts +8 -0
- package/tui/dist/interactive/tui-renderer.js +10 -0
- package/tui/dist/protocol.d.ts +78 -0
- package/tui/dist/protocol.js +1 -0
- package/tui/dist/theme/dark.json +54 -0
- package/tui/dist/theme/light.json +71 -0
- package/tui/dist/theme/theme.d.ts +20 -0
- package/tui/dist/theme/theme.js +86 -0
- 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
|
+
]
|