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.
- soothe_plugins/.plugin_template/PLUGIN_TEMPLATE.md +40 -0
- soothe_plugins/.plugin_template/README.md.template +152 -0
- soothe_plugins/.plugin_template/__init__.py.template +174 -0
- soothe_plugins/.plugin_template/events.py.template +34 -0
- soothe_plugins/.plugin_template/implementation.py.template +112 -0
- soothe_plugins/.plugin_template/models.py.template +39 -0
- soothe_plugins/.plugin_template/state.py.template +48 -0
- soothe_plugins/README.md +150 -0
- soothe_plugins/__init__.py +20 -0
- soothe_plugins/_paths.py +17 -0
- soothe_plugins/sample_echo/__init__.py +44 -0
- soothe_plugins/sample_echo/implementation.py +47 -0
- soothe_plugins/skillify/__init__.py +288 -0
- soothe_plugins/skillify/events.py +148 -0
- soothe_plugins/skillify/indexer.py +312 -0
- soothe_plugins/skillify/models.py +36 -0
- soothe_plugins/skillify/retriever.py +165 -0
- soothe_plugins/skillify/warehouse.py +96 -0
- soothe_plugins/weaver/__init__.py +507 -0
- soothe_plugins/weaver/analyzer.py +81 -0
- soothe_plugins/weaver/composer.py +322 -0
- soothe_plugins/weaver/events.py +223 -0
- soothe_plugins/weaver/generator.py +177 -0
- soothe_plugins/weaver/models.py +136 -0
- soothe_plugins/weaver/registry.py +214 -0
- soothe_plugins/weaver/reuse.py +151 -0
- soothe_plugins-0.2.6.dist-info/METADATA +156 -0
- soothe_plugins-0.2.6.dist-info/RECORD +30 -0
- soothe_plugins-0.2.6.dist-info/WHEEL +4 -0
- soothe_plugins-0.2.6.dist-info/entry_points.txt +4 -0
soothe_plugins/README.md
ADDED
|
@@ -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
|
+
]
|
soothe_plugins/_paths.py
ADDED
|
@@ -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]
|