soothe-plugins 0.2.6__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.
@@ -0,0 +1,150 @@
1
+ # Soothe Community Plugins
2
+
3
+ Welcome to the Soothe Community Plugins repository! This package contains
4
+ community-contributed plugins for the Soothe agent orchestration framework.
5
+
6
+ ## What Are Community Plugins?
7
+
8
+ Community plugins are third-party extensions that add new capabilities to Soothe
9
+ without modifying the core framework. They follow the RFC-0018 Plugin Extension
10
+ System specification.
11
+
12
+ ## Available Plugins
13
+
14
+ ### PaperScout (`paperscout`)
15
+
16
+ ArXiv paper recommendation agent that delivers personalized daily paper
17
+ recommendations by analyzing your Zotero library.
18
+
19
+ **Features**:
20
+ - Fetches papers from ArXiv based on configurable categories
21
+ - Analyzes your Zotero library to understand research interests
22
+ - Ranks papers by relevance using sentence embeddings
23
+ - Sends daily email digests with TLDR summaries
24
+ - Discovers code repositories via PapersWithCode
25
+
26
+ **Installation**:
27
+ ```bash
28
+ pip install soothe[paperscout]
29
+ ```
30
+
31
+ **Configuration**:
32
+ ```yaml
33
+ subagents:
34
+ paperscout:
35
+ enabled: true
36
+ model: "openai:gpt-4o-mini"
37
+ config:
38
+ arxiv_categories:
39
+ - cs.AI
40
+ - cs.CV
41
+ - cs.LG
42
+ max_papers: 25
43
+ smtp:
44
+ host: "${SMTP_HOST}"
45
+ port: 587
46
+ user: "${SMTP_USER}"
47
+ password: "${SMTP_PASSWORD}"
48
+ zotero:
49
+ api_key: "${ZOTERO_API_KEY}"
50
+ library_id: "${ZOTERO_LIBRARY_ID}"
51
+ ```
52
+
53
+ **Usage**:
54
+ ```bash
55
+ # Use via TUI (default)
56
+ soothe "Find recent papers on transformer architectures" --subagent paperscout
57
+
58
+ # Or headless mode
59
+ soothe "Find recent papers" --subagent paperscout --no-tui
60
+ ```
61
+
62
+ ## Creating a New Plugin
63
+
64
+ To create a new community plugin:
65
+
66
+ 1. **Create a package** in `src/soothe_plugins/your_plugin/`
67
+ 2. **Define events** in `events.py` using `register_event()`
68
+ 3. **Create plugin class** in `__init__.py` with `@plugin` decorator
69
+ 4. **Implement functionality** in `implementation.py`
70
+ 5. **Add tests** in `tests/unit/community/test_your_plugin/`
71
+ 6. **Update dependencies** in `pyproject.toml` (optional extras)
72
+
73
+ See the PaperScout plugin for a complete example.
74
+
75
+ ## Plugin Development Guidelines
76
+
77
+ ### 1. Follow RFC-0018
78
+
79
+ All plugins must comply with RFC-0018 Plugin Extension System:
80
+ - Use `@plugin` decorator for plugin registration
81
+ - Use `@tool` or `@subagent` decorators for capabilities
82
+ - Register custom events using `register_event()`
83
+ - Respect trust levels and permissions
84
+
85
+ ### 2. Self-Containment
86
+
87
+ Each plugin should be self-contained:
88
+ - Define its own events in `events.py`
89
+ - Include all necessary data models
90
+ - Handle configuration through Soothe's config system
91
+ - Document dependencies clearly
92
+
93
+ ### 3. Testing
94
+
95
+ - Provide comprehensive test coverage (>80%)
96
+ - Mock all external APIs and services
97
+ - Test plugin lifecycle (load/unload)
98
+ - Test event emission
99
+
100
+ ### 4. Documentation
101
+
102
+ - Create a README for the plugin
103
+ - Document configuration options
104
+ - Provide usage examples
105
+ - List dependencies and installation requirements
106
+
107
+ ### 5. Code Quality
108
+
109
+ - Follow Soothe's Python style guide
110
+ - Use type hints on all public functions
111
+ - Add Google-style docstrings
112
+ - Run `./scripts/verify_finally.sh` before committing
113
+
114
+ ## Directory Structure
115
+
116
+ ```
117
+ src/soothe_plugins/
118
+ ├── __init__.py # Package init
119
+ ├── README.md # This file
120
+ └── paperscout/ # Example plugin
121
+ ├── __init__.py # Plugin registration
122
+ ├── events.py # Event definitions
123
+ ├── implementation.py # Core implementation
124
+ ├── state.py # Configuration/state models
125
+ ├── nodes.py # Workflow nodes
126
+ ├── reranker.py # Paper ranking
127
+ ├── email.py # Email formatting
128
+ ├── models.py # Data models
129
+ └── gap_scanner.py # Gap detection
130
+ ```
131
+
132
+ ## Contributing
133
+
134
+ To contribute a plugin:
135
+
136
+ 1. Fork the repository
137
+ 2. Create your plugin in `src/soothe_plugins/your_plugin/`
138
+ 3. Add tests in `tests/unit/community/test_your_plugin/`
139
+ 4. Update this README with your plugin's documentation
140
+ 5. Submit a pull request
141
+
142
+ ## Support
143
+
144
+ - **Documentation**: See `docs/specs/RFC-0018.md` for plugin architecture
145
+ - **Examples**: See existing plugins in this directory
146
+ - **Issues**: Report issues on GitHub
147
+
148
+ ## License
149
+
150
+ Community plugins follow the same license as the main Soothe project.
@@ -0,0 +1,20 @@
1
+ """Soothe Community Plugins.
2
+
3
+ This package contains community-contributed plugins for Soothe,
4
+ including subagents, tools, and other extensions built on the
5
+ RFC-0018 plugin system.
6
+
7
+ All plugins in this package are third-party contributions and follow
8
+ the standard plugin architecture with @plugin and @subagent decorators.
9
+ """
10
+
11
+ import importlib.metadata
12
+
13
+ try:
14
+ __version__ = importlib.metadata.version("soothe-plugins")
15
+ except importlib.metadata.PackageNotFoundError:
16
+ __version__ = "0.0.0"
17
+
18
+ __all__ = [
19
+ "__version__",
20
+ ]
@@ -0,0 +1,17 @@
1
+ """Path helpers for community plugins (mirrors ``soothe.utils.path.expand_path``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def expand_path(path: str | Path) -> Path:
10
+ """Expand and resolve a filesystem path."""
11
+ path_str = str(path)
12
+ expanded = os.path.expandvars(path_str)
13
+ expanded_path = Path(expanded).expanduser()
14
+ return expanded_path.resolve()
15
+
16
+
17
+ __all__ = ["expand_path"]
@@ -0,0 +1,44 @@
1
+ """Sample echo subagent — minimal community plugin for Soothe integration tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from soothe_sdk.plugin import plugin, subagent
9
+
10
+ from .implementation import create_echo_subagent_spec
11
+
12
+ __all__ = ["SampleEchoPlugin", "create_echo_subagent_spec"]
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ @plugin(
18
+ name="sample_echo",
19
+ version="1.0.0",
20
+ description="Minimal echo subagent for testing soothe-plugins against the Soothe daemon",
21
+ dependencies=["langgraph>=0.2.0"],
22
+ trust_level="standard",
23
+ )
24
+ class SampleEchoPlugin:
25
+ """Registers a no-LLM subgraph so CI can validate plugin subagent loading."""
26
+
27
+ async def on_load(self, context: Any) -> None:
28
+ """No-op load hook."""
29
+ context.logger.info("sample_echo plugin loaded")
30
+
31
+ @subagent(
32
+ name="sample_echo",
33
+ description=("Echoes the user's last message with a sample_echo tag. For automated tests only."),
34
+ )
35
+ async def create_sample_echo(
36
+ self,
37
+ model: Any,
38
+ config: Any,
39
+ context: Any,
40
+ **_kwargs: Any,
41
+ ) -> dict[str, Any]:
42
+ """Materialize the echo subgraph (model and global config unused)."""
43
+ _ = model, config, context
44
+ return create_echo_subagent_spec()
@@ -0,0 +1,47 @@
1
+ """Minimal LangGraph subagent for integration testing (no LLM)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated, Any, TypedDict
6
+
7
+ from langchain_core.messages import AIMessage, HumanMessage
8
+ from langgraph.graph import END, START, StateGraph
9
+ from langgraph.graph.message import add_messages
10
+
11
+
12
+ class EchoState(TypedDict):
13
+ """Graph state: conversation messages only."""
14
+
15
+ messages: Annotated[list[Any], add_messages]
16
+
17
+
18
+ def _echo_node(state: EchoState) -> dict[str, Any]:
19
+ """Return an assistant message echoing the last human input."""
20
+ text = ""
21
+ for msg in reversed(state.get("messages", [])):
22
+ if isinstance(msg, HumanMessage):
23
+ text = str(msg.content)
24
+ break
25
+ body = f"[sample_echo] {text!r}"
26
+ return {"messages": [AIMessage(content=body)]}
27
+
28
+
29
+ def create_echo_subagent_spec() -> dict[str, Any]:
30
+ """Build a compiled subgraph spec for deepagents ``task`` delegation.
31
+
32
+ Returns:
33
+ Subagent dict with ``name``, ``description``, and ``runnable``.
34
+ """
35
+ graph = StateGraph(EchoState)
36
+ graph.add_node("echo", _echo_node)
37
+ graph.add_edge(START, "echo")
38
+ graph.add_edge("echo", END)
39
+ compiled = graph.compile()
40
+ return {
41
+ "name": "sample_echo",
42
+ "description": (
43
+ "Test-only community subagent that echoes the last user message. "
44
+ "Use for plugin and resolver integration checks."
45
+ ),
46
+ "runnable": compiled,
47
+ }
@@ -0,0 +1,288 @@
1
+ """Skillify subagent plugin -- skill warehouse indexing and semantic retrieval (RFC-0004).
2
+
3
+ Community plugin for Soothe that provides:
4
+ 1. Background indexing loop (asyncio.Task) keeping the vector index in sync
5
+ with the skill warehouse.
6
+ 2. Retrieval CompiledSubAgent (LangGraph) serving on-demand skill bundles
7
+ for user goals or downstream agents like Weaver.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Annotated, Any, TypedDict
16
+
17
+ from langchain_core.messages import AIMessage
18
+ from langgraph.graph import END, START, StateGraph
19
+ from langgraph.graph.message import add_messages
20
+ from soothe_sdk.plugin import plugin, subagent
21
+
22
+ from .events import (
23
+ SkillifyCompletedEvent,
24
+ SkillifyDispatchedEvent,
25
+ SkillifyIndexingPendingEvent,
26
+ SkillifyRetrieveCompletedEvent,
27
+ SkillifyRetrieveNotReadyEvent,
28
+ SkillifyRetrieveStartedEvent,
29
+ )
30
+ from .indexer import SkillIndexer
31
+ from .retriever import SkillRetriever
32
+ from .warehouse import SkillWarehouse
33
+
34
+ if TYPE_CHECKING:
35
+ from deepagents.middleware.subagents import CompiledSubAgent
36
+ from langchain_core.language_models import BaseChatModel
37
+
38
+ from .models import SkillBundle
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ SKILLIFY_DESCRIPTION = (
43
+ "Skill retrieval agent for semantic search over the skill warehouse. "
44
+ "Given a task description or objective, returns a ranked bundle of relevant "
45
+ "skills with paths and relevance scores. Use when you need to find skills "
46
+ "matching a specific capability or goal."
47
+ )
48
+
49
+
50
+ def _emit_event(event_dict: dict[str, Any], ctx_logger: logging.Logger) -> None:
51
+ """Emit progress event via logger or event emission.
52
+
53
+ For community plugins, we use logger.info() for visibility.
54
+ Daemon may intercept and convert to progress events.
55
+ """
56
+ event_type = event_dict.get("type", "unknown")
57
+ ctx_logger.info(f"[{event_type}] {event_dict}")
58
+
59
+
60
+ class SkillifyState(TypedDict):
61
+ """State for the Skillify retrieval graph."""
62
+
63
+ messages: Annotated[list[Any], add_messages]
64
+
65
+
66
+ def _build_skillify_graph(retriever: SkillRetriever) -> Any:
67
+ """Build and compile the Skillify retrieval LangGraph."""
68
+
69
+ async def _retrieve_async(state: dict[str, Any]) -> dict[str, Any]:
70
+ messages = state.get("messages", [])
71
+ query = ""
72
+ for msg in reversed(messages):
73
+ if hasattr(msg, "type") and msg.type == "human":
74
+ query = msg.content if hasattr(msg, "content") else str(msg)
75
+ break
76
+ if not query and messages:
77
+ last = messages[-1]
78
+ query = last.content if hasattr(last, "content") else str(last)
79
+
80
+ _emit_event(SkillifyDispatchedEvent(task=query[:200]).to_dict(), logger)
81
+
82
+ if not retriever.is_ready:
83
+ _emit_event(SkillifyIndexingPendingEvent(query=query[:200]).to_dict(), logger)
84
+
85
+ _emit_event(SkillifyRetrieveStartedEvent(query=query[:200]).to_dict(), logger)
86
+
87
+ bundle: SkillBundle = await retriever.retrieve(query)
88
+
89
+ if bundle.query.startswith("[Indexing in progress]"):
90
+ _emit_event(SkillifyRetrieveNotReadyEvent(message=bundle.query).to_dict(), logger)
91
+ _emit_event(SkillifyCompletedEvent(duration_ms=0, result_count=0).to_dict(), logger)
92
+ return {"messages": [AIMessage(content=bundle.query)]}
93
+
94
+ top_score = bundle.results[0].score if bundle.results else 0.0
95
+ _emit_event(
96
+ SkillifyRetrieveCompletedEvent(
97
+ query=query[:200],
98
+ result_count=len(bundle.results),
99
+ top_score=round(top_score, 3),
100
+ ).to_dict(),
101
+ logger,
102
+ )
103
+
104
+ result_lines = [f"Found {len(bundle.results)} relevant skills (total indexed: {bundle.total_indexed}):\n"]
105
+ for i, sr in enumerate(bundle.results, 1):
106
+ result_lines.append(
107
+ f"{i}. **{sr.record.name}** (score: {sr.score:.3f})\n"
108
+ f" Path: {sr.record.path}\n"
109
+ f" Description: {sr.record.description[:200]}\n"
110
+ f" Tags: {', '.join(sr.record.tags) if sr.record.tags else 'none'}"
111
+ )
112
+
113
+ result_text = "\n".join(result_lines)
114
+ _emit_event(
115
+ SkillifyCompletedEvent(
116
+ duration_ms=0,
117
+ result_count=len(bundle.results),
118
+ ).to_dict(),
119
+ logger,
120
+ )
121
+ return {"messages": [AIMessage(content=result_text)]}
122
+
123
+ def retrieve_sync(state: dict[str, Any]) -> dict[str, Any]:
124
+ try:
125
+ loop = asyncio.get_event_loop()
126
+ except RuntimeError:
127
+ loop = asyncio.new_event_loop()
128
+ asyncio.set_event_loop(loop)
129
+
130
+ if loop.is_running():
131
+ new_loop = asyncio.new_event_loop()
132
+ try:
133
+ return new_loop.run_until_complete(_retrieve_async(state))
134
+ finally:
135
+ new_loop.close()
136
+ else:
137
+ return loop.run_until_complete(_retrieve_async(state))
138
+
139
+ graph = StateGraph(SkillifyState)
140
+ graph.add_node("retrieve", retrieve_sync)
141
+ graph.add_edge(START, "retrieve")
142
+ graph.add_edge("retrieve", END)
143
+ return graph.compile()
144
+
145
+
146
+ def _resolve_dependencies(soothe_cfg: Any) -> tuple[Any, Any]:
147
+ """Resolve VectorStore and Embeddings from context services."""
148
+ # Use context services if available
149
+ if hasattr(soothe_cfg, "services"):
150
+ services = soothe_cfg.services
151
+ vector_store = services.get("vector_store")
152
+ embeddings_factory = services.get("embeddings_factory")
153
+ if vector_store and embeddings_factory:
154
+ return vector_store, embeddings_factory
155
+
156
+ # Fallback: use soothe_config protocol methods
157
+ if hasattr(soothe_cfg, "create_vector_store_for_role"):
158
+ vs = soothe_cfg.create_vector_store_for_role("skillify")
159
+ embeddings_factory = soothe_cfg.create_embedding_model
160
+ return vs, embeddings_factory
161
+
162
+ # Last resort: create basic implementations
163
+ msg = "Cannot resolve vector_store or embeddings from context or config"
164
+ raise ValueError(msg)
165
+
166
+
167
+ def _start_background_indexer(indexer: SkillIndexer) -> None:
168
+ """Start the indexer background loop, creating an event loop if needed."""
169
+ try:
170
+ loop = asyncio.get_running_loop()
171
+ indexer._start_task = loop.create_task(indexer.start())
172
+ except RuntimeError:
173
+ pass
174
+
175
+
176
+ @plugin(
177
+ name="skillify",
178
+ version="1.0.0",
179
+ description="Skill warehouse indexing and semantic retrieval",
180
+ dependencies=["langgraph>=0.2.0"],
181
+ trust_level="standard",
182
+ )
183
+ class SkillifyPlugin:
184
+ """Skillify community plugin for skill warehouse indexing and retrieval."""
185
+
186
+ def __init__(self) -> None:
187
+ self._indexer: SkillIndexer | None = None
188
+
189
+ async def on_load(self, context: Any) -> None:
190
+ """Trigger event self-registration."""
191
+ import soothe_plugins.skillify.events # noqa: F401
192
+
193
+ context.logger.info("Skillify plugin loaded")
194
+
195
+ async def on_unload(self) -> None:
196
+ """Stop the background indexer."""
197
+ if self._indexer is not None:
198
+ try:
199
+ await self._indexer.stop()
200
+ except Exception:
201
+ pass
202
+ self._indexer = None
203
+
204
+ @subagent(
205
+ name="skillify",
206
+ description=SKILLIFY_DESCRIPTION,
207
+ )
208
+ async def create_skillify(
209
+ self,
210
+ model: str | BaseChatModel | None,
211
+ config: Any,
212
+ context: Any,
213
+ **_kwargs: Any,
214
+ ) -> CompiledSubAgent:
215
+ """Create a Skillify subagent.
216
+
217
+ Args:
218
+ model: Unused (Skillify does not need an LLM).
219
+ config: Plugin context (PluginContext instance from soothe_sdk).
220
+ context: Plugin context (same as config parameter - deprecated parameter name).
221
+ **_kwargs: Additional config (ignored).
222
+
223
+ Returns:
224
+ CompiledSubAgent dict with background indexer.
225
+ """
226
+
227
+ soothe_cfg = context.soothe_config
228
+ plugin_cfg = context.config if hasattr(context, "config") else {}
229
+ ctx_logger = context.logger if hasattr(context, "logger") else logger
230
+
231
+ # Get plugin-specific config
232
+ skillify_cfg = plugin_cfg.get("skillify", {})
233
+
234
+ # Resolve warehouse paths
235
+ soothe_home = Path.home() / ".soothe" # Default SOOTHE_HOME
236
+ if hasattr(soothe_cfg, "home"):
237
+ soothe_home = Path(soothe_cfg.home)
238
+
239
+ default_warehouse = str(soothe_home / "agents" / "skillify" / "warehouse")
240
+ warehouse_paths = skillify_cfg.get("warehouse_paths", [])
241
+ if isinstance(warehouse_paths, list) and default_warehouse not in warehouse_paths:
242
+ warehouse_paths.insert(0, default_warehouse)
243
+
244
+ warehouse = SkillWarehouse(paths=warehouse_paths)
245
+ vector_store, embeddings = _resolve_dependencies(soothe_cfg)
246
+
247
+ # Extract config parameters with defaults
248
+ collection = skillify_cfg.get("index_collection", "soothe_skillify")
249
+ interval = skillify_cfg.get("index_interval_seconds", 300)
250
+ top_k = skillify_cfg.get("retrieval_top_k", 10)
251
+ embedding_dims = skillify_cfg.get("embedding_dims", 1536)
252
+
253
+ def emit_callback(event: dict[str, Any]) -> None:
254
+ _emit_event(event, ctx_logger)
255
+
256
+ self._indexer = SkillIndexer(
257
+ warehouse=warehouse,
258
+ vector_store=vector_store,
259
+ embeddings=embeddings,
260
+ interval_seconds=interval,
261
+ collection=collection,
262
+ embedding_dims=embedding_dims,
263
+ event_callback=emit_callback,
264
+ )
265
+
266
+ retriever = SkillRetriever(
267
+ vector_store=vector_store,
268
+ embeddings=embeddings,
269
+ top_k=top_k,
270
+ ready_event=self._indexer.ready_event,
271
+ )
272
+
273
+ _start_background_indexer(self._indexer)
274
+
275
+ runnable = _build_skillify_graph(retriever)
276
+
277
+ spec: CompiledSubAgent = {
278
+ "name": "skillify",
279
+ "description": SKILLIFY_DESCRIPTION,
280
+ "runnable": runnable,
281
+ }
282
+ spec["_skillify_indexer"] = self._indexer # type: ignore[typeddict-unknown-key]
283
+ spec["_skillify_retriever"] = retriever # type: ignore[typeddict-unknown-key]
284
+ return spec
285
+
286
+ def get_subagents(self) -> list[Any]:
287
+ """Get list of subagent factory functions."""
288
+ return [self.create_skillify]