@aerovato/operator-helper 1.0.1 → 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 +11 -5
- package/dist/code-puppy/register_callbacks.py +371 -0
- package/dist/index.js +15192 -2560
- package/dist/index.js.map +48 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,27 +18,33 @@ Or with Bun:
|
|
|
18
18
|
bun add --global --minimum-release-age 0 @aerovato/operator-helper@latest
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
Install
|
|
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
|
|
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
|
|
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
|
|
|
35
|
-
The direct user-facing Helper
|
|
39
|
+
The direct user-facing Helper operation is adapter installation:
|
|
36
40
|
|
|
37
41
|
```sh
|
|
38
42
|
operator-helper install opencode
|
|
39
|
-
operator-helper
|
|
43
|
+
operator-helper install code-puppy
|
|
40
44
|
```
|
|
41
45
|
|
|
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.
|
|
47
|
+
|
|
42
48
|
## Documentation
|
|
43
49
|
|
|
44
50
|
- [Overview and setup](https://github.com/aerovato/operator-memory#readme)
|
|
@@ -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()
|