astra-plugin-sdk 0.1.0__tar.gz

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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: astra-plugin-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for building Astra plugins
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/mihailinl/AstraPlugins
7
+ Project-URL: Repository, https://github.com/mihailinl/AstraPlugins/tree/master/astra-plugin-sdk-python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: grpcio>=1.60.0
11
+ Requires-Dist: grpcio-tools>=1.60.0
12
+ Requires-Dist: protobuf>=4.25.0
13
+
14
+ # Astra Plugin SDK (Python)
15
+
16
+ Build plugins for [Astra](https://github.com/astra-assistant) in Python.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install astra-plugin-sdk
22
+ ```
23
+
24
+ ## Quick Start
25
+
26
+ ```python
27
+ from astra_plugin_sdk import Plugin
28
+
29
+ class MyPlugin(Plugin):
30
+ async def list_tools(self):
31
+ return [{
32
+ "name": "hello",
33
+ "description": "Say hello",
34
+ "parameters_json": '{"type": "object", "properties": {}}',
35
+ }]
36
+
37
+ async def call_tool(self, name, arguments_json):
38
+ if name == "hello":
39
+ return {"success": True, "result": "Hello from the plugin!"}
40
+ return {"success": False, "error": f"Unknown tool: {name}"}
41
+
42
+ if __name__ == "__main__":
43
+ MyPlugin().run()
44
+ ```
45
+
46
+ ## Capabilities
47
+
48
+ Override the methods you need:
49
+
50
+ - **Tools**: `list_tools()`, `call_tool(name, args)`
51
+ - **TTS**: `tts_list_voices()`, `tts_synthesize(text, voice_id, speed, pitch)`
52
+ - **STT**: `stt_get_languages()`
53
+ - **AI Provider**: `ai_get_models()`
54
+ - **Actions**: `get_action_types()`, `execute_action(type, params)`
55
+ - **Triggers**: `get_trigger_types()`
56
+ - **Lifecycle**: `on_config_changed(config)`, `on_shutdown()`, `health_check()`
57
+
58
+ ## Host Client
59
+
60
+ Access daemon services from your plugin:
61
+
62
+ ```python
63
+ class MyPlugin(Plugin):
64
+ async def on_config_changed(self, config):
65
+ # Log to daemon
66
+ await self.host.log("info", f"Config updated: {config}")
67
+
68
+ # Fire a trigger
69
+ await self.host.fire_trigger("my_trigger", '{"key": "value"}')
70
+
71
+ # Get daemon info
72
+ info = await self.host.get_daemon_info()
73
+ print(f"Daemon version: {info.version}")
74
+ ```
@@ -0,0 +1,61 @@
1
+ # Astra Plugin SDK (Python)
2
+
3
+ Build plugins for [Astra](https://github.com/astra-assistant) in Python.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install astra-plugin-sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from astra_plugin_sdk import Plugin
15
+
16
+ class MyPlugin(Plugin):
17
+ async def list_tools(self):
18
+ return [{
19
+ "name": "hello",
20
+ "description": "Say hello",
21
+ "parameters_json": '{"type": "object", "properties": {}}',
22
+ }]
23
+
24
+ async def call_tool(self, name, arguments_json):
25
+ if name == "hello":
26
+ return {"success": True, "result": "Hello from the plugin!"}
27
+ return {"success": False, "error": f"Unknown tool: {name}"}
28
+
29
+ if __name__ == "__main__":
30
+ MyPlugin().run()
31
+ ```
32
+
33
+ ## Capabilities
34
+
35
+ Override the methods you need:
36
+
37
+ - **Tools**: `list_tools()`, `call_tool(name, args)`
38
+ - **TTS**: `tts_list_voices()`, `tts_synthesize(text, voice_id, speed, pitch)`
39
+ - **STT**: `stt_get_languages()`
40
+ - **AI Provider**: `ai_get_models()`
41
+ - **Actions**: `get_action_types()`, `execute_action(type, params)`
42
+ - **Triggers**: `get_trigger_types()`
43
+ - **Lifecycle**: `on_config_changed(config)`, `on_shutdown()`, `health_check()`
44
+
45
+ ## Host Client
46
+
47
+ Access daemon services from your plugin:
48
+
49
+ ```python
50
+ class MyPlugin(Plugin):
51
+ async def on_config_changed(self, config):
52
+ # Log to daemon
53
+ await self.host.log("info", f"Config updated: {config}")
54
+
55
+ # Fire a trigger
56
+ await self.host.fire_trigger("my_trigger", '{"key": "value"}')
57
+
58
+ # Get daemon info
59
+ info = await self.host.get_daemon_info()
60
+ print(f"Daemon version: {info.version}")
61
+ ```
@@ -0,0 +1,8 @@
1
+ """Astra Plugin SDK — build plugins for Astra in Python."""
2
+
3
+ from astra_plugin_sdk.plugin import Plugin
4
+ from astra_plugin_sdk.host_client import HostClient
5
+ from astra_plugin_sdk.decorators import tool, action, trigger, Field
6
+
7
+ __all__ = ["Plugin", "HostClient", "tool", "action", "trigger", "Field"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,318 @@
1
+ """Decorators and helpers for declarative plugin definitions.
2
+
3
+ Use ``@tool``, ``@action``, and ``@trigger`` to define capabilities with
4
+ minimal boilerplate. The ``Field`` class provides builder methods for
5
+ action/trigger field definitions.
6
+ """
7
+
8
+ import inspect
9
+ import json
10
+ import typing
11
+ from typing import Any, Literal, Optional, Union, get_type_hints
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Type-hint -> JSON Schema mapping
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _PY_TO_JSON_TYPE = {
19
+ str: "string",
20
+ int: "integer",
21
+ float: "number",
22
+ bool: "boolean",
23
+ list: "array",
24
+ dict: "object",
25
+ }
26
+
27
+
28
+ def _type_to_schema(hint: Any) -> dict:
29
+ """Convert a Python type hint to a JSON Schema fragment."""
30
+ # Plain types
31
+ if hint in _PY_TO_JSON_TYPE:
32
+ return {"type": _PY_TO_JSON_TYPE[hint]}
33
+
34
+ origin = typing.get_origin(hint)
35
+ args = typing.get_args(hint)
36
+
37
+ # Literal["a", "b"] -> enum
38
+ if origin is Literal:
39
+ return {"type": "string", "enum": list(args)}
40
+
41
+ # Optional[X] (Union[X, None])
42
+ if origin is Union:
43
+ non_none = [a for a in args if a is not type(None)]
44
+ if len(non_none) == 1:
45
+ return _type_to_schema(non_none[0])
46
+
47
+ # list[str] etc.
48
+ if origin is list:
49
+ schema: dict = {"type": "array"}
50
+ if args:
51
+ schema["items"] = _type_to_schema(args[0])
52
+ return schema
53
+
54
+ # Fallback
55
+ return {"type": "string"}
56
+
57
+
58
+ def _build_json_schema(fn: Any) -> str:
59
+ """Build a JSON Schema string from a function's type hints."""
60
+ try:
61
+ hints = get_type_hints(fn)
62
+ except Exception:
63
+ hints = {}
64
+
65
+ sig = inspect.signature(fn)
66
+ properties: dict[str, dict] = {}
67
+ required: list[str] = []
68
+
69
+ for name, param in sig.parameters.items():
70
+ if name == "self":
71
+ continue
72
+ hint = hints.get(name, str) # default to string
73
+ prop = _type_to_schema(hint)
74
+
75
+ # Use parameter name as description placeholder if no docstring parsing
76
+ properties[name] = prop
77
+
78
+ # Required if no default
79
+ if param.default is inspect.Parameter.empty:
80
+ origin = typing.get_origin(hint)
81
+ args = typing.get_args(hint)
82
+ is_optional = (
83
+ origin is Union
84
+ and type(None) in args
85
+ )
86
+ if not is_optional:
87
+ required.append(name)
88
+
89
+ schema = {"type": "object", "properties": properties}
90
+ if required:
91
+ schema["required"] = required
92
+ return json.dumps(schema)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Decorators
97
+ # ---------------------------------------------------------------------------
98
+
99
+ def tool(description: str):
100
+ """Mark a method as a plugin tool.
101
+
102
+ The decorated method's type hints are used to auto-generate JSON Schema
103
+ for the tool parameters. The return value is automatically wrapped in
104
+ ``{"success": True, "result": ...}`` by the SDK.
105
+
106
+ Example::
107
+
108
+ @tool("Count words in text")
109
+ async def word_count(self, text: str):
110
+ return {"words": len(text.split())}
111
+ """
112
+ def decorator(fn):
113
+ fn._astra_tool_meta = {
114
+ "name": fn.__name__,
115
+ "description": description,
116
+ "parameters_json": _build_json_schema(fn),
117
+ }
118
+ return fn
119
+ return decorator
120
+
121
+
122
+ def action(
123
+ label: str,
124
+ *,
125
+ icon_svg: str = "",
126
+ fields: list[dict] | None = None,
127
+ ai_available: bool = False,
128
+ ai_description: str = "",
129
+ ai_primary_field: str = "",
130
+ ):
131
+ """Mark a method as a plugin action type.
132
+
133
+ Example::
134
+
135
+ @action("Transform Text", fields=[
136
+ Field.dropdown("op", "Operation", options=["upper", "lower"]),
137
+ ])
138
+ async def transform_text(self, op: str, input_text: str):
139
+ ...
140
+ """
141
+ def decorator(fn):
142
+ fn._astra_action_meta = {
143
+ "type": fn.__name__,
144
+ "label": label,
145
+ "icon_svg": icon_svg,
146
+ "fields": fields or [],
147
+ "ai_available": ai_available,
148
+ "ai_description": ai_description,
149
+ "ai_primary_field": ai_primary_field,
150
+ }
151
+ return fn
152
+ return decorator
153
+
154
+
155
+ def trigger(
156
+ label: str,
157
+ *,
158
+ icon_svg: str = "",
159
+ fields: list[dict] | None = None,
160
+ ):
161
+ """Mark a method as a plugin trigger type definition.
162
+
163
+ The method itself is not called automatically — it just holds metadata.
164
+ Use ``self.fire_trigger(...)`` to fire the trigger from a background task.
165
+
166
+ Example::
167
+
168
+ @trigger("Scheduled Time", fields=[
169
+ Field.text("time", "Time", default="09:00", placeholder="HH:MM"),
170
+ ])
171
+ def on_time(self):
172
+ pass
173
+ """
174
+ def decorator(fn):
175
+ fn._astra_trigger_meta = {
176
+ "type": fn.__name__,
177
+ "label": label,
178
+ "icon_svg": icon_svg,
179
+ "fields": fields or [],
180
+ }
181
+ return fn
182
+ return decorator
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # Field builder
187
+ # ---------------------------------------------------------------------------
188
+
189
+ class Field:
190
+ """Builder for action/trigger field definitions.
191
+
192
+ Each static method returns a dict matching the proto ``FieldDefinitionMsg``
193
+ structure, ready to pass into ``@action(fields=[...])`` or
194
+ ``@trigger(fields=[...])``.
195
+ """
196
+
197
+ @staticmethod
198
+ def text(
199
+ id: str,
200
+ label: str,
201
+ *,
202
+ placeholder: str = "",
203
+ default: str = "",
204
+ description: str = "",
205
+ conditions: list[dict] | None = None,
206
+ ) -> dict:
207
+ return {
208
+ "id": id, "label": label, "field_type": "text",
209
+ "placeholder": placeholder, "default_value": default,
210
+ "description": description, "conditions": conditions or [],
211
+ }
212
+
213
+ @staticmethod
214
+ def textarea(
215
+ id: str,
216
+ label: str,
217
+ *,
218
+ placeholder: str = "",
219
+ default: str = "",
220
+ description: str = "",
221
+ conditions: list[dict] | None = None,
222
+ ) -> dict:
223
+ return {
224
+ "id": id, "label": label, "field_type": "textarea",
225
+ "placeholder": placeholder, "default_value": default,
226
+ "description": description, "conditions": conditions or [],
227
+ }
228
+
229
+ @staticmethod
230
+ def textarea_with_variables(
231
+ id: str,
232
+ label: str,
233
+ *,
234
+ placeholder: str = "",
235
+ default: str = "",
236
+ description: str = "",
237
+ conditions: list[dict] | None = None,
238
+ ) -> dict:
239
+ return {
240
+ "id": id, "label": label, "field_type": "textarea_with_variables",
241
+ "placeholder": placeholder, "default_value": default,
242
+ "description": description, "conditions": conditions or [],
243
+ }
244
+
245
+ @staticmethod
246
+ def dropdown(
247
+ id: str,
248
+ label: str,
249
+ *,
250
+ options: list,
251
+ default: str = "",
252
+ description: str = "",
253
+ conditions: list[dict] | None = None,
254
+ ) -> dict:
255
+ """Create a dropdown field.
256
+
257
+ ``options`` accepts:
258
+ - ``[("value", "Label"), ...]`` — tuple pairs
259
+ - ``[{"value": ..., "label": ...}, ...]`` — explicit dicts
260
+ - ``["value1", "value2"]`` — strings (value = label)
261
+ """
262
+ normalized = []
263
+ for opt in options:
264
+ if isinstance(opt, dict):
265
+ normalized.append(opt)
266
+ elif isinstance(opt, (tuple, list)) and len(opt) == 2:
267
+ normalized.append({"value": opt[0], "label": opt[1]})
268
+ else:
269
+ normalized.append({"value": str(opt), "label": str(opt)})
270
+ return {
271
+ "id": id, "label": label, "field_type": "dropdown",
272
+ "options": normalized, "default_value": default,
273
+ "description": description, "conditions": conditions or [],
274
+ }
275
+
276
+ @staticmethod
277
+ def number(
278
+ id: str,
279
+ label: str,
280
+ *,
281
+ min: float | None = None,
282
+ max: float | None = None,
283
+ step: float | None = None,
284
+ default: str = "",
285
+ description: str = "",
286
+ conditions: list[dict] | None = None,
287
+ ) -> dict:
288
+ return {
289
+ "id": id, "label": label, "field_type": "number",
290
+ "default_value": default, "description": description,
291
+ "has_min": min is not None,
292
+ "has_max": max is not None,
293
+ "has_step": step is not None,
294
+ "min": float(min) if min is not None else 0.0,
295
+ "max": float(max) if max is not None else 0.0,
296
+ "step": float(step) if step is not None else 0.0,
297
+ "conditions": conditions or [],
298
+ }
299
+
300
+ @staticmethod
301
+ def toggle(
302
+ id: str,
303
+ label: str,
304
+ *,
305
+ default: bool = False,
306
+ description: str = "",
307
+ conditions: list[dict] | None = None,
308
+ ) -> dict:
309
+ return {
310
+ "id": id, "label": label, "field_type": "toggle",
311
+ "default_value": "true" if default else "false",
312
+ "description": description, "conditions": conditions or [],
313
+ }
314
+
315
+ @staticmethod
316
+ def condition(field_id: str, operator: str, value: str = "") -> dict:
317
+ """Build a field visibility condition."""
318
+ return {"field_id": field_id, "operator": operator, "value": value}
@@ -0,0 +1,93 @@
1
+ """HostClient — plugin-side gRPC client for calling the Astra daemon."""
2
+
3
+ import grpc
4
+
5
+ from astra_plugin_sdk.proto import plugin_pb2, plugin_pb2_grpc
6
+
7
+
8
+ class HostClient:
9
+ """Client for calling daemon services from a plugin."""
10
+
11
+ def __init__(self, daemon_addr: str, plugin_id: str):
12
+ self.daemon_addr = daemon_addr
13
+ self.plugin_id = plugin_id
14
+ self._channel: grpc.aio.Channel | None = None
15
+ self._stub: plugin_pb2_grpc.PluginHostServiceStub | None = None
16
+
17
+ async def connect(self):
18
+ """Connect to the daemon's PluginHostService."""
19
+ self._channel = grpc.aio.insecure_channel(self.daemon_addr)
20
+ self._stub = plugin_pb2_grpc.PluginHostServiceStub(self._channel)
21
+
22
+ async def register(
23
+ self, port: int, capabilities: list[str]
24
+ ) -> plugin_pb2.PluginRegisterResponse:
25
+ """Register this plugin with the daemon."""
26
+ return await self._stub.Register(
27
+ plugin_pb2.PluginRegisterRequest(
28
+ plugin_id=self.plugin_id,
29
+ port=port,
30
+ capabilities=capabilities,
31
+ )
32
+ )
33
+
34
+ async def fire_trigger(self, trigger_type: str, payload_json: str = "{}"):
35
+ """Fire a trigger (for trigger plugins)."""
36
+ await self._stub.FireTrigger(
37
+ plugin_pb2.PluginFireTriggerRequest(
38
+ trigger_type=trigger_type,
39
+ payload_json=payload_json,
40
+ )
41
+ )
42
+
43
+ async def log(self, level: str, message: str):
44
+ """Log a message to the daemon's log buffer."""
45
+ await self._stub.PluginLog(
46
+ plugin_pb2.PluginLogRequest(
47
+ plugin_id=self.plugin_id,
48
+ level=level,
49
+ message=message,
50
+ )
51
+ )
52
+
53
+ async def get_config(self) -> str:
54
+ """Get this plugin's current config from the daemon."""
55
+ response = await self._stub.GetPluginSelfConfig(
56
+ plugin_pb2.PluginSelfIdRequest(plugin_id=self.plugin_id)
57
+ )
58
+ return response.config_json
59
+
60
+ async def get_daemon_info(self) -> plugin_pb2.PluginDaemonInfoResponse:
61
+ """Get daemon info (version, state, port)."""
62
+ return await self._stub.GetDaemonInfo(plugin_pb2.Empty())
63
+
64
+ async def subscribe_events(self, event_types: list[str] | None = None):
65
+ """Subscribe to daemon events. Returns an async iterator."""
66
+ return self._stub.SubscribeEvents(
67
+ plugin_pb2.PluginEventFilter(
68
+ plugin_id=self.plugin_id,
69
+ event_types=event_types or [],
70
+ )
71
+ )
72
+
73
+ async def set_variable(self, name: str, value: str, scope: str = "session"):
74
+ """Set a variable in the daemon's variable context.
75
+
76
+ Args:
77
+ name: Variable name.
78
+ value: Variable value.
79
+ scope: "session" (default, cleared on restart) or "persistent" (saved to disk).
80
+ """
81
+ await self._stub.SetVariable(
82
+ plugin_pb2.PluginSetVariableRequest(
83
+ plugin_id=self.plugin_id,
84
+ name=name,
85
+ value=value,
86
+ scope=scope,
87
+ )
88
+ )
89
+
90
+ async def close(self):
91
+ """Close the gRPC channel."""
92
+ if self._channel:
93
+ await self._channel.close()