laive-mcp 0.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.
Files changed (67) hide show
  1. package/AGENTS.md +48 -0
  2. package/CHANGELOG.md +13 -0
  3. package/LICENSE +674 -0
  4. package/README.md +219 -0
  5. package/bin/laive.mjs +340 -0
  6. package/package.json +66 -0
  7. package/packages/als-parser/src/index.js +2 -0
  8. package/packages/als-parser/src/read.js +16 -0
  9. package/packages/als-parser/src/summarize.js +116 -0
  10. package/packages/common/src/index.js +3 -0
  11. package/packages/common/src/jsonl.js +41 -0
  12. package/packages/common/src/protocol.js +94 -0
  13. package/packages/common/src/validation.js +121 -0
  14. package/packages/live-bridge-remote-script/README.md +22 -0
  15. package/packages/live-bridge-remote-script/python/laive/__init__.py +7 -0
  16. package/packages/live-bridge-remote-script/python/laive/control_surface.py +208 -0
  17. package/packages/live-bridge-remote-script/python/laive/fake_live.py +168 -0
  18. package/packages/live-bridge-remote-script/python/laive/listeners.py +46 -0
  19. package/packages/live-bridge-remote-script/python/laive/live_access.py +272 -0
  20. package/packages/live-bridge-remote-script/python/laive/protocol.py +87 -0
  21. package/packages/live-bridge-remote-script/python/laive/server.py +130 -0
  22. package/packages/live-bridge-remote-script/python/laive/task_queue.py +47 -0
  23. package/packages/live-bridge-remote-script/src/bridge/client.js +113 -0
  24. package/packages/live-bridge-remote-script/src/bridge/server.js +189 -0
  25. package/packages/live-bridge-remote-script/src/cli/client.js +75 -0
  26. package/packages/live-bridge-remote-script/src/cli/server.js +51 -0
  27. package/packages/live-bridge-remote-script/src/fixtures/default-live-set.json +113 -0
  28. package/packages/live-bridge-remote-script/src/index.js +3 -0
  29. package/packages/live-bridge-remote-script/src/runtime/fixture-runtime.js +356 -0
  30. package/packages/live-sidecar-m4l/README.md +45 -0
  31. package/packages/live-sidecar-m4l/device/laive-sidecar.amxd +0 -0
  32. package/packages/live-sidecar-m4l/project/code/laive-sidecar-node.js +149 -0
  33. package/packages/live-sidecar-m4l/project/data/laive-sidecar.manifest.json +8 -0
  34. package/packages/live-sidecar-m4l/project/laive-sidecar.maxproj +36 -0
  35. package/packages/live-sidecar-m4l/project/patchers/laive-sidecar.maxpat +172 -0
  36. package/packages/live-sidecar-m4l/src/contracts.js +35 -0
  37. package/packages/live-sidecar-m4l/src/index.js +19 -0
  38. package/packages/live-sidecar-m4l/src/install-sidecar-device.js +15 -0
  39. package/packages/live-sidecar-m4l/src/package-sidecar.js +5 -0
  40. package/packages/live-sidecar-m4l/src/project.js +132 -0
  41. package/packages/live-sidecar-m4l/src/runtime.js +96 -0
  42. package/packages/live-sidecar-m4l/src/workflows.js +95 -0
  43. package/packages/mcp-server/src/cli.js +113 -0
  44. package/packages/mcp-server/src/default-tools.js +253 -0
  45. package/packages/mcp-server/src/errors.js +24 -0
  46. package/packages/mcp-server/src/index.js +10 -0
  47. package/packages/mcp-server/src/server.js +96 -0
  48. package/packages/mcp-server/src/session.js +475 -0
  49. package/packages/mcp-server/src/tool-registry.js +41 -0
  50. package/packages/state-engine/src/engine.js +566 -0
  51. package/packages/state-engine/src/ids.js +57 -0
  52. package/packages/state-engine/src/index.js +40 -0
  53. package/packages/state-engine/src/normalize.js +357 -0
  54. package/packages/state-engine/src/queries.js +154 -0
  55. package/packages/state-engine/src/replay.js +60 -0
  56. package/packages/ui-automation/src/executor.js +87 -0
  57. package/packages/ui-automation/src/guards.js +21 -0
  58. package/packages/ui-automation/src/helper.js +186 -0
  59. package/packages/ui-automation/src/index.js +20 -0
  60. package/packages/ui-automation/src/macos.js +82 -0
  61. package/packages/ui-automation/src/package-ui-helper.js +5 -0
  62. package/packages/ui-automation/src/workflows.js +72 -0
  63. package/scripts/install-remote-script.py +7 -0
  64. package/scripts/install-ui-helper.mjs +14 -0
  65. package/scripts/package-remote-script.py +7 -0
  66. package/scripts/package-ui-helper.mjs +4 -0
  67. package/scripts/remote_script_tooling.py +253 -0
@@ -0,0 +1,168 @@
1
+ from __future__ import absolute_import, print_function, unicode_literals
2
+
3
+
4
+ class ListenerMixin(object):
5
+ def __init__(self):
6
+ self._listeners = {}
7
+
8
+ def _add_listener(self, name, callback):
9
+ self._listeners.setdefault(name, []).append(callback)
10
+
11
+ def _remove_listener(self, name, callback):
12
+ self._listeners[name] = [item for item in self._listeners.get(name, []) if item != callback]
13
+
14
+ def _notify(self, name):
15
+ for callback in list(self._listeners.get(name, [])):
16
+ callback()
17
+
18
+ def __getattr__(self, item):
19
+ if item.startswith("add_") and item.endswith("_listener"):
20
+ name = item[4:-9]
21
+ return lambda callback: self._add_listener(name, callback)
22
+ if item.startswith("remove_") and item.endswith("_listener"):
23
+ name = item[7:-9]
24
+ return lambda callback: self._remove_listener(name, callback)
25
+ raise AttributeError(item)
26
+
27
+
28
+ class FakeParameter(object):
29
+ def __init__(self, name, value, minimum=0.0, maximum=1.0):
30
+ self.name = name
31
+ self._value = value
32
+ self.min = minimum
33
+ self.max = maximum
34
+ self.display_value = str(value)
35
+
36
+ @property
37
+ def value(self):
38
+ return self._value
39
+
40
+ @value.setter
41
+ def value(self, next_value):
42
+ self._value = next_value
43
+ self.display_value = str(next_value)
44
+
45
+
46
+ class FakeDevice(object):
47
+ def __init__(self, name, parameters):
48
+ self.name = name
49
+ self.class_name = name
50
+ self.parameters = parameters
51
+
52
+
53
+ class FakeClip(object):
54
+ def __init__(self, name="Clip 1", length=4):
55
+ self.name = name
56
+ self.length = length
57
+ self.is_playing = False
58
+ self.notes = []
59
+
60
+ def add_new_notes(self, notes):
61
+ self.notes.extend(notes)
62
+
63
+
64
+ class FakeClipSlot(object):
65
+ def __init__(self):
66
+ self.clip = None
67
+
68
+ @property
69
+ def has_clip(self):
70
+ return self.clip is not None
71
+
72
+ def create_clip(self, length):
73
+ self.clip = FakeClip(length=length)
74
+
75
+ def preview_clip(self, length_beats, name=None):
76
+ preview = FakeClip(name=name or "Preview Clip", length=length_beats)
77
+ return preview
78
+
79
+
80
+ class FakeTrack(object):
81
+ def __init__(self, name):
82
+ self.name = name
83
+ self.type = "midi"
84
+ self.arm = False
85
+ self.mute = False
86
+ self.solo = False
87
+ self.clip_slots = [FakeClipSlot() for _ in range(4)]
88
+ self.devices = [FakeDevice("Instrument", [FakeParameter("Macro 1", 0.5)])]
89
+
90
+
91
+ class FakeScene(object):
92
+ def __init__(self, name):
93
+ self.name = name
94
+
95
+
96
+ class FakeSong(ListenerMixin):
97
+ def __init__(self):
98
+ super(FakeSong, self).__init__()
99
+ self.name = "Fake Set"
100
+ self.live_version = "12.1.10"
101
+ self.signature_numerator = 4
102
+ self.signature_denominator = 4
103
+ self._tempo = 124.0
104
+ self._is_playing = False
105
+ self.is_recording = False
106
+ self.metronome = False
107
+ self.tracks = [FakeTrack("Drums"), FakeTrack("Bass")]
108
+ self.scenes = [FakeScene("Intro"), FakeScene("Drop")]
109
+
110
+ @property
111
+ def tempo(self):
112
+ return self._tempo
113
+
114
+ @tempo.setter
115
+ def tempo(self, next_value):
116
+ self._tempo = next_value
117
+ self._notify("tempo")
118
+
119
+ @property
120
+ def is_playing(self):
121
+ return self._is_playing
122
+
123
+ @is_playing.setter
124
+ def is_playing(self, next_value):
125
+ self._is_playing = bool(next_value)
126
+ self._notify("is_playing")
127
+
128
+ def start_playing(self):
129
+ self.is_playing = True
130
+
131
+ def stop_playing(self):
132
+ self.is_playing = False
133
+
134
+ def create_midi_track(self, index):
135
+ self.tracks.insert(index, FakeTrack("Track {0}".format(index + 1)))
136
+ self._notify("tracks")
137
+
138
+ def create_scene(self, index):
139
+ self.scenes.insert(index, FakeScene("Scene {0}".format(index + 1)))
140
+ self._notify("scenes")
141
+
142
+ def preview_track(self, index, name=None):
143
+ track = FakeTrack(name or "Track {0}".format(index + 1))
144
+ return track
145
+
146
+ def preview_scene(self, index, name=None):
147
+ return FakeScene(name or "Scene {0}".format(index + 1))
148
+
149
+
150
+ class FakeCInstance(object):
151
+ def __init__(self, song=None):
152
+ self._song = song or FakeSong()
153
+ self.scheduled = []
154
+ self.logged = []
155
+ self.messages = []
156
+
157
+ def song(self):
158
+ return self._song
159
+
160
+ def schedule_message(self, _delay, callback):
161
+ self.scheduled.append(callback)
162
+ callback()
163
+
164
+ def log_message(self, message):
165
+ self.logged.append(message)
166
+
167
+ def show_message(self, message):
168
+ self.messages.append(message)
@@ -0,0 +1,46 @@
1
+ from __future__ import absolute_import, print_function, unicode_literals
2
+
3
+
4
+ def _safe_call(logger, message):
5
+ if logger:
6
+ logger(message)
7
+
8
+
9
+ class ListenerHub(object):
10
+ def __init__(self, live_access, event_sink, logger=None):
11
+ self._live_access = live_access
12
+ self._song = live_access.song
13
+ self._event_sink = event_sink
14
+ self._logger = logger
15
+ self._detach_steps = []
16
+
17
+ def attach(self):
18
+ self._attach_listener(self._song, "tempo", self._emit_transport_changed)
19
+ self._attach_listener(self._song, "is_playing", self._emit_transport_changed)
20
+ self._attach_listener(self._song, "tracks", self._emit_tracks_changed)
21
+ self._attach_listener(self._song, "scenes", self._emit_scenes_changed)
22
+
23
+ def detach(self):
24
+ while self._detach_steps:
25
+ callback = self._detach_steps.pop()
26
+ try:
27
+ callback()
28
+ except Exception as error: # pragma: no cover - defensive cleanup
29
+ _safe_call(self._logger, "Listener detach failed: {0}".format(error))
30
+
31
+ def _attach_listener(self, subject, name, callback):
32
+ add_method = getattr(subject, "add_{0}_listener".format(name), None)
33
+ remove_method = getattr(subject, "remove_{0}_listener".format(name), None)
34
+ if not add_method or not remove_method:
35
+ return
36
+ add_method(callback)
37
+ self._detach_steps.append(lambda: remove_method(callback))
38
+
39
+ def _emit_transport_changed(self):
40
+ self._event_sink("transport.changed", self._live_access.get_song_state())
41
+
42
+ def _emit_tracks_changed(self):
43
+ self._event_sink("tracks.changed", {"tracks": self._live_access.get_tracks()})
44
+
45
+ def _emit_scenes_changed(self):
46
+ self._event_sink("state.changed", {"scenes": self._live_access.get_scenes()})
@@ -0,0 +1,272 @@
1
+ from __future__ import absolute_import, print_function, unicode_literals
2
+
3
+ from .protocol import RequestError
4
+
5
+
6
+ def _track_id(index):
7
+ return "track:{0}".format(index + 1)
8
+
9
+
10
+ def _scene_id(index):
11
+ return "scene:{0}".format(index + 1)
12
+
13
+
14
+ def _clip_id(track_id, slot_index):
15
+ return "clip:session:{0}:slot:{1}".format(track_id, slot_index + 1)
16
+
17
+
18
+ def _device_id(track_id, device_index):
19
+ return "device:{0}:{1}".format(track_id, device_index + 1)
20
+
21
+
22
+ def _parameter_id(device_id, parameter_index):
23
+ return "parameter:{0}:{1}".format(device_id, parameter_index + 1)
24
+
25
+
26
+ class LiveSetAdapter(object):
27
+ def __init__(self, song):
28
+ self.song = song
29
+
30
+ @property
31
+ def live_version(self):
32
+ return getattr(self.song, "live_version", "unknown")
33
+
34
+ def capabilities(self):
35
+ return {
36
+ "read_state": True,
37
+ "set_transport": True,
38
+ "create_track": hasattr(self.song, "create_midi_track"),
39
+ "create_scene": hasattr(self.song, "create_scene"),
40
+ "create_clip": True,
41
+ "insert_notes": True,
42
+ "set_parameter": True,
43
+ "subscribe": True,
44
+ }
45
+
46
+ def get_song_state(self):
47
+ return {
48
+ "id": "song:current",
49
+ "name": getattr(self.song, "name", "Untitled Set"),
50
+ "tempo": getattr(self.song, "tempo", None),
51
+ "time_signature_numerator": getattr(self.song, "signature_numerator", None),
52
+ "time_signature_denominator": getattr(self.song, "signature_denominator", None),
53
+ "is_playing": bool(getattr(self.song, "is_playing", False)),
54
+ "is_recording": bool(getattr(self.song, "is_recording", False)),
55
+ "metronome": bool(getattr(self.song, "metronome", False)),
56
+ }
57
+
58
+ def get_tracks(self):
59
+ return [self._serialize_track(track, index) for index, track in enumerate(getattr(self.song, "tracks", []))]
60
+
61
+ def get_scenes(self):
62
+ return [self._serialize_scene(scene, index) for index, scene in enumerate(getattr(self.song, "scenes", []))]
63
+
64
+ def get_track(self, track_id):
65
+ track, index = self._find_track(track_id)
66
+ return self._serialize_track(track, index)
67
+
68
+ def get_clip(self, clip_id):
69
+ clip, track_id, slot_index = self._find_clip(clip_id)
70
+ return self._serialize_clip(clip, track_id, slot_index)
71
+
72
+ def get_device(self, device_id):
73
+ device, track_id, device_index = self._find_device(device_id)
74
+ return self._serialize_device(device, track_id, device_index)
75
+
76
+ def get_parameter(self, parameter_id):
77
+ parameter, device_id, parameter_index = self._find_parameter(parameter_id)
78
+ return self._serialize_parameter(parameter, device_id, parameter_index)
79
+
80
+ def set_tempo(self, value, dry_run=False):
81
+ tempo = float(value)
82
+ if tempo <= 0:
83
+ raise RequestError("invalid_argument", "tempo must be positive")
84
+ if not dry_run:
85
+ self.song.tempo = tempo
86
+ return {"target": "song.tempo", "applied": not dry_run, "value": tempo}
87
+
88
+ def set_parameter(self, parameter_id, value, dry_run=False):
89
+ parameter, device_id, parameter_index = self._find_parameter(parameter_id)
90
+ next_value = float(value)
91
+ if not dry_run:
92
+ parameter.value = next_value
93
+ return {
94
+ "target": parameter_id,
95
+ "applied": not dry_run,
96
+ "parameter": self._serialize_parameter(parameter, device_id, parameter_index),
97
+ }
98
+
99
+ def play(self, dry_run=False):
100
+ if not dry_run:
101
+ self.song.start_playing()
102
+ return {"target": "transport.play", "applied": not dry_run, "is_playing": True}
103
+
104
+ def stop(self, dry_run=False):
105
+ if not dry_run:
106
+ self.song.stop_playing()
107
+ return {"target": "transport.stop", "applied": not dry_run, "is_playing": False}
108
+
109
+ def create_track(self, kind="midi", name=None, dry_run=False):
110
+ if kind != "midi":
111
+ raise RequestError("unsupported_argument", "Only midi tracks are supported in the first pass")
112
+ index = len(getattr(self.song, "tracks", []))
113
+ if dry_run:
114
+ track = self.song.preview_track(index=index, name=name)
115
+ else:
116
+ self.song.create_midi_track(index)
117
+ track = self.song.tracks[index]
118
+ if name:
119
+ track.name = name
120
+ return {"applied": not dry_run, "track": self._serialize_track(track, index)}
121
+
122
+ def create_scene(self, name=None, dry_run=False):
123
+ index = len(getattr(self.song, "scenes", []))
124
+ if dry_run:
125
+ scene = self.song.preview_scene(index=index, name=name)
126
+ else:
127
+ self.song.create_scene(index)
128
+ scene = self.song.scenes[index]
129
+ if name:
130
+ scene.name = name
131
+ return {"applied": not dry_run, "scene": self._serialize_scene(scene, index)}
132
+
133
+ def create_clip(self, track_id, slot_index, length_beats=4, name=None, dry_run=False):
134
+ track, _track_index = self._find_track(track_id)
135
+ slot = self._find_clip_slot(track, slot_index)
136
+ if dry_run:
137
+ clip = slot.preview_clip(length_beats=length_beats, name=name)
138
+ else:
139
+ slot.create_clip(length_beats)
140
+ clip = slot.clip
141
+ if name:
142
+ clip.name = name
143
+ return {"applied": not dry_run, "clip": self._serialize_clip(clip, track_id, slot_index)}
144
+
145
+ def insert_notes(self, clip_id, notes, dry_run=False):
146
+ clip, track_id, slot_index = self._find_clip(clip_id)
147
+ if not dry_run:
148
+ if hasattr(clip, "add_new_notes"):
149
+ clip.add_new_notes(notes)
150
+ elif hasattr(clip, "set_notes"):
151
+ clip.set_notes(tuple(self._tuple_note(note) for note in notes))
152
+ else:
153
+ clip.notes.extend(notes)
154
+ note_count = len(notes)
155
+ clip_state = self._serialize_clip(clip, track_id, slot_index)
156
+ clip_state["note_count"] = len(getattr(clip, "notes", [])) if not dry_run else note_count
157
+ return {"applied": not dry_run, "clip": clip_state, "note_count": note_count}
158
+
159
+ def _serialize_track(self, track, index):
160
+ track_id = getattr(track, "id", None) or _track_id(index)
161
+ clip_slots = getattr(track, "clip_slots", [])
162
+ devices = getattr(track, "devices", [])
163
+ return {
164
+ "id": track_id,
165
+ "index": index,
166
+ "name": getattr(track, "name", "Track {0}".format(index + 1)),
167
+ "type": getattr(track, "type", "midi"),
168
+ "arm": bool(getattr(track, "arm", False)),
169
+ "mute": bool(getattr(track, "mute", False)),
170
+ "solo": bool(getattr(track, "solo", False)),
171
+ "session_clips": [
172
+ self._serialize_clip(slot.clip, track_id, slot_index)
173
+ for slot_index, slot in enumerate(clip_slots)
174
+ if getattr(slot, "has_clip", False)
175
+ ],
176
+ "devices": [self._serialize_device(device, track_id, device_index) for device_index, device in enumerate(devices)],
177
+ }
178
+
179
+ def _serialize_scene(self, scene, index):
180
+ return {
181
+ "id": getattr(scene, "id", None) or _scene_id(index),
182
+ "index": index,
183
+ "name": getattr(scene, "name", "Scene {0}".format(index + 1)),
184
+ }
185
+
186
+ def _serialize_clip(self, clip, track_id, slot_index):
187
+ return {
188
+ "id": getattr(clip, "id", None) or _clip_id(track_id, slot_index),
189
+ "slot_index": slot_index,
190
+ "name": getattr(clip, "name", "Clip {0}".format(slot_index + 1)),
191
+ "length_beats": getattr(clip, "length", None),
192
+ "is_playing": bool(getattr(clip, "is_playing", False)),
193
+ "notes": list(getattr(clip, "notes", [])),
194
+ }
195
+
196
+ def _serialize_device(self, device, track_id, device_index):
197
+ device_id = getattr(device, "id", None) or _device_id(track_id, device_index)
198
+ return {
199
+ "id": device_id,
200
+ "name": getattr(device, "name", "Device {0}".format(device_index + 1)),
201
+ "class_name": getattr(device, "class_name", "Device"),
202
+ "parameters": [
203
+ self._serialize_parameter(parameter, device_id, parameter_index)
204
+ for parameter_index, parameter in enumerate(getattr(device, "parameters", []))
205
+ ],
206
+ }
207
+
208
+ def _serialize_parameter(self, parameter, device_id, parameter_index):
209
+ return {
210
+ "id": getattr(parameter, "id", None) or _parameter_id(device_id, parameter_index),
211
+ "name": getattr(parameter, "name", "Parameter {0}".format(parameter_index + 1)),
212
+ "value": getattr(parameter, "value", None),
213
+ "min": getattr(parameter, "min", 0.0),
214
+ "max": getattr(parameter, "max", 1.0),
215
+ "display_value": getattr(parameter, "display_value", str(getattr(parameter, "value", ""))),
216
+ }
217
+
218
+ def _find_track(self, track_id):
219
+ for index, track in enumerate(getattr(self.song, "tracks", [])):
220
+ candidate = getattr(track, "id", None) or _track_id(index)
221
+ if candidate == track_id:
222
+ return track, index
223
+ raise RequestError("not_found", "Track not found: {0}".format(track_id))
224
+
225
+ def _find_clip_slot(self, track, slot_index):
226
+ if slot_index is None:
227
+ raise RequestError("invalid_argument", "slot_index is required")
228
+ clip_slots = getattr(track, "clip_slots", [])
229
+ if slot_index < 0 or slot_index >= len(clip_slots):
230
+ raise RequestError("not_found", "Clip slot not found: {0}".format(slot_index))
231
+ return clip_slots[slot_index]
232
+
233
+ def _find_clip(self, clip_id):
234
+ for track_index, track in enumerate(getattr(self.song, "tracks", [])):
235
+ track_id = getattr(track, "id", None) or _track_id(track_index)
236
+ for slot_index, slot in enumerate(getattr(track, "clip_slots", [])):
237
+ if not getattr(slot, "has_clip", False):
238
+ continue
239
+ current_clip = slot.clip
240
+ candidate = getattr(current_clip, "id", None) or _clip_id(track_id, slot_index)
241
+ if candidate == clip_id:
242
+ return current_clip, track_id, slot_index
243
+ raise RequestError("not_found", "Clip not found: {0}".format(clip_id))
244
+
245
+ def _find_device(self, device_id):
246
+ for track_index, track in enumerate(getattr(self.song, "tracks", [])):
247
+ track_id = getattr(track, "id", None) or _track_id(track_index)
248
+ for device_index, device in enumerate(getattr(track, "devices", [])):
249
+ candidate = getattr(device, "id", None) or _device_id(track_id, device_index)
250
+ if candidate == device_id:
251
+ return device, track_id, device_index
252
+ raise RequestError("not_found", "Device not found: {0}".format(device_id))
253
+
254
+ def _find_parameter(self, parameter_id):
255
+ for track_index, track in enumerate(getattr(self.song, "tracks", [])):
256
+ track_id = getattr(track, "id", None) or _track_id(track_index)
257
+ for device_index, device in enumerate(getattr(track, "devices", [])):
258
+ device_id = getattr(device, "id", None) or _device_id(track_id, device_index)
259
+ for parameter_index, parameter in enumerate(getattr(device, "parameters", [])):
260
+ candidate = getattr(parameter, "id", None) or _parameter_id(device_id, parameter_index)
261
+ if candidate == parameter_id:
262
+ return parameter, device_id, parameter_index
263
+ raise RequestError("not_found", "Parameter not found: {0}".format(parameter_id))
264
+
265
+ def _tuple_note(self, note):
266
+ return (
267
+ note.get("pitch", 60),
268
+ note.get("start_beats", 0.0),
269
+ note.get("duration_beats", 0.25),
270
+ note.get("velocity", 100),
271
+ bool(note.get("mute", False)),
272
+ )
@@ -0,0 +1,87 @@
1
+ from __future__ import absolute_import, print_function, unicode_literals
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ PROTOCOL_VERSION = "0.1.0"
9
+
10
+
11
+ class RequestError(Exception):
12
+ def __init__(self, code, message):
13
+ super(RequestError, self).__init__(message)
14
+ self.code = code
15
+
16
+
17
+ def create_request(operation, target=None, arguments=None, dry_run=False, client_id="laive-client", request_id=None):
18
+ return {
19
+ "type": "request",
20
+ "request_id": request_id or str(uuid.uuid4()),
21
+ "timestamp": iso_now(),
22
+ "client_id": client_id,
23
+ "operation": operation,
24
+ "target": target,
25
+ "arguments": arguments or {},
26
+ "dry_run": bool(dry_run),
27
+ }
28
+
29
+
30
+ def make_response(request_id, ok=True, result=None, error_code=None, error_message=None, live_version=None):
31
+ return {
32
+ "type": "response",
33
+ "request_id": request_id,
34
+ "timestamp": iso_now(),
35
+ "ok": bool(ok),
36
+ "result": result,
37
+ "error_code": error_code,
38
+ "error_message": error_message,
39
+ "bridge_version": PROTOCOL_VERSION,
40
+ "live_version": live_version,
41
+ }
42
+
43
+
44
+ def make_error_response(request_id, error_code, error_message, live_version=None):
45
+ return make_response(
46
+ request_id=request_id,
47
+ ok=False,
48
+ result=None,
49
+ error_code=error_code,
50
+ error_message=error_message,
51
+ live_version=live_version,
52
+ )
53
+
54
+
55
+ def make_event(topic, payload=None):
56
+ return {
57
+ "type": "event",
58
+ "topic": topic,
59
+ "payload": payload or {},
60
+ "timestamp": iso_now(),
61
+ }
62
+
63
+
64
+ def encode_json_line(payload):
65
+ return json.dumps(payload, separators=(",", ":")) + "\n"
66
+
67
+
68
+ class JsonLineParser(object):
69
+ def __init__(self):
70
+ self._buffer = ""
71
+
72
+ def push(self, chunk):
73
+ if isinstance(chunk, bytes):
74
+ chunk = chunk.decode("utf-8")
75
+ self._buffer += chunk
76
+ messages = []
77
+ while "\n" in self._buffer:
78
+ line, self._buffer = self._buffer.split("\n", 1)
79
+ line = line.strip()
80
+ if not line:
81
+ continue
82
+ messages.append(json.loads(line))
83
+ return messages
84
+
85
+
86
+ def iso_now():
87
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")