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,81 @@
1
+ """Read tool — read the contents of a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from plugin_sdk.core import ToolCall, ToolResult
8
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
9
+
10
+
11
+ class ReadTool(BaseTool):
12
+ parallel_safe = True
13
+
14
+ @property
15
+ def schema(self) -> ToolSchema:
16
+ return ToolSchema(
17
+ name="Read",
18
+ description="Read the contents of a file from disk. Returns the text, "
19
+ "prefixed with line numbers. Supports optional offset and limit "
20
+ "for reading slices of large files.",
21
+ parameters={
22
+ "type": "object",
23
+ "properties": {
24
+ "file_path": {
25
+ "type": "string",
26
+ "description": "Absolute path to the file to read.",
27
+ },
28
+ "offset": {
29
+ "type": "integer",
30
+ "description": "Line number to start reading from (1-indexed).",
31
+ "minimum": 1,
32
+ },
33
+ "limit": {
34
+ "type": "integer",
35
+ "description": "Maximum number of lines to read.",
36
+ "minimum": 1,
37
+ },
38
+ },
39
+ "required": ["file_path"],
40
+ },
41
+ )
42
+
43
+ async def execute(self, call: ToolCall) -> ToolResult:
44
+ args = call.arguments
45
+ path = Path(args.get("file_path", ""))
46
+ if not path.is_absolute():
47
+ return ToolResult(
48
+ tool_call_id=call.id,
49
+ content=f"Error: file_path must be absolute, got: {path}",
50
+ is_error=True,
51
+ )
52
+ if not path.exists():
53
+ return ToolResult(
54
+ tool_call_id=call.id,
55
+ content=f"Error: file does not exist: {path}",
56
+ is_error=True,
57
+ )
58
+ if not path.is_file():
59
+ return ToolResult(
60
+ tool_call_id=call.id,
61
+ content=f"Error: path is not a file: {path}",
62
+ is_error=True,
63
+ )
64
+
65
+ try:
66
+ text = path.read_text(encoding="utf-8", errors="replace")
67
+ except Exception as e:
68
+ return ToolResult(
69
+ tool_call_id=call.id,
70
+ content=f"Error reading {path}: {type(e).__name__}: {e}",
71
+ is_error=True,
72
+ )
73
+
74
+ lines = text.splitlines()
75
+ offset = max(1, int(args.get("offset", 1)))
76
+ limit = int(args.get("limit", 2000))
77
+ start_idx = offset - 1
78
+ end_idx = start_idx + limit
79
+ slice_ = lines[start_idx:end_idx]
80
+ numbered = "\n".join(f"{start_idx + i + 1:>6}\t{ln}" for i, ln in enumerate(slice_))
81
+ return ToolResult(tool_call_id=call.id, content=numbered)
@@ -0,0 +1,69 @@
1
+ """
2
+ Tool registry — a dict + dispatch.
3
+
4
+ Inspired by hermes's ToolEntry pattern. A singleton registry holds
5
+ ToolEntries; tools register themselves via `@register_tool`. The
6
+ agent loop asks the registry for all schemas and dispatches calls.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from plugin_sdk.core import ToolCall, ToolResult
14
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
15
+
16
+
17
+ class ToolRegistry:
18
+ """Singleton registry. Import from elsewhere as `from opencomputer.tools.registry import registry`."""
19
+
20
+ def __init__(self) -> None:
21
+ self._tools: dict[str, BaseTool] = {}
22
+
23
+ def register(self, tool: BaseTool) -> None:
24
+ name = tool.schema.name
25
+ if name in self._tools:
26
+ raise ValueError(f"Tool '{name}' is already registered")
27
+ self._tools[name] = tool
28
+
29
+ def unregister(self, name: str) -> None:
30
+ self._tools.pop(name, None)
31
+
32
+ def get(self, name: str) -> BaseTool | None:
33
+ return self._tools.get(name)
34
+
35
+ def schemas(self) -> list[ToolSchema]:
36
+ return [t.schema for t in self._tools.values()]
37
+
38
+ def names(self) -> Iterable[str]:
39
+ return self._tools.keys()
40
+
41
+ async def dispatch(self, call: ToolCall) -> ToolResult:
42
+ """Dispatch a tool call to its handler. Never raises — always returns a ToolResult."""
43
+ tool = self._tools.get(call.name)
44
+ if tool is None:
45
+ return ToolResult(
46
+ tool_call_id=call.id,
47
+ content=f"Error: tool '{call.name}' not found",
48
+ is_error=True,
49
+ )
50
+ try:
51
+ return await tool.execute(call)
52
+ except Exception as e: # defensive — tool.execute should handle its own errors
53
+ return ToolResult(
54
+ tool_call_id=call.id,
55
+ content=f"Error: {type(e).__name__}: {e}",
56
+ is_error=True,
57
+ )
58
+
59
+
60
+ registry = ToolRegistry()
61
+
62
+
63
+ def register_tool(tool: BaseTool) -> BaseTool:
64
+ """Convenience: register and return the tool (so it can be used as a module-level call)."""
65
+ registry.register(tool)
66
+ return tool
67
+
68
+
69
+ __all__ = ["ToolRegistry", "registry", "register_tool"]
@@ -0,0 +1,265 @@
1
+ """
2
+ skill_manage — the self-improvement tool.
3
+
4
+ The agent calls this after completing complex tasks to save the approach
5
+ as a reusable skill. On the next relevant conversation, the skill's
6
+ description auto-activates and its body enters the system prompt.
7
+
8
+ Inspired by hermes's tools/skill_manager_tool.py. Trimmed: only the
9
+ actions we actually need (create/edit/patch/delete/view/list).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ import frontmatter
18
+
19
+ from opencomputer.agent.config import default_config
20
+ from plugin_sdk.core import ToolCall, ToolResult
21
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
22
+
23
+ _VALID_ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
24
+
25
+
26
+ def _skills_root() -> Path:
27
+ return default_config().memory.skills_path
28
+
29
+
30
+ def _skill_dir(skill_id: str) -> Path:
31
+ return _skills_root() / skill_id
32
+
33
+
34
+ def _validate_id(skill_id: str) -> str | None:
35
+ if not skill_id:
36
+ return "Error: skill id is required"
37
+ if not _VALID_ID.match(skill_id):
38
+ return f"Error: skill id '{skill_id}' must be kebab-case (lowercase, hyphens only)"
39
+ return None
40
+
41
+
42
+ def _validate_frontmatter(body: str) -> str | None:
43
+ if not body.strip():
44
+ return "Error: body is empty"
45
+ try:
46
+ post = frontmatter.loads(body)
47
+ except Exception as e: # noqa: BLE001
48
+ return f"Error parsing frontmatter: {e}"
49
+ meta = post.metadata
50
+ if "name" not in meta:
51
+ return "Error: frontmatter must include 'name'"
52
+ if "description" not in meta:
53
+ return "Error: frontmatter must include 'description'"
54
+ if not str(meta.get("description", "")).strip():
55
+ return "Error: description cannot be empty"
56
+ return None
57
+
58
+
59
+ class SkillManageTool(BaseTool):
60
+ parallel_safe = False # writes to disk
61
+
62
+ @property
63
+ def schema(self) -> ToolSchema:
64
+ return ToolSchema(
65
+ name="skill_manage",
66
+ description=(
67
+ "Manage skills (procedural memory). Call this AFTER completing a complex task "
68
+ "(5+ tool calls, tricky error, non-trivial workflow) to save the approach as a "
69
+ "reusable skill. Skills are auto-activated on relevant future queries.\n\n"
70
+ "Also use to fix outdated/wrong skills — patch them immediately when you "
71
+ "notice something is off.\n\n"
72
+ "Actions:\n"
73
+ " create — make a new skill with full SKILL.md content (frontmatter + body)\n"
74
+ " edit — fully rewrite an existing skill\n"
75
+ " patch — targeted find/replace within an existing skill\n"
76
+ " delete — remove a skill\n"
77
+ " view — read a skill's contents\n"
78
+ " list — list all installed skills"
79
+ ),
80
+ parameters={
81
+ "type": "object",
82
+ "properties": {
83
+ "action": {
84
+ "type": "string",
85
+ "enum": ["create", "edit", "patch", "delete", "view", "list"],
86
+ "description": "What to do",
87
+ },
88
+ "name": {
89
+ "type": "string",
90
+ "description": "Skill id (kebab-case). Not required for list.",
91
+ },
92
+ "content": {
93
+ "type": "string",
94
+ "description": (
95
+ "Full SKILL.md content for create/edit (must include frontmatter "
96
+ "with name + description)."
97
+ ),
98
+ },
99
+ "find": {
100
+ "type": "string",
101
+ "description": "For patch action: exact text to find.",
102
+ },
103
+ "replace": {
104
+ "type": "string",
105
+ "description": "For patch action: replacement text.",
106
+ },
107
+ },
108
+ "required": ["action"],
109
+ },
110
+ )
111
+
112
+ async def execute(self, call: ToolCall) -> ToolResult:
113
+ args = call.arguments
114
+ action = args.get("action", "").lower()
115
+ name = args.get("name", "")
116
+
117
+ if action == "list":
118
+ return self._list(call.id)
119
+ if action == "view":
120
+ return self._view(call.id, name)
121
+ if action == "create":
122
+ return self._create(call.id, name, args.get("content", ""))
123
+ if action == "edit":
124
+ return self._edit(call.id, name, args.get("content", ""))
125
+ if action == "patch":
126
+ return self._patch(
127
+ call.id, name, args.get("find", ""), args.get("replace", "")
128
+ )
129
+ if action == "delete":
130
+ return self._delete(call.id, name)
131
+ return ToolResult(
132
+ tool_call_id=call.id,
133
+ content=f"Error: unknown action '{action}'",
134
+ is_error=True,
135
+ )
136
+
137
+ # ─── actions ──────────────────────────────────────────────────
138
+
139
+ def _list(self, call_id: str) -> ToolResult:
140
+ root = _skills_root()
141
+ if not root.exists():
142
+ return ToolResult(tool_call_id=call_id, content="no skills installed")
143
+ lines: list[str] = []
144
+ for d in sorted(root.iterdir()):
145
+ if not d.is_dir():
146
+ continue
147
+ skill_md = d / "SKILL.md"
148
+ if not skill_md.exists():
149
+ continue
150
+ try:
151
+ post = frontmatter.load(skill_md)
152
+ desc = post.metadata.get("description", "")
153
+ except Exception:
154
+ desc = "[failed to parse]"
155
+ lines.append(f"- {d.name}: {desc}")
156
+ return ToolResult(
157
+ tool_call_id=call_id,
158
+ content="\n".join(lines) or "no skills installed",
159
+ )
160
+
161
+ def _view(self, call_id: str, name: str) -> ToolResult:
162
+ if err := _validate_id(name):
163
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
164
+ skill_md = _skill_dir(name) / "SKILL.md"
165
+ if not skill_md.exists():
166
+ return ToolResult(
167
+ tool_call_id=call_id,
168
+ content=f"Error: skill '{name}' not found",
169
+ is_error=True,
170
+ )
171
+ return ToolResult(
172
+ tool_call_id=call_id, content=skill_md.read_text(encoding="utf-8")
173
+ )
174
+
175
+ def _create(self, call_id: str, name: str, content: str) -> ToolResult:
176
+ if err := _validate_id(name):
177
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
178
+ if err := _validate_frontmatter(content):
179
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
180
+ skill_dir = _skill_dir(name)
181
+ if skill_dir.exists():
182
+ return ToolResult(
183
+ tool_call_id=call_id,
184
+ content=f"Error: skill '{name}' already exists — use action='edit' or 'patch'",
185
+ is_error=True,
186
+ )
187
+ skill_dir.mkdir(parents=True, exist_ok=True)
188
+ (skill_dir / "SKILL.md").write_text(content, encoding="utf-8")
189
+ return ToolResult(
190
+ tool_call_id=call_id,
191
+ content=f"Created skill '{name}' at {skill_dir}",
192
+ )
193
+
194
+ def _edit(self, call_id: str, name: str, content: str) -> ToolResult:
195
+ if err := _validate_id(name):
196
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
197
+ if err := _validate_frontmatter(content):
198
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
199
+ skill_md = _skill_dir(name) / "SKILL.md"
200
+ if not skill_md.exists():
201
+ return ToolResult(
202
+ tool_call_id=call_id,
203
+ content=f"Error: skill '{name}' not found — use action='create' to add it",
204
+ is_error=True,
205
+ )
206
+ skill_md.write_text(content, encoding="utf-8")
207
+ return ToolResult(
208
+ tool_call_id=call_id, content=f"Updated skill '{name}'"
209
+ )
210
+
211
+ def _patch(self, call_id: str, name: str, find: str, replace: str) -> ToolResult:
212
+ if err := _validate_id(name):
213
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
214
+ if not find:
215
+ return ToolResult(
216
+ tool_call_id=call_id,
217
+ content="Error: patch requires 'find' string",
218
+ is_error=True,
219
+ )
220
+ skill_md = _skill_dir(name) / "SKILL.md"
221
+ if not skill_md.exists():
222
+ return ToolResult(
223
+ tool_call_id=call_id,
224
+ content=f"Error: skill '{name}' not found",
225
+ is_error=True,
226
+ )
227
+ text = skill_md.read_text(encoding="utf-8")
228
+ if find not in text:
229
+ return ToolResult(
230
+ tool_call_id=call_id,
231
+ content=f"Error: 'find' string not present in skill '{name}'",
232
+ is_error=True,
233
+ )
234
+ if text.count(find) > 1:
235
+ return ToolResult(
236
+ tool_call_id=call_id,
237
+ content=(
238
+ f"Error: 'find' string appears {text.count(find)} times in skill "
239
+ f"'{name}' — be more specific"
240
+ ),
241
+ is_error=True,
242
+ )
243
+ new_text = text.replace(find, replace)
244
+ skill_md.write_text(new_text, encoding="utf-8")
245
+ return ToolResult(
246
+ tool_call_id=call_id, content=f"Patched skill '{name}' (1 replacement)"
247
+ )
248
+
249
+ def _delete(self, call_id: str, name: str) -> ToolResult:
250
+ if err := _validate_id(name):
251
+ return ToolResult(tool_call_id=call_id, content=err, is_error=True)
252
+ skill_dir = _skill_dir(name)
253
+ if not skill_dir.exists():
254
+ return ToolResult(
255
+ tool_call_id=call_id,
256
+ content=f"Error: skill '{name}' not found",
257
+ is_error=True,
258
+ )
259
+ import shutil
260
+
261
+ shutil.rmtree(skill_dir)
262
+ return ToolResult(tool_call_id=call_id, content=f"Deleted skill '{name}'")
263
+
264
+
265
+ __all__ = ["SkillManageTool"]
@@ -0,0 +1,58 @@
1
+ """Write tool — write content to a file, creating parent dirs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from plugin_sdk.core import ToolCall, ToolResult
8
+ from plugin_sdk.tool_contract import BaseTool, ToolSchema
9
+
10
+
11
+ class WriteTool(BaseTool):
12
+ parallel_safe = False # writes to same path could race
13
+
14
+ @property
15
+ def schema(self) -> ToolSchema:
16
+ return ToolSchema(
17
+ name="Write",
18
+ description="Write content to a file. Overwrites the file if it exists. "
19
+ "Creates parent directories as needed. file_path must be absolute.",
20
+ parameters={
21
+ "type": "object",
22
+ "properties": {
23
+ "file_path": {
24
+ "type": "string",
25
+ "description": "Absolute path to the file to write.",
26
+ },
27
+ "content": {
28
+ "type": "string",
29
+ "description": "Full contents to write.",
30
+ },
31
+ },
32
+ "required": ["file_path", "content"],
33
+ },
34
+ )
35
+
36
+ async def execute(self, call: ToolCall) -> ToolResult:
37
+ args = call.arguments
38
+ path = Path(args.get("file_path", ""))
39
+ content = args.get("content", "")
40
+ if not path.is_absolute():
41
+ return ToolResult(
42
+ tool_call_id=call.id,
43
+ content=f"Error: file_path must be absolute, got: {path}",
44
+ is_error=True,
45
+ )
46
+ try:
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(content, encoding="utf-8")
49
+ except Exception as e:
50
+ return ToolResult(
51
+ tool_call_id=call.id,
52
+ content=f"Error writing {path}: {type(e).__name__}: {e}",
53
+ is_error=True,
54
+ )
55
+ return ToolResult(
56
+ tool_call_id=call.id,
57
+ content=f"Wrote {len(content)} bytes to {path}",
58
+ )
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: opencomputer
3
+ Version: 0.1.0
4
+ Summary: Personal AI agent framework — plugin-first, self-improving, multi-channel
5
+ Author: OpenComputer Contributors
6
+ License: MIT
7
+ Keywords: agent,ai,anthropic,chatbot,claude,llm,openai
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: aiosqlite>=0.20
13
+ Requires-Dist: anthropic>=0.40
14
+ Requires-Dist: discord-py>=2.3
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: jinja2>=3.1
17
+ Requires-Dist: mcp>=1.0
18
+ Requires-Dist: openai>=1.60
19
+ Requires-Dist: pydantic>=2.9
20
+ Requires-Dist: python-frontmatter>=1.1
21
+ Requires-Dist: pyyaml>=6.0
22
+ Requires-Dist: rich>=13.7
23
+ Requires-Dist: tenacity>=9.0
24
+ Requires-Dist: typer>=0.12
25
+ Requires-Dist: websockets>=13.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: black>=24.0; extra == 'dev'
28
+ Requires-Dist: mypy>=1.11; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.7; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # OpenComputer
35
+
36
+ A personal AI agent framework — plugin-first, self-improving, multi-channel.
37
+
38
+ A synthesis of the best ideas from [Claude Code](https://github.com/anthropics/claude-code), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [OpenClaw](https://github.com/openclaw/openclaw), and [Kimi CLI](https://github.com/MoonshotAI/kimi-cli).
39
+
40
+ ## What it does
41
+
42
+ - **Chat agent** with tool calling (file ops, bash, grep, glob, subagents, skills).
43
+ - **Three-pillar persistent memory:** declarative (MEMORY.md), procedural (skills/), episodic (SQLite + FTS5 full-text search).
44
+ - **Self-improvement loop:** the agent saves complex workflows as skills that auto-activate next time.
45
+ - **Strict plugin SDK boundary:** third-party plugins never import core internals, so the core can evolve without breaking plugins.
46
+ - **Multi-channel gateway:** run as a daemon; chat via Telegram and Discord today, Slack coming.
47
+ - **Multiple providers:** Anthropic (native + proxy-compatible), OpenAI, any OpenAI-compatible endpoint (OpenRouter, Ollama, etc.).
48
+ - **MCP integration:** plug in any [Model Context Protocol](https://modelcontextprotocol.io) server — its tools become native tools.
49
+
50
+ ## Status
51
+
52
+ Pre-alpha (0.1.0). Core architecture stable. 114 tests passing. Adding features incrementally.
53
+
54
+ ## Install
55
+
56
+ Requires **Python 3.12+**.
57
+
58
+ ```bash
59
+ pip install opencomputer
60
+ ```
61
+
62
+ ### For development
63
+
64
+ ```bash
65
+ git clone https://github.com/sakshamzip2-sys/opencomputer.git
66
+ cd opencomputer
67
+ python3 -m venv .venv
68
+ source .venv/bin/activate
69
+ pip install -e ".[dev]"
70
+ ```
71
+
72
+ ## Quickstart
73
+
74
+ ```bash
75
+ # 1. Run the setup wizard — picks provider, saves config
76
+ opencomputer setup
77
+
78
+ # 2. Export your API key (setup tells you which)
79
+ export ANTHROPIC_API_KEY=sk-ant-...
80
+ # or: export OPENAI_API_KEY=sk-...
81
+
82
+ # 3. Verify the install
83
+ opencomputer doctor
84
+
85
+ # 4. Chat
86
+ opencomputer
87
+ ```
88
+
89
+ ## Commands
90
+
91
+ ```bash
92
+ opencomputer # start a chat session (alias for `chat`)
93
+ opencomputer chat # interactive REPL with tools
94
+ opencomputer gateway # run the daemon — listens on configured channels
95
+ opencomputer search QUERY # full-text search past conversations
96
+ opencomputer sessions # list recent sessions
97
+ opencomputer skills # list available skills (bundled + user)
98
+ opencomputer plugins # list installed plugins
99
+ opencomputer setup # first-run wizard
100
+ opencomputer doctor # diagnose config/env issues
101
+ opencomputer config show # print effective config
102
+ opencomputer config get KEY # read one config value (e.g. model.provider)
103
+ opencomputer config set KEY VALUE
104
+ ```
105
+
106
+ ## Coding mode
107
+
108
+ OpenComputer ships with a `coding-harness` plugin that adds Claude-Code-style
109
+ coding tools (Edit, MultiEdit, TodoWrite, background process management) plus
110
+ a formal "plan mode" that refuses destructive tools while you review the plan.
111
+
112
+ ```bash
113
+ # Normal mode — Edit/Write/Bash work, agent can modify files directly
114
+ opencomputer
115
+
116
+ # Plan mode — agent describes what it would do, Edit/Write/Bash are refused
117
+ # Useful for big refactors where you want to review before committing
118
+ opencomputer chat --plan
119
+
120
+ # Disable automatic context compaction (debugging long sessions)
121
+ opencomputer chat --no-compact
122
+ ```
123
+
124
+ In plan mode, plan-mode guidance is injected into the system prompt AND a
125
+ PreToolUse hook hard-blocks destructive tools — belt + suspenders. Subagents
126
+ spawned via the `delegate` tool inherit plan mode automatically.
127
+
128
+ Remove the coding harness any time by removing or renaming
129
+ `extensions/coding-harness/`. The core agent stays fully functional.
130
+
131
+ ## Messaging channels
132
+
133
+ ### Telegram
134
+
135
+ 1. Message [@BotFather](https://t.me/BotFather) → `/newbot` → get a token
136
+ 2. `export TELEGRAM_BOT_TOKEN=123:ABC...`
137
+ 3. `opencomputer gateway` — the bundled Telegram plugin auto-connects
138
+ 4. DM your bot on Telegram
139
+
140
+ ## MCP servers
141
+
142
+ Plug any MCP server into OpenComputer. Edit `~/.opencomputer/config.yaml`:
143
+
144
+ ```yaml
145
+ mcp:
146
+ servers:
147
+ - name: my-server
148
+ transport: stdio
149
+ command: python3
150
+ args:
151
+ - /path/to/mcp_server.py
152
+ enabled: true
153
+ ```
154
+
155
+ The server's tools become available to the agent on next run (namespaced `my-server__tool_name`).
156
+
157
+ ## Architecture
158
+
159
+ ```
160
+ opencomputer/ (core — agent loop, state, memory, tools, hooks, gateway, plugin discovery)
161
+ plugin_sdk/ (public contract — stable types plugins import)
162
+ extensions/ (bundled plugins — telegram, discord, anthropic-provider, openai-provider, coding-harness)
163
+ ```
164
+
165
+ **Key design rule:** extensions import only from `plugin_sdk/*`, never from `opencomputer/*`. The core can be refactored freely without breaking plugins.
166
+
167
+ ## Writing a plugin
168
+
169
+ Plugins are separate folders with a manifest and an entry module. Minimal channel plugin:
170
+
171
+ ```
172
+ extensions/my-channel/
173
+ ├── plugin.json # { "id": "my-channel", "version": "0.1.0", "entry": "plugin", "kind": "channel" }
174
+ ├── plugin.py # exports register(api) — registers the adapter
175
+ └── adapter.py # your BaseChannelAdapter subclass
176
+ ```
177
+
178
+ See `extensions/telegram/` for a working reference.
179
+
180
+ ## License
181
+
182
+ MIT — see `LICENSE.md`.
183
+
184
+ ## Credits
185
+
186
+ Architectural ideas synthesized from:
187
+ - **Claude Code** — plugin primitives vocabulary (commands/skills/agents/hooks/MCP), lifecycle events
188
+ - **Hermes Agent** — Python core patterns, three-pillar memory, agent loop shape
189
+ - **OpenClaw** — plugin-first architecture, manifest-first discovery, strict SDK boundary
190
+ - **Kimi CLI** — dynamic injection, fire-and-forget hooks, deferred MCP, wire-protocol UI decoupling