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,3 @@
1
+ """OpenComputer — personal AI agent framework."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Agent subsystem — loop, memory, state, prompt building."""
@@ -0,0 +1,245 @@
1
+ """
2
+ CompactionEngine — auto-summarize old turns when the context fills up.
3
+
4
+ Design notes (per Phase 6a review):
5
+
6
+ 1. Trigger uses the ACTUAL input_tokens from the last ProviderResponse.usage
7
+ (not a character-count estimate). Different providers tokenize differently.
8
+ 2. Preserves the last N messages (default 20) untouched.
9
+ 3. Preserves assistant+tool_result message PAIRS atomically. Splitting a
10
+ tool_use from its matching tool_result causes Anthropic's API to 400.
11
+ 4. On aux-LLM failure or timeout, falls back to a deterministic
12
+ "truncate-and-drop-oldest-N" strategy so the turn can still proceed.
13
+ 5. Hooks and injection providers DO NOT fire inside the compaction LLM call
14
+ (no recursion). Iteration budget is not charged.
15
+
16
+ Returns a NEW message list with the compacted range replaced by one synthetic
17
+ assistant message tagged `[compacted-summary]` so downstream tools (FTS5
18
+ search) can distinguish it from the model's own output.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import logging
25
+ from dataclasses import dataclass
26
+
27
+ from plugin_sdk.core import Message
28
+ from plugin_sdk.provider_contract import BaseProvider
29
+
30
+ logger = logging.getLogger("opencomputer.agent.compaction")
31
+
32
+
33
+ #: Sensible per-model-family context windows. Compaction fires at 80% of these.
34
+ #: Keep conservative — better to compact early than hit a real-limit error.
35
+ DEFAULT_CONTEXT_WINDOWS: dict[str, int] = {
36
+ # Anthropic Claude 4.x models with extended context
37
+ "claude-opus-4-7": 200_000,
38
+ "claude-sonnet-4-6": 200_000,
39
+ "claude-haiku-4-5": 200_000,
40
+ # OpenAI GPT 5.x
41
+ "gpt-5.4": 400_000,
42
+ # Fallback
43
+ "_default": 200_000,
44
+ }
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class CompactionConfig:
49
+ preserve_recent: int = 20
50
+ threshold_ratio: float = 0.8
51
+ summarize_max_tokens: int = 1024
52
+ summarize_timeout_s: float = 30.0
53
+ #: Number of messages to drop on aux-LLM failure fallback.
54
+ fallback_drop_count: int = 10
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class CompactionResult:
59
+ messages: list[Message]
60
+ did_compact: bool = False
61
+ degraded: bool = False # True when aux LLM failed and we truncated instead
62
+ reason: str = ""
63
+
64
+
65
+ def context_window_for(model: str) -> int:
66
+ """Look up the context window for a model. Falls back to default."""
67
+ if model in DEFAULT_CONTEXT_WINDOWS:
68
+ return DEFAULT_CONTEXT_WINDOWS[model]
69
+ # Fuzzy family match
70
+ for key, v in DEFAULT_CONTEXT_WINDOWS.items():
71
+ if key != "_default" and model.startswith(key.split("-")[0]):
72
+ return v
73
+ return DEFAULT_CONTEXT_WINDOWS["_default"]
74
+
75
+
76
+ class CompactionEngine:
77
+ """Decide when to compact, and do it with safety rails."""
78
+
79
+ def __init__(
80
+ self,
81
+ provider: BaseProvider,
82
+ model: str,
83
+ config: CompactionConfig | None = None,
84
+ disabled: bool = False,
85
+ ) -> None:
86
+ self.provider = provider
87
+ self.model = model
88
+ self.config = config or CompactionConfig()
89
+ self.disabled = disabled
90
+ #: Flag the loop checks to suppress hook firing while compaction runs.
91
+ self._in_progress = False
92
+
93
+ @property
94
+ def in_progress(self) -> bool:
95
+ """True while compaction's own LLM call is in flight — hooks must not fire."""
96
+ return self._in_progress
97
+
98
+ def should_compact(self, last_input_tokens: int) -> bool:
99
+ """Use actual measured tokens, not an estimate."""
100
+ if self.disabled:
101
+ return False
102
+ window = context_window_for(self.model)
103
+ threshold = int(window * self.config.threshold_ratio)
104
+ return last_input_tokens >= threshold
105
+
106
+ async def maybe_run(
107
+ self, messages: list[Message], last_input_tokens: int
108
+ ) -> CompactionResult:
109
+ """Check the threshold; compact if needed; otherwise return unchanged."""
110
+ if not self.should_compact(last_input_tokens):
111
+ return CompactionResult(messages=messages, did_compact=False)
112
+
113
+ # Decide which messages to compact. Preserve:
114
+ # - System messages at the start
115
+ # - The last N messages untouched
116
+ recent_count = self.config.preserve_recent
117
+ if len(messages) <= recent_count + 1:
118
+ # Not enough old messages to bother — no-op
119
+ return CompactionResult(messages=messages, did_compact=False)
120
+
121
+ # Split at a SAFE boundary — must not split tool_use from tool_result.
122
+ split_idx = self._safe_split_index(messages, recent_count)
123
+ if split_idx <= 0:
124
+ return CompactionResult(messages=messages, did_compact=False)
125
+
126
+ old_block = messages[:split_idx]
127
+ recent_block = messages[split_idx:]
128
+
129
+ # Try the aux LLM summary
130
+ try:
131
+ summary_text = await asyncio.wait_for(
132
+ self._summarize(old_block), timeout=self.config.summarize_timeout_s
133
+ )
134
+ except Exception as e: # noqa: BLE001 — fall back on any failure
135
+ logger.warning("compaction aux LLM failed, falling back to truncate: %s", e)
136
+ return self._truncate_fallback(messages, split_idx)
137
+
138
+ # Success — replace old_block with one synthetic summary message
139
+ synthetic = Message(
140
+ role="assistant",
141
+ content=f"[compacted-summary]\n\n{summary_text}",
142
+ )
143
+ new_msgs = [synthetic, *recent_block]
144
+ return CompactionResult(messages=new_msgs, did_compact=True, reason="aux-summary")
145
+
146
+ # ─── internals ────────────────────────────────────────────────
147
+
148
+ def _safe_split_index(
149
+ self, messages: list[Message], preserve_recent: int
150
+ ) -> int:
151
+ """
152
+ Find a split point at `len(messages) - preserve_recent` that does NOT
153
+ break a tool_use / tool_result pair.
154
+
155
+ Walk backwards from the target index. If the candidate boundary has
156
+ a tool_result right after a tool_use, move earlier until we're between
157
+ a clean turn boundary.
158
+ """
159
+ if len(messages) <= preserve_recent:
160
+ return 0
161
+ target = len(messages) - preserve_recent
162
+
163
+ # Scan backward: if messages[target] is a tool result, move back until
164
+ # we land right BEFORE its originating assistant tool_use message
165
+ # (ideally at a user message or a clean assistant reply).
166
+ idx = target
167
+ while idx > 0:
168
+ msg = messages[idx]
169
+ prev = messages[idx - 1] if idx > 0 else None
170
+ # Unsafe: `idx` points to a tool result and prev is an assistant
171
+ # message containing tool_use blocks — splitting would orphan them.
172
+ prev_has_tool_use = (
173
+ prev is not None
174
+ and prev.role == "assistant"
175
+ and bool(prev.tool_calls)
176
+ )
177
+ if msg.role == "tool" or prev_has_tool_use:
178
+ idx -= 1
179
+ continue
180
+ break
181
+ return idx
182
+
183
+ async def _summarize(self, old_block: list[Message]) -> str:
184
+ """Call the provider to summarize. Hooks/injection must NOT fire here."""
185
+ self._in_progress = True
186
+ try:
187
+ # Keep the prompt simple. The provider returns a plain Message.
188
+ prompt = Message(
189
+ role="user",
190
+ content=(
191
+ "Summarize the following conversation history tightly. "
192
+ "Keep facts, decisions, file paths, and any commands run. "
193
+ "Output plain prose, no markdown headers. Target ~300 words."
194
+ ),
195
+ )
196
+ # Flatten history into text — providers need canonical messages.
197
+ synth_history = _flatten_for_summary(old_block)
198
+ resp = await self.provider.complete(
199
+ model=self.model,
200
+ messages=[Message(role="user", content=synth_history), prompt],
201
+ max_tokens=self.config.summarize_max_tokens,
202
+ temperature=0.3,
203
+ )
204
+ return resp.message.content or "[compaction returned empty]"
205
+ finally:
206
+ self._in_progress = False
207
+
208
+ def _truncate_fallback(
209
+ self, messages: list[Message], split_idx: int
210
+ ) -> CompactionResult:
211
+ """Degraded path: drop N oldest non-system messages."""
212
+ drop = min(self.config.fallback_drop_count, split_idx)
213
+ new_msgs = messages[drop:]
214
+ synthetic = Message(
215
+ role="assistant",
216
+ content=f"[compacted-truncated] — {drop} oldest messages removed due to compaction failure",
217
+ )
218
+ return CompactionResult(
219
+ messages=[synthetic, *new_msgs],
220
+ did_compact=True,
221
+ degraded=True,
222
+ reason="aux-failed-truncated",
223
+ )
224
+
225
+
226
+ def _flatten_for_summary(messages: list[Message]) -> str:
227
+ """Render a message list as plain text for the summarizer."""
228
+ parts: list[str] = []
229
+ for m in messages:
230
+ role = m.role.upper()
231
+ content = m.content or ""
232
+ if m.tool_calls:
233
+ tool_names = ", ".join(tc.name for tc in m.tool_calls)
234
+ content = (content + f"\n[called tools: {tool_names}]").strip()
235
+ parts.append(f"{role}: {content}")
236
+ return "\n\n".join(parts)
237
+
238
+
239
+ __all__ = [
240
+ "CompactionEngine",
241
+ "CompactionConfig",
242
+ "CompactionResult",
243
+ "context_window_for",
244
+ "DEFAULT_CONTEXT_WINDOWS",
245
+ ]
@@ -0,0 +1,108 @@
1
+ """
2
+ Typed configuration — replaces the 58-parameter __init__ nightmare.
3
+
4
+ All agent config lives in small, composable dataclasses. Load from
5
+ ~/.opencomputer/config.yaml (or TOML — TBD). Environment variables
6
+ can override individual fields.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ def _home() -> Path:
17
+ """Return ~/.opencomputer/, creating it if needed."""
18
+ home = Path(os.environ.get("OPENCOMPUTER_HOME", Path.home() / ".opencomputer"))
19
+ home.mkdir(parents=True, exist_ok=True)
20
+ return home
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ModelConfig:
25
+ """Which LLM to use and how."""
26
+
27
+ provider: str = "anthropic" # maps to a provider plugin name
28
+ model: str = "claude-opus-4-7"
29
+ max_tokens: int = 4096
30
+ temperature: float = 1.0
31
+ api_key_env: str = "ANTHROPIC_API_KEY"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class LoopConfig:
36
+ """Behavior of the main agent loop."""
37
+
38
+ max_iterations: int = 50
39
+ parallel_tools: bool = True
40
+ iteration_timeout_s: int = 600
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class SessionConfig:
45
+ """Where sessions are stored and how."""
46
+
47
+ db_path: Path = field(default_factory=lambda: _home() / "sessions.db")
48
+ session_id: str | None = None # None = create new session each run
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class MemoryConfig:
53
+ """The three-pillar memory configuration."""
54
+
55
+ declarative_path: Path = field(default_factory=lambda: _home() / "MEMORY.md")
56
+ skills_path: Path = field(default_factory=lambda: _home() / "skills")
57
+ # episodic memory uses SessionConfig.db_path
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class MCPServerConfig:
62
+ """One MCP server the agent should connect to."""
63
+
64
+ name: str = ""
65
+ transport: str = "stdio" # "stdio" or "http"
66
+ command: str = "" # for stdio: the executable (e.g. "python3")
67
+ args: tuple[str, ...] = () # for stdio: argv (use tuple for hashability)
68
+ url: str = "" # for http: endpoint URL
69
+ env: dict[str, str] = field(default_factory=dict) # optional env vars
70
+ enabled: bool = True
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class MCPConfig:
75
+ """MCP integration — list of servers + global toggles."""
76
+
77
+ servers: tuple[MCPServerConfig, ...] = ()
78
+ # Connect servers in the background after startup (kimi-cli pattern).
79
+ deferred: bool = True
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Config:
84
+ """Root configuration — composed of small focused configs."""
85
+
86
+ model: ModelConfig = field(default_factory=ModelConfig)
87
+ loop: LoopConfig = field(default_factory=LoopConfig)
88
+ session: SessionConfig = field(default_factory=SessionConfig)
89
+ memory: MemoryConfig = field(default_factory=MemoryConfig)
90
+ mcp: MCPConfig = field(default_factory=MCPConfig)
91
+ home: Path = field(default_factory=_home)
92
+
93
+
94
+ def default_config() -> Config:
95
+ """Return the default configuration with filesystem-appropriate paths."""
96
+ return Config()
97
+
98
+
99
+ __all__ = [
100
+ "Config",
101
+ "ModelConfig",
102
+ "LoopConfig",
103
+ "SessionConfig",
104
+ "MemoryConfig",
105
+ "MCPConfig",
106
+ "MCPServerConfig",
107
+ "default_config",
108
+ ]
@@ -0,0 +1,210 @@
1
+ """
2
+ Config persistence — load/save ~/.opencomputer/config.yaml.
3
+
4
+ Users can edit this file by hand or via `opencomputer config set key=value`.
5
+ Defaults from ModelConfig/LoopConfig/etc. apply if the file is missing
6
+ or if a given key isn't set.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import asdict, fields, is_dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ from opencomputer.agent.config import (
18
+ Config,
19
+ _home,
20
+ default_config,
21
+ )
22
+
23
+
24
+ def config_file_path() -> Path:
25
+ return _home() / "config.yaml"
26
+
27
+
28
+ # ─── load ────────────────────────────────────────────────────────
29
+
30
+
31
+ def _apply_overrides(base: Any, overrides: dict[str, Any]) -> Any:
32
+ """Recursively apply a dict over a dataclass, returning a new dataclass."""
33
+ if not is_dataclass(base) or not isinstance(overrides, dict):
34
+ return base
35
+ field_map = {f.name: f for f in fields(base)}
36
+ kwargs: dict[str, Any] = {}
37
+ for name, current in asdict(base).items():
38
+ if name in overrides:
39
+ new = overrides[name]
40
+ nested = getattr(base, name)
41
+
42
+ if is_dataclass(nested) and isinstance(new, dict):
43
+ # Nested dataclass (e.g. model, loop, mcp)
44
+ kwargs[name] = _apply_overrides(nested, new)
45
+ elif isinstance(nested, tuple) and isinstance(new, list):
46
+ # Tuple-of-dataclasses field (e.g. mcp.servers = [MCPServerConfig, ...])
47
+ inner_type = _extract_tuple_inner_type(type(base), name, nested)
48
+ if inner_type is not None:
49
+ built = []
50
+ for item in new:
51
+ if isinstance(item, dict):
52
+ # build a default instance then apply overrides
53
+ try:
54
+ default_instance = inner_type()
55
+ except TypeError:
56
+ default_instance = None
57
+ if default_instance is not None:
58
+ built.append(_apply_overrides(default_instance, item))
59
+ else:
60
+ built.append(item)
61
+ else:
62
+ built.append(item)
63
+ kwargs[name] = tuple(built)
64
+ else:
65
+ kwargs[name] = tuple(new)
66
+ else:
67
+ field_type = field_map[name].type
68
+ if "Path" in str(field_type) and isinstance(new, str):
69
+ kwargs[name] = Path(new)
70
+ else:
71
+ kwargs[name] = new
72
+ else:
73
+ kwargs[name] = getattr(base, name)
74
+ return type(base)(**kwargs)
75
+
76
+
77
+ def _extract_tuple_inner_type(
78
+ base_cls: type, field_name: str, existing_tuple: tuple
79
+ ) -> type | None:
80
+ """Best-effort: figure out the dataclass type stored in a tuple field.
81
+
82
+ Uses typing.get_type_hints so 'from __future__ import annotations'
83
+ string annotations are resolved to real types.
84
+ """
85
+ if existing_tuple and is_dataclass(existing_tuple[0]):
86
+ return type(existing_tuple[0])
87
+ import typing
88
+
89
+ try:
90
+ hints = typing.get_type_hints(base_cls)
91
+ except Exception:
92
+ return None
93
+ annotation = hints.get(field_name)
94
+ if annotation is None:
95
+ return None
96
+ origin = typing.get_origin(annotation)
97
+ if origin is tuple:
98
+ args = typing.get_args(annotation)
99
+ if args and is_dataclass(args[0]):
100
+ return args[0]
101
+ return None
102
+
103
+
104
+ def load_config(path: Path | None = None) -> Config:
105
+ """Load config from YAML, applying overrides on top of defaults.
106
+
107
+ Missing file or empty file → returns defaults. Invalid YAML is an error.
108
+ """
109
+ cfg_path = path or config_file_path()
110
+ base = default_config()
111
+ if not cfg_path.exists():
112
+ return base
113
+ try:
114
+ raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
115
+ except yaml.YAMLError as e:
116
+ raise RuntimeError(f"Failed to parse {cfg_path}: {e}") from e
117
+ if not isinstance(raw, dict):
118
+ raise RuntimeError(f"Config file {cfg_path} must be a YAML mapping")
119
+ return _apply_overrides(base, raw)
120
+
121
+
122
+ # ─── save ────────────────────────────────────────────────────────
123
+
124
+
125
+ def _to_yaml_dict(cfg: Config) -> dict[str, Any]:
126
+ """Convert a Config dataclass to a YAML-friendly dict (Paths as strings)."""
127
+
128
+ def _encode(v: Any) -> Any:
129
+ if isinstance(v, Path):
130
+ return str(v)
131
+ if is_dataclass(v):
132
+ return {k: _encode(getattr(v, k)) for k in [f.name for f in fields(v)]}
133
+ if isinstance(v, tuple):
134
+ return [_encode(item) for item in v]
135
+ return v
136
+
137
+ return {
138
+ "model": _encode(cfg.model),
139
+ "loop": _encode(cfg.loop),
140
+ "session": _encode(cfg.session),
141
+ "memory": _encode(cfg.memory),
142
+ "mcp": _encode(cfg.mcp),
143
+ }
144
+
145
+
146
+ def save_config(cfg: Config, path: Path | None = None) -> Path:
147
+ """Write config to YAML. Returns the path written."""
148
+ cfg_path = path or config_file_path()
149
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
150
+ data = _to_yaml_dict(cfg)
151
+ cfg_path.write_text(
152
+ yaml.safe_dump(data, default_flow_style=False, sort_keys=False),
153
+ encoding="utf-8",
154
+ )
155
+ return cfg_path
156
+
157
+
158
+ # ─── get / set by dotted key ────────────────────────────────────
159
+
160
+
161
+ def get_value(cfg: Config, key: str) -> Any:
162
+ """Get a value by dotted key like 'model.provider'."""
163
+ parts = key.split(".")
164
+ current: Any = cfg
165
+ for p in parts:
166
+ if is_dataclass(current):
167
+ if not hasattr(current, p):
168
+ raise KeyError(f"Unknown config key: {key} (failed at '{p}')")
169
+ current = getattr(current, p)
170
+ else:
171
+ raise KeyError(f"Unknown config key: {key} (not a config section at '{p}')")
172
+ return current
173
+
174
+
175
+ def set_value(cfg: Config, key: str, value: Any) -> Config:
176
+ """Return a NEW Config with `key` set to `value`. Dotted key supported."""
177
+ parts = key.split(".")
178
+ if len(parts) == 1:
179
+ raise KeyError("Top-level set not supported: use e.g. 'model.provider'")
180
+ section_name, *rest = parts
181
+ if not hasattr(cfg, section_name):
182
+ raise KeyError(f"Unknown section: {section_name}")
183
+
184
+ section = getattr(cfg, section_name)
185
+ if not is_dataclass(section):
186
+ raise KeyError(f"'{section_name}' is not a config section")
187
+
188
+ # Descend into nested sections (rare in this flat schema but future-proof)
189
+ section_overrides: dict[str, Any] = {}
190
+ cursor = section_overrides
191
+ for i, p in enumerate(rest):
192
+ if i == len(rest) - 1:
193
+ cursor[p] = value
194
+ else:
195
+ cursor[p] = {}
196
+ cursor = cursor[p]
197
+
198
+ new_section = _apply_overrides(section, section_overrides)
199
+ kwargs = {f.name: getattr(cfg, f.name) for f in fields(cfg)}
200
+ kwargs[section_name] = new_section
201
+ return Config(**kwargs)
202
+
203
+
204
+ __all__ = [
205
+ "config_file_path",
206
+ "load_config",
207
+ "save_config",
208
+ "get_value",
209
+ "set_value",
210
+ ]
@@ -0,0 +1,60 @@
1
+ """
2
+ InjectionEngine — queries all registered DynamicInjectionProviders per turn.
3
+
4
+ Deterministic ordering (priority asc, then provider_id asc) so repeated turns
5
+ with the same state produce the same system prompt. Critical for prompt cache
6
+ stability on the LLM side.
7
+
8
+ Providers register via `opencomputer.plugins.loader.PluginAPI.register_injection_provider`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from plugin_sdk.injection import DynamicInjectionProvider, InjectionContext
14
+
15
+
16
+ class InjectionEngine:
17
+ """Singleton registry + composer for injection providers."""
18
+
19
+ def __init__(self) -> None:
20
+ self._providers: dict[str, DynamicInjectionProvider] = {}
21
+
22
+ def register(self, provider: DynamicInjectionProvider) -> None:
23
+ pid = provider.provider_id
24
+ if pid in self._providers:
25
+ raise ValueError(f"Injection provider '{pid}' already registered")
26
+ self._providers[pid] = provider
27
+
28
+ def unregister(self, provider_id: str) -> None:
29
+ self._providers.pop(provider_id, None)
30
+
31
+ def providers(self) -> list[DynamicInjectionProvider]:
32
+ return list(self._providers.values())
33
+
34
+ def collect(self, ctx: InjectionContext) -> list[str]:
35
+ """Call each provider, return non-empty injections in deterministic order."""
36
+ # Deterministic: sort by (priority, provider_id). Same inputs → same output.
37
+ ordered = sorted(
38
+ self._providers.values(),
39
+ key=lambda p: (p.priority, p.provider_id),
40
+ )
41
+ out: list[str] = []
42
+ for p in ordered:
43
+ try:
44
+ text = p.collect(ctx)
45
+ except Exception: # noqa: BLE001 — providers never break the loop
46
+ continue
47
+ if text and text.strip():
48
+ out.append(text.strip())
49
+ return out
50
+
51
+ def compose(self, ctx: InjectionContext, separator: str = "\n\n") -> str:
52
+ """Convenience: collect() + join."""
53
+ return separator.join(self.collect(ctx))
54
+
55
+
56
+ #: Global singleton (matches tool_registry pattern)
57
+ engine = InjectionEngine()
58
+
59
+
60
+ __all__ = ["InjectionEngine", "engine"]