opencomputer 0.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.
- opencomputer/__init__.py +3 -0
- opencomputer/agent/__init__.py +1 -0
- opencomputer/agent/compaction.py +245 -0
- opencomputer/agent/config.py +108 -0
- opencomputer/agent/config_store.py +210 -0
- opencomputer/agent/injection.py +60 -0
- opencomputer/agent/loop.py +326 -0
- opencomputer/agent/memory.py +132 -0
- opencomputer/agent/prompt_builder.py +66 -0
- opencomputer/agent/prompts/base.j2 +23 -0
- opencomputer/agent/state.py +251 -0
- opencomputer/agent/step.py +31 -0
- opencomputer/cli.py +483 -0
- opencomputer/doctor.py +216 -0
- opencomputer/gateway/__init__.py +1 -0
- opencomputer/gateway/dispatch.py +89 -0
- opencomputer/gateway/protocol.py +84 -0
- opencomputer/gateway/server.py +77 -0
- opencomputer/gateway/wire_server.py +256 -0
- opencomputer/hooks/__init__.py +1 -0
- opencomputer/hooks/engine.py +79 -0
- opencomputer/hooks/runner.py +42 -0
- opencomputer/mcp/__init__.py +1 -0
- opencomputer/mcp/client.py +208 -0
- opencomputer/plugins/__init__.py +1 -0
- opencomputer/plugins/discovery.py +107 -0
- opencomputer/plugins/loader.py +155 -0
- opencomputer/plugins/registry.py +56 -0
- opencomputer/setup_wizard.py +235 -0
- opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
- opencomputer/tools/__init__.py +1 -0
- opencomputer/tools/bash.py +78 -0
- opencomputer/tools/delegate.py +98 -0
- opencomputer/tools/glob.py +70 -0
- opencomputer/tools/grep.py +117 -0
- opencomputer/tools/read.py +81 -0
- opencomputer/tools/registry.py +69 -0
- opencomputer/tools/skill_manage.py +265 -0
- opencomputer/tools/write.py +58 -0
- opencomputer-0.1.0.dist-info/METADATA +190 -0
- opencomputer-0.1.0.dist-info/RECORD +51 -0
- opencomputer-0.1.0.dist-info/WHEEL +4 -0
- opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
- plugin_sdk/__init__.py +66 -0
- plugin_sdk/channel_contract.py +74 -0
- plugin_sdk/core.py +129 -0
- plugin_sdk/hooks.py +80 -0
- plugin_sdk/injection.py +60 -0
- plugin_sdk/provider_contract.py +95 -0
- plugin_sdk/runtime_context.py +39 -0
- plugin_sdk/tool_contract.py +67 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP client — connects to MCP servers (stdio or HTTP) and exposes their
|
|
3
|
+
tools via our tool registry.
|
|
4
|
+
|
|
5
|
+
Each MCP tool becomes a thin BaseTool subclass that dispatches calls back
|
|
6
|
+
through the live MCP session. Servers are connected lazily in the
|
|
7
|
+
background (kimi-cli pattern) so startup stays fast.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from contextlib import AsyncExitStack
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from mcp import ClientSession, StdioServerParameters
|
|
19
|
+
from mcp.client.stdio import stdio_client
|
|
20
|
+
|
|
21
|
+
from opencomputer.agent.config import MCPServerConfig
|
|
22
|
+
from opencomputer.tools.registry import ToolRegistry
|
|
23
|
+
from plugin_sdk.core import ToolCall, ToolResult
|
|
24
|
+
from plugin_sdk.tool_contract import BaseTool, ToolSchema
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("opencomputer.mcp.client")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ─── MCPTool — one tool exposed via MCP ────────────────────────────
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MCPTool(BaseTool):
|
|
33
|
+
"""Tool that dispatches calls to an MCP session."""
|
|
34
|
+
|
|
35
|
+
parallel_safe = False # conservative — each server has its own state
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
server_name: str,
|
|
40
|
+
tool_name: str,
|
|
41
|
+
description: str,
|
|
42
|
+
parameters: dict[str, Any],
|
|
43
|
+
session: ClientSession,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.server_name = server_name
|
|
46
|
+
self.tool_name = tool_name
|
|
47
|
+
self.description = description
|
|
48
|
+
self.parameters = parameters
|
|
49
|
+
self.session = session
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def schema(self) -> ToolSchema:
|
|
53
|
+
# Namespace MCP tools with the server name so there's no collision
|
|
54
|
+
# between multiple servers exposing a tool with the same name.
|
|
55
|
+
display_name = f"{self.server_name}__{self.tool_name}"
|
|
56
|
+
return ToolSchema(
|
|
57
|
+
name=display_name,
|
|
58
|
+
description=self.description,
|
|
59
|
+
parameters=self.parameters,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def execute(self, call: ToolCall) -> ToolResult:
|
|
63
|
+
try:
|
|
64
|
+
result = await self.session.call_tool(
|
|
65
|
+
name=self.tool_name, arguments=call.arguments
|
|
66
|
+
)
|
|
67
|
+
# Convert MCP result to our string format — concatenate text blocks
|
|
68
|
+
parts: list[str] = []
|
|
69
|
+
is_error = bool(getattr(result, "isError", False))
|
|
70
|
+
for block in (result.content or []):
|
|
71
|
+
if hasattr(block, "text") and block.text:
|
|
72
|
+
parts.append(block.text)
|
|
73
|
+
elif hasattr(block, "type") and block.type == "image":
|
|
74
|
+
parts.append("[image]")
|
|
75
|
+
else:
|
|
76
|
+
parts.append(str(block))
|
|
77
|
+
return ToolResult(
|
|
78
|
+
tool_call_id=call.id,
|
|
79
|
+
content="\n".join(parts) or "[empty MCP response]",
|
|
80
|
+
is_error=is_error,
|
|
81
|
+
)
|
|
82
|
+
except Exception as e: # noqa: BLE001
|
|
83
|
+
return ToolResult(
|
|
84
|
+
tool_call_id=call.id,
|
|
85
|
+
content=f"MCP error from {self.server_name}.{self.tool_name}: {type(e).__name__}: {e}",
|
|
86
|
+
is_error=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─── MCPConnection — one live server connection ───────────────────
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True)
|
|
94
|
+
class MCPConnection:
|
|
95
|
+
config: MCPServerConfig
|
|
96
|
+
session: ClientSession | None = None
|
|
97
|
+
exit_stack: AsyncExitStack | None = None
|
|
98
|
+
tools: list[MCPTool] = None # type: ignore[assignment]
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
if self.tools is None:
|
|
102
|
+
self.tools = []
|
|
103
|
+
|
|
104
|
+
async def connect(self) -> bool:
|
|
105
|
+
"""Spin up the server process / HTTP session, initialize, cache tool list."""
|
|
106
|
+
self.exit_stack = AsyncExitStack()
|
|
107
|
+
try:
|
|
108
|
+
if self.config.transport == "stdio":
|
|
109
|
+
params = StdioServerParameters(
|
|
110
|
+
command=self.config.command,
|
|
111
|
+
args=list(self.config.args),
|
|
112
|
+
env=self.config.env or None,
|
|
113
|
+
)
|
|
114
|
+
stdio_ctx = stdio_client(params)
|
|
115
|
+
streams = await self.exit_stack.enter_async_context(stdio_ctx)
|
|
116
|
+
read_stream, write_stream = streams
|
|
117
|
+
elif self.config.transport == "http":
|
|
118
|
+
# HTTP (SSE) transport kept minimal — just an example hook
|
|
119
|
+
raise NotImplementedError("http MCP transport — Phase 4.1")
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"unknown MCP transport: {self.config.transport}")
|
|
122
|
+
|
|
123
|
+
session = await self.exit_stack.enter_async_context(
|
|
124
|
+
ClientSession(read_stream, write_stream)
|
|
125
|
+
)
|
|
126
|
+
await session.initialize()
|
|
127
|
+
self.session = session
|
|
128
|
+
|
|
129
|
+
# List + cache tools
|
|
130
|
+
tool_list = await session.list_tools()
|
|
131
|
+
for t in tool_list.tools:
|
|
132
|
+
self.tools.append(
|
|
133
|
+
MCPTool(
|
|
134
|
+
server_name=self.config.name,
|
|
135
|
+
tool_name=t.name,
|
|
136
|
+
description=t.description or "",
|
|
137
|
+
parameters=t.inputSchema or {"type": "object", "properties": {}},
|
|
138
|
+
session=session,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
logger.info(
|
|
142
|
+
"MCP server '%s' connected — %d tool(s)",
|
|
143
|
+
self.config.name,
|
|
144
|
+
len(self.tools),
|
|
145
|
+
)
|
|
146
|
+
return True
|
|
147
|
+
except Exception as e: # noqa: BLE001
|
|
148
|
+
logger.exception("MCP server '%s' failed to connect: %s", self.config.name, e)
|
|
149
|
+
await self.disconnect()
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
async def disconnect(self) -> None:
|
|
153
|
+
if self.exit_stack is not None:
|
|
154
|
+
try:
|
|
155
|
+
await self.exit_stack.aclose()
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
pass
|
|
158
|
+
self.exit_stack = None
|
|
159
|
+
self.session = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ─── MCPManager — orchestrates multiple connections ───────────────
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class MCPManager:
|
|
166
|
+
"""Manages connections to all configured MCP servers."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, tool_registry: ToolRegistry) -> None:
|
|
169
|
+
self.tool_registry = tool_registry
|
|
170
|
+
self.connections: list[MCPConnection] = []
|
|
171
|
+
|
|
172
|
+
async def connect_all(self, servers: list[MCPServerConfig]) -> int:
|
|
173
|
+
"""Connect to every enabled server + register its tools. Returns tool count."""
|
|
174
|
+
total = 0
|
|
175
|
+
for cfg in servers:
|
|
176
|
+
if not cfg.enabled:
|
|
177
|
+
continue
|
|
178
|
+
conn = MCPConnection(config=cfg)
|
|
179
|
+
ok = await conn.connect()
|
|
180
|
+
if not ok:
|
|
181
|
+
continue
|
|
182
|
+
self.connections.append(conn)
|
|
183
|
+
for tool in conn.tools:
|
|
184
|
+
try:
|
|
185
|
+
self.tool_registry.register(tool)
|
|
186
|
+
total += 1
|
|
187
|
+
except ValueError:
|
|
188
|
+
logger.warning(
|
|
189
|
+
"MCP tool name collision (skipped): %s", tool.schema.name
|
|
190
|
+
)
|
|
191
|
+
return total
|
|
192
|
+
|
|
193
|
+
async def shutdown(self) -> None:
|
|
194
|
+
"""Disconnect all servers and remove their tools from the registry."""
|
|
195
|
+
for conn in self.connections:
|
|
196
|
+
for tool in conn.tools:
|
|
197
|
+
self.tool_registry.unregister(tool.schema.name)
|
|
198
|
+
await conn.disconnect()
|
|
199
|
+
self.connections.clear()
|
|
200
|
+
|
|
201
|
+
def schedule_deferred_connect(
|
|
202
|
+
self, servers: list[MCPServerConfig]
|
|
203
|
+
) -> asyncio.Task[int]:
|
|
204
|
+
"""Start connecting in the background (kimi-cli pattern) — returns the Task."""
|
|
205
|
+
return asyncio.create_task(self.connect_all(servers))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = ["MCPTool", "MCPConnection", "MCPManager"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plugin system — discovery (manifest scan) + loader (lazy activation)."""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin discovery — Phase 1 of the two-phase loader.
|
|
3
|
+
|
|
4
|
+
Walk the extensions/ and ~/.opencomputer/plugins/ directories, find
|
|
5
|
+
`plugin.json` manifests, and build PluginCandidates. This phase is
|
|
6
|
+
CHEAP — only JSON reads, no imports.
|
|
7
|
+
|
|
8
|
+
Phase 2 (loader.py) activates a candidate on demand by importing its
|
|
9
|
+
entry module and letting it register its tools/channels/hooks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from plugin_sdk.core import PluginManifest
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("opencomputer.plugins.discovery")
|
|
22
|
+
|
|
23
|
+
_IGNORE_DIRS = {
|
|
24
|
+
".git",
|
|
25
|
+
".venv",
|
|
26
|
+
"node_modules",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class PluginCandidate:
|
|
37
|
+
"""Metadata-only view of an installed plugin — output of discovery."""
|
|
38
|
+
|
|
39
|
+
manifest: PluginManifest
|
|
40
|
+
root_dir: Path
|
|
41
|
+
manifest_path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_manifest(manifest_path: Path) -> PluginManifest | None:
|
|
45
|
+
try:
|
|
46
|
+
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
47
|
+
except Exception as e: # noqa: BLE001
|
|
48
|
+
logger.warning("failed to parse manifest %s: %s", manifest_path, e)
|
|
49
|
+
return None
|
|
50
|
+
if "id" not in data or "name" not in data or "version" not in data:
|
|
51
|
+
logger.warning("manifest %s missing required fields (id, name, version)", manifest_path)
|
|
52
|
+
return None
|
|
53
|
+
return PluginManifest(
|
|
54
|
+
id=str(data["id"]),
|
|
55
|
+
name=str(data["name"]),
|
|
56
|
+
version=str(data["version"]),
|
|
57
|
+
description=str(data.get("description", "")),
|
|
58
|
+
author=str(data.get("author", "")),
|
|
59
|
+
homepage=str(data.get("homepage", "")),
|
|
60
|
+
license=str(data.get("license", "MIT")),
|
|
61
|
+
kind=data.get("kind", "mixed"),
|
|
62
|
+
entry=str(data.get("entry", "")),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def discover(search_paths: list[Path]) -> list[PluginCandidate]:
|
|
67
|
+
"""
|
|
68
|
+
Scan each path for `plugin.json` files. Return a list of PluginCandidates.
|
|
69
|
+
|
|
70
|
+
Only direct children of each search path are considered (we don't recurse
|
|
71
|
+
deeply — plugins live at `<root>/<plugin-id>/plugin.json`).
|
|
72
|
+
"""
|
|
73
|
+
candidates: list[PluginCandidate] = []
|
|
74
|
+
seen_ids: set[str] = set()
|
|
75
|
+
|
|
76
|
+
for root in search_paths:
|
|
77
|
+
if not root.exists() or not root.is_dir():
|
|
78
|
+
continue
|
|
79
|
+
for entry in sorted(root.iterdir()):
|
|
80
|
+
if not entry.is_dir() or entry.name in _IGNORE_DIRS or entry.name.startswith("."):
|
|
81
|
+
continue
|
|
82
|
+
manifest_path = entry / "plugin.json"
|
|
83
|
+
if not manifest_path.exists():
|
|
84
|
+
continue
|
|
85
|
+
manifest = _parse_manifest(manifest_path)
|
|
86
|
+
if manifest is None:
|
|
87
|
+
continue
|
|
88
|
+
if manifest.id in seen_ids:
|
|
89
|
+
logger.warning(
|
|
90
|
+
"plugin id collision: '%s' — skipping second occurrence at %s",
|
|
91
|
+
manifest.id,
|
|
92
|
+
entry,
|
|
93
|
+
)
|
|
94
|
+
continue
|
|
95
|
+
seen_ids.add(manifest.id)
|
|
96
|
+
candidates.append(
|
|
97
|
+
PluginCandidate(
|
|
98
|
+
manifest=manifest,
|
|
99
|
+
root_dir=entry,
|
|
100
|
+
manifest_path=manifest_path,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return candidates
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["discover", "PluginCandidate"]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin loader — Phase 2 of the two-phase pattern.
|
|
3
|
+
|
|
4
|
+
Given a PluginCandidate (from discovery.py), lazily import the entry
|
|
5
|
+
module and call its register() function. Plugins register their tools,
|
|
6
|
+
channel adapters, provider adapters, and hooks with the core registries.
|
|
7
|
+
|
|
8
|
+
Plugins declare their entry module in plugin.json via the `entry` field
|
|
9
|
+
(e.g. `"entry": "src.plugin"`). We import that module — it must export
|
|
10
|
+
a `register(api)` function where `api` exposes the plugin-facing registries.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib
|
|
16
|
+
import importlib.util
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from opencomputer.plugins.discovery import PluginCandidate
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("opencomputer.plugins.loader")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Common short names plugins use for their sibling files. Clearing these
|
|
28
|
+
# between plugin loads prevents two plugins (both with a top-level
|
|
29
|
+
# `provider.py`, say) from sharing the first-loaded module.
|
|
30
|
+
_PLUGIN_LOCAL_NAMES = ("provider", "adapter", "plugin", "handlers", "hooks")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clear_plugin_local_cache() -> None:
|
|
34
|
+
for name in _PLUGIN_LOCAL_NAMES:
|
|
35
|
+
sys.modules.pop(name, None)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class LoadedPlugin:
|
|
40
|
+
"""Record of an activated plugin."""
|
|
41
|
+
|
|
42
|
+
candidate: PluginCandidate
|
|
43
|
+
module: Any
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PluginAPI:
|
|
47
|
+
"""Passed to each plugin's register() — the narrow runtime surface."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
tool_registry: Any,
|
|
52
|
+
hook_engine: Any,
|
|
53
|
+
provider_registry: dict[str, Any],
|
|
54
|
+
channel_registry: dict[str, Any],
|
|
55
|
+
injection_engine: Any = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.tools = tool_registry
|
|
58
|
+
self.hooks = hook_engine
|
|
59
|
+
self.providers = provider_registry
|
|
60
|
+
self.channels = channel_registry
|
|
61
|
+
self.injection = injection_engine
|
|
62
|
+
|
|
63
|
+
def register_tool(self, tool: Any) -> None:
|
|
64
|
+
self.tools.register(tool)
|
|
65
|
+
|
|
66
|
+
def register_hook(self, spec: Any) -> None:
|
|
67
|
+
self.hooks.register(spec)
|
|
68
|
+
|
|
69
|
+
def register_provider(self, name: str, provider: Any) -> None:
|
|
70
|
+
self.providers[name] = provider
|
|
71
|
+
|
|
72
|
+
def register_channel(self, name: str, adapter: Any) -> None:
|
|
73
|
+
self.channels[name] = adapter
|
|
74
|
+
|
|
75
|
+
def register_injection_provider(self, provider: Any) -> None:
|
|
76
|
+
"""Register a DynamicInjectionProvider (plan mode, yolo mode, etc.)."""
|
|
77
|
+
if self.injection is None:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"Injection engine unavailable — plugin-SDK version mismatch?"
|
|
80
|
+
)
|
|
81
|
+
self.injection.register(provider)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_plugin(candidate: PluginCandidate, api: PluginAPI) -> LoadedPlugin | None:
|
|
85
|
+
"""Import a candidate's entry module and call its register(api) function.
|
|
86
|
+
|
|
87
|
+
Uses importlib.util.spec_from_file_location with a unique synthetic module
|
|
88
|
+
name per plugin (based on plugin id). This avoids Python's module cache
|
|
89
|
+
returning the same module for multiple plugins that happen to share an
|
|
90
|
+
`entry` value (e.g. all three plugins use "plugin" as their entry).
|
|
91
|
+
|
|
92
|
+
Also adds the plugin root to sys.path so the entry module's own sibling
|
|
93
|
+
imports (e.g. `from adapter import X`) resolve correctly.
|
|
94
|
+
"""
|
|
95
|
+
manifest = candidate.manifest
|
|
96
|
+
entry = manifest.entry.strip()
|
|
97
|
+
if not entry:
|
|
98
|
+
logger.warning("plugin '%s' has no 'entry' field in manifest", manifest.id)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
plugin_root = candidate.root_dir.resolve()
|
|
102
|
+
plugin_root_str = str(plugin_root)
|
|
103
|
+
if plugin_root_str not in sys.path:
|
|
104
|
+
sys.path.insert(0, plugin_root_str)
|
|
105
|
+
|
|
106
|
+
entry_path = plugin_root / f"{entry}.py"
|
|
107
|
+
if not entry_path.exists():
|
|
108
|
+
logger.warning(
|
|
109
|
+
"plugin '%s' entry file not found: %s (expected at %s)",
|
|
110
|
+
manifest.id,
|
|
111
|
+
entry,
|
|
112
|
+
entry_path,
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Clear common sibling module names from sys.modules so this plugin sees
|
|
117
|
+
# its OWN siblings (not another plugin's cached 'provider' or 'adapter').
|
|
118
|
+
# Without this, two plugins that both have a top-level 'provider' module
|
|
119
|
+
# would share the one that loaded first.
|
|
120
|
+
_clear_plugin_local_cache()
|
|
121
|
+
|
|
122
|
+
# Unique module name so sys.modules doesn't collide between plugins
|
|
123
|
+
synthetic_name = f"_opencomputer_plugin_{manifest.id.replace('-', '_')}_{entry}"
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
spec = importlib.util.spec_from_file_location(synthetic_name, entry_path)
|
|
127
|
+
if spec is None or spec.loader is None:
|
|
128
|
+
raise ImportError(f"no spec for {entry_path}")
|
|
129
|
+
module = importlib.util.module_from_spec(spec)
|
|
130
|
+
sys.modules[synthetic_name] = module
|
|
131
|
+
spec.loader.exec_module(module)
|
|
132
|
+
except Exception as e: # noqa: BLE001
|
|
133
|
+
logger.exception("failed to import plugin '%s' (entry=%s): %s", manifest.id, entry, e)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
register_fn = getattr(module, "register", None)
|
|
137
|
+
if register_fn is None:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"plugin '%s' has no register() function in entry module %s",
|
|
140
|
+
manifest.id,
|
|
141
|
+
entry,
|
|
142
|
+
)
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
register_fn(api)
|
|
147
|
+
except Exception as e: # noqa: BLE001
|
|
148
|
+
logger.exception("plugin '%s' register() raised: %s", manifest.id, e)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
logger.info("loaded plugin '%s' v%s", manifest.id, manifest.version)
|
|
152
|
+
return LoadedPlugin(candidate=candidate, module=module)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = ["PluginAPI", "LoadedPlugin", "load_plugin"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin registry — the active set of loaded plugins + the surfaces they registered.
|
|
3
|
+
|
|
4
|
+
Holds the provider registry and channel registry (tools go into the
|
|
5
|
+
tool registry from tools/registry.py; hooks go into the hook engine).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from opencomputer.agent.injection import engine as injection_engine
|
|
14
|
+
from opencomputer.hooks.engine import engine as hook_engine
|
|
15
|
+
from opencomputer.plugins.discovery import PluginCandidate, discover
|
|
16
|
+
from opencomputer.plugins.loader import LoadedPlugin, PluginAPI, load_plugin
|
|
17
|
+
from opencomputer.tools.registry import registry as tool_registry
|
|
18
|
+
from plugin_sdk.provider_contract import BaseProvider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class PluginRegistry:
|
|
23
|
+
"""Holds all loaded plugins and the shared API they register into."""
|
|
24
|
+
|
|
25
|
+
providers: dict[str, BaseProvider] = field(default_factory=dict)
|
|
26
|
+
channels: dict[str, object] = field(default_factory=dict)
|
|
27
|
+
loaded: list[LoadedPlugin] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
def api(self) -> PluginAPI:
|
|
30
|
+
return PluginAPI(
|
|
31
|
+
tool_registry=tool_registry,
|
|
32
|
+
hook_engine=hook_engine,
|
|
33
|
+
provider_registry=self.providers,
|
|
34
|
+
channel_registry=self.channels,
|
|
35
|
+
injection_engine=injection_engine,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def load_all(self, search_paths: list[Path]) -> list[LoadedPlugin]:
|
|
39
|
+
"""Discover + activate all plugins. Returns the list of successfully loaded ones."""
|
|
40
|
+
candidates = discover(search_paths)
|
|
41
|
+
api = self.api()
|
|
42
|
+
for cand in candidates:
|
|
43
|
+
loaded = load_plugin(cand, api)
|
|
44
|
+
if loaded:
|
|
45
|
+
self.loaded.append(loaded)
|
|
46
|
+
return self.loaded
|
|
47
|
+
|
|
48
|
+
def list_candidates(self, search_paths: list[Path]) -> list[PluginCandidate]:
|
|
49
|
+
"""Cheap discovery only — doesn't activate anything."""
|
|
50
|
+
return discover(search_paths)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
registry = PluginRegistry()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
__all__ = ["PluginRegistry", "registry"]
|