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.
Files changed (51) hide show
  1. opencomputer/__init__.py +3 -0
  2. opencomputer/agent/__init__.py +1 -0
  3. opencomputer/agent/compaction.py +245 -0
  4. opencomputer/agent/config.py +108 -0
  5. opencomputer/agent/config_store.py +210 -0
  6. opencomputer/agent/injection.py +60 -0
  7. opencomputer/agent/loop.py +326 -0
  8. opencomputer/agent/memory.py +132 -0
  9. opencomputer/agent/prompt_builder.py +66 -0
  10. opencomputer/agent/prompts/base.j2 +23 -0
  11. opencomputer/agent/state.py +251 -0
  12. opencomputer/agent/step.py +31 -0
  13. opencomputer/cli.py +483 -0
  14. opencomputer/doctor.py +216 -0
  15. opencomputer/gateway/__init__.py +1 -0
  16. opencomputer/gateway/dispatch.py +89 -0
  17. opencomputer/gateway/protocol.py +84 -0
  18. opencomputer/gateway/server.py +77 -0
  19. opencomputer/gateway/wire_server.py +256 -0
  20. opencomputer/hooks/__init__.py +1 -0
  21. opencomputer/hooks/engine.py +79 -0
  22. opencomputer/hooks/runner.py +42 -0
  23. opencomputer/mcp/__init__.py +1 -0
  24. opencomputer/mcp/client.py +208 -0
  25. opencomputer/plugins/__init__.py +1 -0
  26. opencomputer/plugins/discovery.py +107 -0
  27. opencomputer/plugins/loader.py +155 -0
  28. opencomputer/plugins/registry.py +56 -0
  29. opencomputer/setup_wizard.py +235 -0
  30. opencomputer/skills/debug-python-import-error/SKILL.md +58 -0
  31. opencomputer/tools/__init__.py +1 -0
  32. opencomputer/tools/bash.py +78 -0
  33. opencomputer/tools/delegate.py +98 -0
  34. opencomputer/tools/glob.py +70 -0
  35. opencomputer/tools/grep.py +117 -0
  36. opencomputer/tools/read.py +81 -0
  37. opencomputer/tools/registry.py +69 -0
  38. opencomputer/tools/skill_manage.py +265 -0
  39. opencomputer/tools/write.py +58 -0
  40. opencomputer-0.1.0.dist-info/METADATA +190 -0
  41. opencomputer-0.1.0.dist-info/RECORD +51 -0
  42. opencomputer-0.1.0.dist-info/WHEEL +4 -0
  43. opencomputer-0.1.0.dist-info/entry_points.txt +3 -0
  44. plugin_sdk/__init__.py +66 -0
  45. plugin_sdk/channel_contract.py +74 -0
  46. plugin_sdk/core.py +129 -0
  47. plugin_sdk/hooks.py +80 -0
  48. plugin_sdk/injection.py +60 -0
  49. plugin_sdk/provider_contract.py +95 -0
  50. plugin_sdk/runtime_context.py +39 -0
  51. 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"]