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,326 @@
1
+ """
2
+ The agent loop — THE while loop.
3
+
4
+ Kept intentionally small (target <500 lines). All the architectural ideas
5
+ we studied condense to this:
6
+ 1. user message arrives
7
+ 2. loop:
8
+ call LLM with current messages + tool schemas
9
+ if response has tool_calls:
10
+ dispatch them in parallel (where safe), append results
11
+ continue
12
+ else:
13
+ break — this is the final answer
14
+ 3. persist the conversation to SQLite
15
+ 4. return the final message
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import uuid
22
+ from dataclasses import dataclass
23
+
24
+ from opencomputer.agent.compaction import CompactionEngine
25
+ from opencomputer.agent.config import Config
26
+ from opencomputer.agent.injection import engine as injection_engine
27
+ from opencomputer.agent.memory import MemoryManager
28
+ from opencomputer.agent.prompt_builder import PromptBuilder
29
+ from opencomputer.agent.state import SessionDB
30
+ from opencomputer.agent.step import StepOutcome
31
+ from opencomputer.tools.registry import registry
32
+ from plugin_sdk.core import Message, StopReason, ToolCall
33
+ from plugin_sdk.injection import InjectionContext
34
+ from plugin_sdk.provider_contract import BaseProvider
35
+ from plugin_sdk.runtime_context import DEFAULT_RUNTIME_CONTEXT, RuntimeContext
36
+
37
+
38
+ @dataclass(slots=True)
39
+ class ConversationResult:
40
+ """What a full run_conversation call returns."""
41
+
42
+ final_message: Message
43
+ messages: list[Message]
44
+ session_id: str
45
+ iterations: int
46
+ input_tokens: int
47
+ output_tokens: int
48
+
49
+
50
+ class AgentLoop:
51
+ """The single while-loop that runs the agent."""
52
+
53
+ def __init__(
54
+ self,
55
+ provider: BaseProvider,
56
+ config: Config,
57
+ db: SessionDB | None = None,
58
+ memory: MemoryManager | None = None,
59
+ prompt_builder: PromptBuilder | None = None,
60
+ compaction_disabled: bool = False,
61
+ ) -> None:
62
+ self.provider = provider
63
+ self.config = config
64
+ self.db = db or SessionDB(config.session.db_path)
65
+ self.memory = memory or MemoryManager(
66
+ config.memory.declarative_path, config.memory.skills_path
67
+ )
68
+ self.prompt_builder = prompt_builder or PromptBuilder()
69
+ self.compaction = CompactionEngine(
70
+ provider=provider,
71
+ model=config.model.model,
72
+ disabled=compaction_disabled,
73
+ )
74
+ self._last_input_tokens = 0
75
+
76
+ # ─── the loop ──────────────────────────────────────────────────
77
+
78
+ async def run_conversation(
79
+ self,
80
+ user_message: str,
81
+ session_id: str | None = None,
82
+ system_override: str | None = None,
83
+ runtime: RuntimeContext | None = None,
84
+ stream_callback=None,
85
+ ) -> ConversationResult:
86
+ sid = session_id or str(uuid.uuid4())
87
+ self._runtime = runtime or DEFAULT_RUNTIME_CONTEXT
88
+
89
+ # If this is a fresh session, create it in the DB and seed history from disk.
90
+ existing = self.db.get_session(sid) if session_id else None
91
+ if existing is None:
92
+ self.db.create_session(
93
+ session_id=sid,
94
+ platform="cli",
95
+ model=self.config.model.model,
96
+ )
97
+ messages: list[Message] = []
98
+ else:
99
+ messages = self.db.get_messages(sid)
100
+
101
+ # Build system prompt fresh every turn (cheap, keeps skill list up to date)
102
+ if system_override is not None:
103
+ base_system = system_override
104
+ else:
105
+ skills = self.memory.list_skills()
106
+ base_system = self.prompt_builder.build(skills=skills)
107
+
108
+ # Collect dynamic injections (plan_mode, yolo_mode, etc. from plugins)
109
+ inj_ctx = InjectionContext(
110
+ messages=tuple(messages),
111
+ runtime=self._runtime,
112
+ session_id=sid,
113
+ )
114
+ injected = injection_engine.compose(inj_ctx)
115
+ system = base_system + ("\n\n" + injected if injected else "")
116
+
117
+ # Append user message + persist
118
+ user_msg = Message(role="user", content=user_message)
119
+ messages.append(user_msg)
120
+ self.db.append_message(sid, user_msg)
121
+
122
+ total_input = 0
123
+ total_output = 0
124
+ iterations = 0
125
+
126
+ for _iter in range(self.config.loop.max_iterations):
127
+ iterations += 1
128
+
129
+ # Compaction check — uses REAL measured tokens from prior turn.
130
+ # First iteration (no prior measurement) skips the check.
131
+ if self._last_input_tokens > 0:
132
+ result = await self.compaction.maybe_run(
133
+ messages, self._last_input_tokens
134
+ )
135
+ if result.did_compact:
136
+ messages = result.messages
137
+ # Re-collect injections with the new message list
138
+ inj_ctx = InjectionContext(
139
+ messages=tuple(messages),
140
+ runtime=self._runtime,
141
+ session_id=sid,
142
+ )
143
+ injected = injection_engine.compose(inj_ctx)
144
+ system = base_system + ("\n\n" + injected if injected else "")
145
+
146
+ step = await self._run_one_step(
147
+ messages=messages, system=system, stream_callback=stream_callback
148
+ )
149
+ self._last_input_tokens = step.input_tokens
150
+ total_input += step.input_tokens
151
+ total_output += step.output_tokens
152
+ messages.append(step.assistant_message)
153
+ self.db.append_message(sid, step.assistant_message)
154
+
155
+ if not step.should_continue:
156
+ self.db.end_session(sid)
157
+ return ConversationResult(
158
+ final_message=step.assistant_message,
159
+ messages=messages,
160
+ session_id=sid,
161
+ iterations=iterations,
162
+ input_tokens=total_input,
163
+ output_tokens=total_output,
164
+ )
165
+
166
+ # Push the current runtime to DelegateTool so subagents inherit it
167
+ try:
168
+ from opencomputer.tools.delegate import DelegateTool
169
+
170
+ DelegateTool.set_runtime(self._runtime)
171
+ except Exception:
172
+ pass # delegate tool may not be registered yet in some contexts
173
+
174
+ # Dispatch all tool calls from this step (runtime flows into hooks via sid)
175
+ tool_results = await self._dispatch_tool_calls(
176
+ step.assistant_message.tool_calls or [],
177
+ session_id=sid,
178
+ )
179
+ for tr_msg in tool_results:
180
+ messages.append(tr_msg)
181
+ self.db.append_message(sid, tr_msg)
182
+
183
+ # Budget exhausted
184
+ final = Message(
185
+ role="assistant",
186
+ content="[loop iteration budget exhausted — agent did not finish]",
187
+ )
188
+ messages.append(final)
189
+ self.db.append_message(sid, final)
190
+ self.db.end_session(sid)
191
+ return ConversationResult(
192
+ final_message=final,
193
+ messages=messages,
194
+ session_id=sid,
195
+ iterations=iterations,
196
+ input_tokens=total_input,
197
+ output_tokens=total_output,
198
+ )
199
+
200
+ # ─── one step ──────────────────────────────────────────────────
201
+
202
+ async def _run_one_step(
203
+ self,
204
+ *,
205
+ messages: list[Message],
206
+ system: str,
207
+ stream_callback=None,
208
+ ) -> StepOutcome:
209
+ """One LLM call + classification of the result.
210
+
211
+ If `stream_callback` is provided, stream_complete is used and each
212
+ text chunk is passed to the callback synchronously.
213
+ """
214
+ if stream_callback is not None:
215
+ final_response = None
216
+ async for event in self.provider.stream_complete(
217
+ model=self.config.model.model,
218
+ messages=messages,
219
+ system=system,
220
+ tools=registry.schemas(),
221
+ max_tokens=self.config.model.max_tokens,
222
+ temperature=self.config.model.temperature,
223
+ ):
224
+ if event.kind == "text_delta":
225
+ stream_callback(event.text)
226
+ elif event.kind == "done":
227
+ final_response = event.response
228
+ if final_response is None:
229
+ raise RuntimeError("stream ended without a 'done' event")
230
+ resp = final_response
231
+ else:
232
+ resp = await self.provider.complete(
233
+ model=self.config.model.model,
234
+ messages=messages,
235
+ system=system,
236
+ tools=registry.schemas(),
237
+ max_tokens=self.config.model.max_tokens,
238
+ temperature=self.config.model.temperature,
239
+ )
240
+
241
+ stop_reason_map = {
242
+ "end_turn": StopReason.END_TURN,
243
+ "tool_use": StopReason.TOOL_USE,
244
+ "max_tokens": StopReason.MAX_TOKENS,
245
+ "stop_sequence": StopReason.END_TURN,
246
+ }
247
+ stop = stop_reason_map.get(resp.stop_reason, StopReason.END_TURN)
248
+
249
+ # If the model called tools, even if the raw stop_reason was "end_turn",
250
+ # we need to continue so the model can process results.
251
+ if resp.message.tool_calls and stop == StopReason.END_TURN:
252
+ stop = StopReason.TOOL_USE
253
+
254
+ return StepOutcome(
255
+ stop_reason=stop,
256
+ assistant_message=resp.message,
257
+ tool_calls_made=len(resp.message.tool_calls or []),
258
+ input_tokens=resp.usage.input_tokens,
259
+ output_tokens=resp.usage.output_tokens,
260
+ )
261
+
262
+ # ─── tool dispatch ─────────────────────────────────────────────
263
+
264
+ async def _dispatch_tool_calls(
265
+ self, calls: list[ToolCall], session_id: str = ""
266
+ ) -> list[Message]:
267
+ """Run all tool calls — in parallel where safe — and return result Messages.
268
+
269
+ Fires PreToolUse hooks before each tool runs. If a hook blocks, the tool
270
+ is skipped and an error ToolResult is synthesized. Runtime context flows
271
+ to hooks so plan_mode_block etc. can read it.
272
+ """
273
+ if not calls:
274
+ return []
275
+
276
+ # Fire PreToolUse hooks first (blocking). Determine which calls are blocked.
277
+ from opencomputer.hooks.engine import engine as hook_engine
278
+ from plugin_sdk.core import ToolResult
279
+ from plugin_sdk.hooks import HookContext, HookEvent
280
+
281
+ blocked: dict[str, str] = {} # call.id → block reason
282
+ for c in calls:
283
+ ctx = HookContext(
284
+ event=HookEvent.PRE_TOOL_USE,
285
+ session_id=session_id,
286
+ tool_call=c,
287
+ runtime=self._runtime,
288
+ )
289
+ decision = await hook_engine.fire_blocking(ctx)
290
+ if decision is not None and decision.decision == "block":
291
+ blocked[c.id] = decision.reason or "blocked by hook"
292
+
293
+ async def _run_one(c: ToolCall):
294
+ if c.id in blocked:
295
+ return ToolResult(
296
+ tool_call_id=c.id,
297
+ content=f"[blocked by PreToolUse hook: {blocked[c.id]}]",
298
+ is_error=True,
299
+ )
300
+ return await registry.dispatch(c)
301
+
302
+ if self.config.loop.parallel_tools and self._all_parallel_safe(calls):
303
+ results = await asyncio.gather(*(_run_one(c) for c in calls))
304
+ else:
305
+ results = [await _run_one(c) for c in calls]
306
+
307
+ return [
308
+ Message(
309
+ role="tool",
310
+ content=r.content,
311
+ tool_call_id=r.tool_call_id,
312
+ name=next((c.name for c in calls if c.id == r.tool_call_id), None),
313
+ )
314
+ for r in results
315
+ ]
316
+
317
+ def _all_parallel_safe(self, calls: list[ToolCall]) -> bool:
318
+ """Only parallelize when every tool in the batch declared parallel_safe."""
319
+ for c in calls:
320
+ tool = registry.get(c.name)
321
+ if tool is None or not tool.parallel_safe:
322
+ return False
323
+ return True
324
+
325
+
326
+ __all__ = ["AgentLoop", "ConversationResult"]
@@ -0,0 +1,132 @@
1
+ """
2
+ Three-pillar memory manager.
3
+
4
+ - Declarative: MEMORY.md + USER.md (plain markdown the user/agent edit)
5
+ - Procedural: ~/.opencomputer/skills/*/SKILL.md (skills folder)
6
+ - Episodic: SQLite + FTS5 (via SessionDB, not here)
7
+
8
+ This module owns the declarative + procedural reads/writes.
9
+ Episodic memory is queried through SessionDB in state.py.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import frontmatter
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class SkillMeta:
22
+ """Lightweight skill metadata — from frontmatter, without loading the body."""
23
+
24
+ id: str
25
+ name: str
26
+ description: str
27
+ path: Path
28
+ version: str = "0.1.0"
29
+
30
+
31
+ class MemoryManager:
32
+ """Reads declarative memory and lists procedural (skill) memory.
33
+
34
+ Skills are searched across multiple roots (kimi-cli pattern):
35
+ 1. User skills: ~/.opencomputer/skills/ (write target for new skills)
36
+ 2. Bundled skills: <repo>/opencomputer/skills/ (read-only, shipped defaults)
37
+
38
+ Higher-priority roots shadow lower-priority ones by skill id.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ declarative_path: Path,
44
+ skills_path: Path,
45
+ bundled_skills_paths: list[Path] | None = None,
46
+ ) -> None:
47
+ self.declarative_path = declarative_path
48
+ self.skills_path = skills_path
49
+ self.skills_path.mkdir(parents=True, exist_ok=True)
50
+ # Always include bundled skills shipped with core at the lowest priority
51
+ if bundled_skills_paths is None:
52
+ bundled = Path(__file__).resolve().parent.parent / "skills"
53
+ bundled_skills_paths = [bundled] if bundled.exists() else []
54
+ self.bundled_skills_paths = bundled_skills_paths
55
+
56
+ # ─── declarative ──────────────────────────────────────────────
57
+
58
+ def read_declarative(self) -> str:
59
+ """Return the entire MEMORY.md contents (empty string if missing)."""
60
+ if not self.declarative_path.exists():
61
+ return ""
62
+ return self.declarative_path.read_text(encoding="utf-8")
63
+
64
+ def append_declarative(self, text: str) -> None:
65
+ """Append a block of text to MEMORY.md."""
66
+ self.declarative_path.parent.mkdir(parents=True, exist_ok=True)
67
+ existing = self.read_declarative()
68
+ separator = "\n\n" if existing and not existing.endswith("\n\n") else ""
69
+ self.declarative_path.write_text(
70
+ existing + separator + text.strip() + "\n",
71
+ encoding="utf-8",
72
+ )
73
+
74
+ # ─── procedural (skills) ─────────────────────────────────────
75
+
76
+ def list_skills(self) -> list[SkillMeta]:
77
+ """Scan all skill roots for SKILL.md files. User skills shadow bundled ones."""
78
+ roots = [self.skills_path, *self.bundled_skills_paths]
79
+ seen_ids: set[str] = set()
80
+ out: list[SkillMeta] = []
81
+ for root in roots:
82
+ if not root.exists():
83
+ continue
84
+ for skill_dir in root.iterdir():
85
+ if not skill_dir.is_dir() or skill_dir.name in seen_ids:
86
+ continue
87
+ skill_md = skill_dir / "SKILL.md"
88
+ if not skill_md.exists():
89
+ continue
90
+ try:
91
+ post = frontmatter.load(skill_md)
92
+ except Exception:
93
+ continue
94
+ meta = post.metadata
95
+ seen_ids.add(skill_dir.name)
96
+ out.append(
97
+ SkillMeta(
98
+ id=skill_dir.name,
99
+ name=str(meta.get("name", skill_dir.name)),
100
+ description=str(meta.get("description", "")),
101
+ path=skill_md,
102
+ version=str(meta.get("version", "0.1.0")),
103
+ )
104
+ )
105
+ return out
106
+
107
+ def load_skill_body(self, skill_id: str) -> str:
108
+ """Load the full text of a skill's SKILL.md (minus frontmatter)."""
109
+ skill_md = self.skills_path / skill_id / "SKILL.md"
110
+ if not skill_md.exists():
111
+ return ""
112
+ post = frontmatter.load(skill_md)
113
+ return post.content
114
+
115
+ def write_skill(
116
+ self, skill_id: str, description: str, body: str, version: str = "0.1.0"
117
+ ) -> Path:
118
+ """Create (or overwrite) a skill at ~/.opencomputer/skills/<skill_id>/SKILL.md."""
119
+ skill_dir = self.skills_path / skill_id
120
+ skill_dir.mkdir(parents=True, exist_ok=True)
121
+ skill_md = skill_dir / "SKILL.md"
122
+ post = frontmatter.Post(
123
+ body,
124
+ name=skill_id,
125
+ description=description,
126
+ version=version,
127
+ )
128
+ skill_md.write_text(frontmatter.dumps(post), encoding="utf-8")
129
+ return skill_md
130
+
131
+
132
+ __all__ = ["MemoryManager", "SkillMeta"]
@@ -0,0 +1,66 @@
1
+ """
2
+ Prompt builder — Jinja2 templates + slot injection.
3
+
4
+ Loads `base.j2` and renders it with runtime variables (cwd, user_home,
5
+ time, available skills, etc). Keeps the prompt out of code and makes
6
+ customization trivial — users can edit the .j2 files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
17
+
18
+ from opencomputer.agent.memory import SkillMeta
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class PromptContext:
23
+ """Variables injected into prompt templates."""
24
+
25
+ cwd: str = ""
26
+ user_home: str = ""
27
+ now: str = ""
28
+ skills: list[SkillMeta] | None = None
29
+
30
+
31
+ class PromptBuilder:
32
+ """Renders system prompts from Jinja2 templates."""
33
+
34
+ def __init__(self, templates_dir: Path | None = None) -> None:
35
+ if templates_dir is None:
36
+ templates_dir = Path(__file__).parent / "prompts"
37
+ self.env = Environment(
38
+ loader=FileSystemLoader(str(templates_dir)),
39
+ autoescape=select_autoescape(disabled_extensions=("j2",)),
40
+ keep_trailing_newline=True,
41
+ trim_blocks=True,
42
+ lstrip_blocks=True,
43
+ )
44
+
45
+ def build(
46
+ self,
47
+ *,
48
+ skills: list[SkillMeta] | None = None,
49
+ template: str = "base.j2",
50
+ ) -> str:
51
+ ctx = PromptContext(
52
+ cwd=os.getcwd(),
53
+ user_home=str(Path.home()),
54
+ now=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
55
+ skills=skills or [],
56
+ )
57
+ tpl = self.env.get_template(template)
58
+ return tpl.render(
59
+ cwd=ctx.cwd,
60
+ user_home=ctx.user_home,
61
+ now=ctx.now,
62
+ skills=ctx.skills,
63
+ )
64
+
65
+
66
+ __all__ = ["PromptBuilder", "PromptContext"]
@@ -0,0 +1,23 @@
1
+ You are OpenComputer — a personal AI agent running on {{ user_home }}.
2
+
3
+ Current working directory: {{ cwd }}
4
+ Current time: {{ now }}
5
+
6
+ You have tools available. Use them to accomplish what the user asks.
7
+
8
+ {% if skills -%}
9
+ ## Skills available
10
+
11
+ {% for skill in skills -%}
12
+ - **{{ skill.name }}** — {{ skill.description }}
13
+ {% endfor %}
14
+
15
+ When one of these skills matches the user's task, follow its guidance.
16
+ {%- endif %}
17
+
18
+ ## Working rules
19
+
20
+ - When the user asks you to do something, USE YOUR TOOLS to actually do it. Don't describe what you would do — do it.
21
+ - Be concise in your responses. The user prefers action over explanation.
22
+ - When a complex task is complete, consider saving the approach as a skill
23
+ using the skill_manage tool, so it can be reused next time.