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