@aerovato/operator-helper 1.0.2 → 1.1.0

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.
package/README.md CHANGED
@@ -18,17 +18,21 @@ Or with Bun:
18
18
  bun add --global --minimum-release-age 0 @aerovato/operator-helper@latest
19
19
  ```
20
20
 
21
- Install the OpenCode adapter:
21
+ Install an adapter:
22
22
 
23
23
  ```sh
24
+ # OpenCode
24
25
  operator-helper install opencode
26
+
27
+ # Code Puppy
28
+ operator-helper install code-puppy
25
29
  ```
26
30
 
27
- Restart OpenCode, then run `/operator:user-init` in a new conversation to begin setup.
31
+ Restart the harness, then run `/operator:user-init` in a new conversation to begin setup.
28
32
 
29
33
  ## Usage
30
34
 
31
- Users interact with Operator through the OpenCode commands `/operator:user-init`, `/operator:project-init`, `/operator:index`, and `/operator:repair`. These commands guide the working agent, which uses Operator Helper for setup, status, validation, and repair mechanics.
35
+ Users interact with Operator through the harness commands `/operator:user-init`, `/operator:project-init`, `/operator:index`, and `/operator:repair`. These commands guide the working agent, which uses Operator Helper for setup, status, validation, and repair mechanics.
32
36
 
33
37
  Run each Operator command in a new conversation so the agent can focus on that operation with a clean working context. Initialization preserves existing content. Project setup configures `.operator/` in the global Git ignore but does not automatically untrack files already committed.
34
38
 
@@ -36,6 +40,7 @@ The direct user-facing Helper operation is adapter installation:
36
40
 
37
41
  ```sh
38
42
  operator-helper install opencode
43
+ operator-helper install code-puppy
39
44
  ```
40
45
 
41
46
  Operator Helper automatically checks for and installs updates when commands run. If it cannot determine whether Bun or npm owns the global installation, it asks for a manual update using the original installation method.
@@ -0,0 +1,371 @@
1
+ """Operator Memory integration for Code Puppy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import subprocess
8
+ import threading
9
+ from collections.abc import AsyncGenerator
10
+ from contextlib import asynccontextmanager, suppress
11
+ from typing import Any
12
+
13
+ from code_puppy.callbacks import CustomCommandResult, register_callback
14
+ from code_puppy.config import get_current_session_name
15
+ from code_puppy.messaging import emit_warning
16
+ from code_puppy.tools.subagent_context import get_conversation_root_id
17
+ from pydantic_ai import ModelRequest, RunContext, UserPromptPart
18
+ from pydantic_ai.messages import ModelMessage, ModelResponse
19
+ from pydantic_ai.models import (
20
+ KnownModelName,
21
+ Model,
22
+ ModelRequestParameters,
23
+ StreamedResponse,
24
+ )
25
+ from pydantic_ai.models.wrapper import WrapperModel
26
+ from pydantic_ai.settings import ModelSettings
27
+
28
+ _MARKER_KEY = "operator_memory"
29
+ _MARKER_VALUE = "synthetic-preamble-v1"
30
+ _RENDER_TIMEOUT_SECONDS = 30
31
+ _COMMAND_TIMEOUT_SECONDS = 60
32
+ _render_tasks: dict[str, asyncio.Task[str]] = {}
33
+ _update_launched = False
34
+
35
+ _COMMANDS = {
36
+ "operator:user-init": (
37
+ "Initialize Operator User Instructions",
38
+ ("user init", "user guide"),
39
+ "Follow the instructions in the guide output above.",
40
+ ),
41
+ "operator:project-init": (
42
+ "Initialize Operator Project",
43
+ ("project init", "project guide"),
44
+ "Follow the instructions in the guide output above.",
45
+ ),
46
+ "operator:index": (
47
+ "Build or refresh the Operator Project Index",
48
+ ("index status", "index guide"),
49
+ "Follow the instructions in the guide output above.",
50
+ ),
51
+ "operator:repair": (
52
+ "Repair Operator",
53
+ ("memory check",),
54
+ "If the output says `No issues detected.`, no action is needed and you may stop. "
55
+ "Otherwise, repair only the reported Operator memory issues; do not initialize "
56
+ "uninitialized partitions. Rerun `operator-helper memory check` until it succeeds, "
57
+ "then read the applicable Operator memory before continuing.",
58
+ ),
59
+ }
60
+
61
+
62
+ class OperatorModel(WrapperModel):
63
+ """Inject an immutable Operator preamble into final model requests."""
64
+
65
+ def __init__(self, wrapped: Model[Any] | KnownModelName, conversation_key: str):
66
+ super().__init__(wrapped)
67
+ self._conversation_key = conversation_key
68
+
69
+ async def request(
70
+ self,
71
+ messages: list[ModelMessage],
72
+ model_settings: ModelSettings | None,
73
+ model_request_parameters: ModelRequestParameters,
74
+ ) -> ModelResponse:
75
+ transformed = await _inject_preamble(messages, self._conversation_key)
76
+ return await self.wrapped.request(
77
+ transformed, model_settings, model_request_parameters
78
+ )
79
+
80
+ @asynccontextmanager
81
+ async def request_stream(
82
+ self,
83
+ messages: list[ModelMessage],
84
+ model_settings: ModelSettings | None,
85
+ model_request_parameters: ModelRequestParameters,
86
+ run_context: RunContext[Any] | None = None,
87
+ ) -> AsyncGenerator[StreamedResponse]:
88
+ transformed = await _inject_preamble(messages, self._conversation_key)
89
+ async with self.wrapped.request_stream(
90
+ transformed, model_settings, model_request_parameters, run_context
91
+ ) as response:
92
+ yield response
93
+
94
+
95
+ async def _inject_preamble(
96
+ messages: list[ModelMessage], conversation_key: str
97
+ ) -> list[ModelMessage]:
98
+ preamble = await _get_preamble(conversation_key)
99
+ transformed = [message for message in messages if not _is_operator_request(message)]
100
+ transformed.insert(
101
+ 0,
102
+ ModelRequest(
103
+ parts=[UserPromptPart(content=preamble)],
104
+ metadata={_MARKER_KEY: _MARKER_VALUE},
105
+ ),
106
+ )
107
+ return transformed
108
+
109
+
110
+ def _is_operator_request(message: ModelMessage) -> bool:
111
+ return (
112
+ isinstance(message, ModelRequest)
113
+ and message.metadata is not None
114
+ and message.metadata.get(_MARKER_KEY) == _MARKER_VALUE
115
+ )
116
+
117
+
118
+ async def _get_preamble(conversation_key: str) -> str:
119
+ task = _render_tasks.get(conversation_key)
120
+ if task is None:
121
+ task = asyncio.create_task(_render_preamble(conversation_key))
122
+ _render_tasks[conversation_key] = task
123
+ return await asyncio.shield(task)
124
+
125
+
126
+ async def _render_preamble(conversation_key: str) -> str:
127
+ environment = os.environ.copy()
128
+ environment["OPERATOR_HELPER_SKIP_UPDATE"] = "1"
129
+ process: asyncio.subprocess.Process | None = None
130
+ try:
131
+ process = await asyncio.create_subprocess_exec(
132
+ "operator-helper",
133
+ "preamble",
134
+ stdout=asyncio.subprocess.PIPE,
135
+ stderr=asyncio.subprocess.PIPE,
136
+ env=environment,
137
+ )
138
+ stdout, stderr = await asyncio.wait_for(
139
+ process.communicate(), timeout=_RENDER_TIMEOUT_SECONDS
140
+ )
141
+ content = _remove_process_newline(stdout.decode(errors="replace"))
142
+ if process.returncode == 0 and content:
143
+ return content
144
+ detail = _remove_process_newline(stderr.decode(errors="replace"))
145
+ reason = detail or f"operator-helper exited with status {process.returncode}"
146
+ except asyncio.CancelledError:
147
+ if process is not None:
148
+ await _stop_process(process)
149
+ raise
150
+ except TimeoutError:
151
+ if process is not None:
152
+ await _stop_process(process)
153
+ reason = "operator-helper preamble timed out"
154
+ except OSError as error:
155
+ if process is not None:
156
+ await _stop_process(process)
157
+ reason = str(error)
158
+
159
+ emit_warning(
160
+ "Operator Memory could not render for this conversation. "
161
+ "Run `operator-helper install code-puppy` to repair the integration."
162
+ )
163
+ return (
164
+ "<operator-diagnostic>\n"
165
+ "Operator Memory could not render its preamble. Continue without Operator Memory, "
166
+ "help the user repair `operator-helper`, then ask them to start a new conversation.\n"
167
+ f"Failure: {reason}\n"
168
+ "</operator-diagnostic>"
169
+ )
170
+
171
+
172
+ async def _stop_process(process: asyncio.subprocess.Process) -> None:
173
+ with suppress(ProcessLookupError):
174
+ process.kill()
175
+ await process.wait()
176
+
177
+
178
+ def _remove_process_newline(value: str) -> str:
179
+ if value.endswith("\r\n"):
180
+ return value[:-2]
181
+ if value.endswith("\n"):
182
+ return value[:-1]
183
+ return value
184
+
185
+
186
+ def _conversation_key() -> str:
187
+ return get_conversation_root_id() or get_current_session_name()
188
+
189
+
190
+ @asynccontextmanager
191
+ async def _agent_run_context(
192
+ _agent: Any, pydantic_agent: Any, _group_id: str, _mcp_servers: Any
193
+ ) -> AsyncGenerator[None]:
194
+ model = pydantic_agent.model
195
+ if model is None:
196
+ yield
197
+ return
198
+ with pydantic_agent.override(model=OperatorModel(model, _conversation_key())):
199
+ yield
200
+
201
+
202
+ async def _session_end() -> None:
203
+ tasks = tuple(_render_tasks.values())
204
+ _render_tasks.clear()
205
+ for task in tasks:
206
+ task.cancel()
207
+ await asyncio.gather(*tasks, return_exceptions=True)
208
+
209
+
210
+ def _custom_command(_command: str, name: str) -> CustomCommandResult | None:
211
+ definition = _COMMANDS.get(name)
212
+ if definition is None:
213
+ return None
214
+
215
+ version = _run_helper(("version",))
216
+ if version[0] != 0:
217
+ diagnostic = (
218
+ "Operator Helper is unavailable. Help the user repair the missing "
219
+ "operator-helper command (npm: @aerovato/operator-helper). Validate the "
220
+ f"repair by rerunning `operator-helper version`. Once it succeeds, ask the "
221
+ f"user to rerun `/{name}`."
222
+ )
223
+ content = "\n\n".join(
224
+ (
225
+ _command_output("operator-helper version 2>&1", version[1]),
226
+ _tag("operator-diagnostic", diagnostic),
227
+ )
228
+ )
229
+ return CustomCommandResult(content)
230
+
231
+ _, operations, instructions = definition
232
+ outputs = []
233
+ for operation in operations:
234
+ arguments = tuple(operation.split())
235
+ result = _run_helper(arguments)
236
+ outputs.append(_command_output(f"operator-helper {operation} 2>&1", result[1]))
237
+ outputs.append(_tag("operator-instructions", instructions))
238
+ return CustomCommandResult("\n\n".join(outputs))
239
+
240
+
241
+ def _run_helper(arguments: tuple[str, ...]) -> tuple[int, str]:
242
+ try:
243
+ result = subprocess.run(
244
+ ("operator-helper", *arguments),
245
+ stdout=subprocess.PIPE,
246
+ stderr=subprocess.STDOUT,
247
+ text=True,
248
+ timeout=_COMMAND_TIMEOUT_SECONDS,
249
+ check=False,
250
+ )
251
+ return result.returncode, _remove_process_newline(result.stdout)
252
+ except (OSError, subprocess.TimeoutExpired) as error:
253
+ return 1, str(error)
254
+
255
+
256
+ def _command_output(command: str, output: str) -> str:
257
+ return (
258
+ "<operator-command>\n"
259
+ f"<command>{command}</command>\n"
260
+ "<output>\n"
261
+ f"{output}\n"
262
+ "</output>\n"
263
+ "</operator-command>"
264
+ )
265
+
266
+
267
+ def _tag(name: str, content: str) -> str:
268
+ return f"<{name}>\n{content}\n</{name}>"
269
+
270
+
271
+ def _custom_command_help() -> list[tuple[str, str]]:
272
+ return [(name, definition[0]) for name, definition in _COMMANDS.items()]
273
+
274
+
275
+ _UPDATE_NOTE = "Operator Updated · Restart Code Puppy to apply"
276
+ _update_note_pending = False
277
+
278
+
279
+ def _launch_update() -> None:
280
+ global _update_launched
281
+ if _update_launched:
282
+ return
283
+ _update_launched = True
284
+ try:
285
+ process = subprocess.Popen(
286
+ ("operator-helper", "install", "code-puppy"),
287
+ stdin=subprocess.DEVNULL,
288
+ stdout=subprocess.PIPE,
289
+ stderr=subprocess.DEVNULL,
290
+ text=True,
291
+ creationflags=(
292
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
293
+ if os.name == "nt"
294
+ else 0
295
+ ),
296
+ start_new_session=os.name != "nt",
297
+ )
298
+ except OSError:
299
+ return
300
+ threading.Thread(target=_monitor_update, args=(process,), daemon=True).start()
301
+
302
+
303
+ def _monitor_update(process: subprocess.Popen[str]) -> None:
304
+ global _update_note_pending
305
+ try:
306
+ output = process.communicate()[0] or ""
307
+ except Exception:
308
+ return
309
+ if process.returncode == 0 and "plugin updated" in output:
310
+ _update_note_pending = True
311
+ _paint_status_indicator(_UPDATE_NOTE)
312
+ _emit_update_notice()
313
+
314
+
315
+ def _emit_update_notice() -> None:
316
+ try:
317
+ from rich.text import Text
318
+
319
+ from code_puppy.messaging import emit_warning
320
+
321
+ notice = Text()
322
+ notice.append("Operator Updated · ", style="bold magenta")
323
+ notice.append("Restart Code Puppy to apply", style="magenta")
324
+ emit_warning(notice)
325
+ except Exception:
326
+ pass
327
+
328
+
329
+ _STARTUP_INDICATOR = "Operator Ready"
330
+ _STATUS_INDICATOR = " Operator Ready"
331
+ _active_runs = 0
332
+
333
+
334
+ def _paint_status_indicator(text: str) -> None:
335
+ try:
336
+ from code_puppy.messaging.bottom_bar import get_bottom_bar
337
+
338
+ get_bottom_bar().set_status_suffix(text)
339
+ except Exception:
340
+ pass
341
+
342
+
343
+ async def _show_status_indicator() -> None:
344
+ global _update_note_pending
345
+ if _update_note_pending:
346
+ _update_note_pending = False
347
+ return
348
+ _paint_status_indicator(_STARTUP_INDICATOR)
349
+
350
+
351
+ async def _agent_run_start(*_args: Any) -> None:
352
+ global _active_runs
353
+ _active_runs += 1
354
+ _paint_status_indicator(_STATUS_INDICATOR)
355
+
356
+
357
+ async def _agent_run_end(*_args: Any) -> None:
358
+ global _active_runs
359
+ _active_runs = max(0, _active_runs - 1)
360
+ if _active_runs == 0:
361
+ _paint_status_indicator("")
362
+
363
+
364
+ register_callback("agent_run_context", _agent_run_context)
365
+ register_callback("session_end", _session_end)
366
+ register_callback("custom_command", _custom_command)
367
+ register_callback("custom_command_help", _custom_command_help)
368
+ register_callback("startup", _show_status_indicator)
369
+ register_callback("agent_run_start", _agent_run_start)
370
+ register_callback("agent_run_end", _agent_run_end)
371
+ _launch_update()