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,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
|