dulus 0.2.0__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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
plugin/loader.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Plugin loader: discover and load tools/skills/mcp from installed plugins."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib.util
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def scrub_any_type(obj: Any) -> Any:
|
|
11
|
+
"""Recursively remove 'type': 'any' from schema dictionaries as it's not valid JSON Schema."""
|
|
12
|
+
if isinstance(obj, dict):
|
|
13
|
+
new_obj = {}
|
|
14
|
+
for k, v in obj.items():
|
|
15
|
+
if k == "type" and v == "any":
|
|
16
|
+
continue
|
|
17
|
+
new_obj[k] = scrub_any_type(v)
|
|
18
|
+
return new_obj
|
|
19
|
+
elif isinstance(obj, list):
|
|
20
|
+
return [scrub_any_type(item) for item in obj]
|
|
21
|
+
return obj
|
|
22
|
+
|
|
23
|
+
from .store import list_plugins
|
|
24
|
+
from .types import PluginEntry, PluginScope
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_all_plugins(scope: PluginScope | None = None) -> list[PluginEntry]:
|
|
28
|
+
"""Return enabled plugins (optionally filtered by scope)."""
|
|
29
|
+
return [p for p in list_plugins(scope) if p.enabled]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_plugin_tools(scope: PluginScope | None = None) -> list[dict]:
|
|
33
|
+
"""
|
|
34
|
+
Import tool modules from all enabled plugins and collect their TOOL_SCHEMAS.
|
|
35
|
+
Returns combined list of tool schema dicts.
|
|
36
|
+
"""
|
|
37
|
+
schemas: list[dict] = []
|
|
38
|
+
for entry in load_all_plugins(scope):
|
|
39
|
+
if not entry.manifest or not entry.manifest.tools:
|
|
40
|
+
continue
|
|
41
|
+
for module_name in entry.manifest.tools:
|
|
42
|
+
mod = _import_plugin_module(entry, module_name)
|
|
43
|
+
if mod and hasattr(mod, "TOOL_SCHEMAS"):
|
|
44
|
+
schemas.extend(mod.TOOL_SCHEMAS)
|
|
45
|
+
return schemas
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reload_plugins(scope: PluginScope | None = None) -> dict:
|
|
49
|
+
"""
|
|
50
|
+
Reload all plugins and register their tools.
|
|
51
|
+
Returns a dict with counts of what was reloaded.
|
|
52
|
+
"""
|
|
53
|
+
# Clear any cached plugin modules to force re-import
|
|
54
|
+
import sys
|
|
55
|
+
modules_to_remove = [k for k in sys.modules.keys() if k.startswith("_plugin_")]
|
|
56
|
+
for mod_name in modules_to_remove:
|
|
57
|
+
del sys.modules[mod_name]
|
|
58
|
+
|
|
59
|
+
# Re-register tools
|
|
60
|
+
tool_count = register_plugin_tools(scope)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"tools_registered": tool_count,
|
|
64
|
+
"modules_cleared": len(modules_to_remove),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_plugin_tools(scope: PluginScope | None = None) -> int:
|
|
69
|
+
"""
|
|
70
|
+
Import tool modules from enabled plugins and register them into tool_registry.
|
|
71
|
+
Returns number of tools registered.
|
|
72
|
+
"""
|
|
73
|
+
from tool_registry import register_tool, ToolDef
|
|
74
|
+
count = 0
|
|
75
|
+
for entry in load_all_plugins(scope):
|
|
76
|
+
if not entry.manifest or not entry.manifest.tools:
|
|
77
|
+
continue
|
|
78
|
+
for module_name in entry.manifest.tools:
|
|
79
|
+
mod = _import_plugin_module(entry, module_name)
|
|
80
|
+
if mod is None:
|
|
81
|
+
continue
|
|
82
|
+
# Register each ToolDef exported by the module
|
|
83
|
+
if hasattr(mod, "TOOL_DEFS"):
|
|
84
|
+
for tdef in mod.TOOL_DEFS:
|
|
85
|
+
# Normalize schema: ensure input_schema and parameters are synced
|
|
86
|
+
if hasattr(tdef, "schema") and isinstance(tdef.schema, dict):
|
|
87
|
+
sch = tdef.schema
|
|
88
|
+
if "input_schema" not in sch and "parameters" in sch:
|
|
89
|
+
sch["input_schema"] = sch["parameters"]
|
|
90
|
+
elif "parameters" not in sch and "input_schema" in sch:
|
|
91
|
+
sch["parameters"] = sch["input_schema"]
|
|
92
|
+
|
|
93
|
+
# Scrub invalid 'any' types
|
|
94
|
+
tdef.schema = scrub_any_type(sch)
|
|
95
|
+
|
|
96
|
+
register_tool(tdef)
|
|
97
|
+
count += 1
|
|
98
|
+
return count
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_plugin_skills(scope: PluginScope | None = None) -> list[Path]:
|
|
102
|
+
"""Return paths to skill markdown files from enabled plugins."""
|
|
103
|
+
paths: list[Path] = []
|
|
104
|
+
for entry in load_all_plugins(scope):
|
|
105
|
+
if not entry.manifest or not entry.manifest.skills:
|
|
106
|
+
continue
|
|
107
|
+
for skill_rel in entry.manifest.skills:
|
|
108
|
+
skill_path = entry.install_dir / skill_rel
|
|
109
|
+
if skill_path.exists():
|
|
110
|
+
paths.append(skill_path)
|
|
111
|
+
return paths
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_plugin_mcp_configs(scope: PluginScope | None = None) -> dict:
|
|
115
|
+
"""Return mcp server configs contributed by enabled plugins."""
|
|
116
|
+
configs: dict = {}
|
|
117
|
+
for entry in load_all_plugins(scope):
|
|
118
|
+
if not entry.manifest or not entry.manifest.mcp_servers:
|
|
119
|
+
continue
|
|
120
|
+
for server_name, server_cfg in entry.manifest.mcp_servers.items():
|
|
121
|
+
# Prefix server name with plugin name to avoid collisions
|
|
122
|
+
qualified = f"{entry.name}__{server_name}"
|
|
123
|
+
configs[qualified] = server_cfg
|
|
124
|
+
return configs
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _import_plugin_module(entry: PluginEntry, module_name: str):
|
|
128
|
+
"""Dynamically import a module from a plugin directory."""
|
|
129
|
+
# Ensure plugin dir is on sys.path
|
|
130
|
+
plugin_dir_str = str(entry.install_dir)
|
|
131
|
+
if plugin_dir_str not in sys.path:
|
|
132
|
+
sys.path.insert(0, plugin_dir_str)
|
|
133
|
+
|
|
134
|
+
# Build a unique module name to avoid collisions
|
|
135
|
+
unique_name = f"_plugin_{entry.name}_{module_name}"
|
|
136
|
+
if unique_name in sys.modules:
|
|
137
|
+
return sys.modules[unique_name]
|
|
138
|
+
|
|
139
|
+
# Try as a file
|
|
140
|
+
candidates = [
|
|
141
|
+
entry.install_dir / f"{module_name}.py",
|
|
142
|
+
entry.install_dir / module_name / "__init__.py",
|
|
143
|
+
]
|
|
144
|
+
for candidate in candidates:
|
|
145
|
+
if candidate.exists():
|
|
146
|
+
spec = importlib.util.spec_from_file_location(unique_name, candidate)
|
|
147
|
+
if spec and spec.loader:
|
|
148
|
+
mod = importlib.util.module_from_spec(spec)
|
|
149
|
+
sys.modules[unique_name] = mod
|
|
150
|
+
try:
|
|
151
|
+
spec.loader.exec_module(mod)
|
|
152
|
+
return mod
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"[plugin] Failed to load {module_name} from {entry.name}: {e}")
|
|
155
|
+
del sys.modules[unique_name]
|
|
156
|
+
return None
|
plugin/recommend.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Plugin recommendation engine: match installed + marketplace plugins to context."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .types import PluginManifest, PluginScope
|
|
10
|
+
from .store import list_plugins, USER_PLUGIN_DIR
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Marketplace ───────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
BUILTIN_MARKETPLACE: list[dict] = [
|
|
16
|
+
{
|
|
17
|
+
"name": "git-tools",
|
|
18
|
+
"description": "Extra git helpers: log graph, blame, bisect",
|
|
19
|
+
"tags": ["git", "vcs", "version control", "diff", "blame"],
|
|
20
|
+
"source": "https://github.com/dulus-plugins/git-tools",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "python-linter",
|
|
24
|
+
"description": "Run ruff/mypy/black on Python files",
|
|
25
|
+
"tags": ["python", "lint", "format", "type check", "mypy", "ruff", "black"],
|
|
26
|
+
"source": "https://github.com/dulus-plugins/python-linter",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "docker-tools",
|
|
30
|
+
"description": "Docker container management tools",
|
|
31
|
+
"tags": ["docker", "container", "compose", "kubernetes", "k8s"],
|
|
32
|
+
"source": "https://github.com/dulus-plugins/docker-tools",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "web-scraper",
|
|
36
|
+
"description": "Advanced web scraping with JavaScript rendering",
|
|
37
|
+
"tags": ["web", "scrape", "html", "browser", "playwright", "selenium"],
|
|
38
|
+
"source": "https://github.com/dulus-plugins/web-scraper",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "sql-tools",
|
|
42
|
+
"description": "Query and inspect SQL databases (SQLite, Postgres, MySQL)",
|
|
43
|
+
"tags": ["sql", "database", "db", "sqlite", "postgres", "mysql", "query"],
|
|
44
|
+
"source": "https://github.com/dulus-plugins/sql-tools",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"name": "test-runner",
|
|
48
|
+
"description": "Run pytest/unittest and parse results",
|
|
49
|
+
"tags": ["test", "pytest", "unittest", "coverage", "tdd"],
|
|
50
|
+
"source": "https://github.com/dulus-plugins/test-runner",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "diagram-tools",
|
|
54
|
+
"description": "Generate Mermaid / PlantUML diagrams",
|
|
55
|
+
"tags": ["diagram", "mermaid", "plantuml", "uml", "flowchart", "architecture"],
|
|
56
|
+
"source": "https://github.com/dulus-plugins/diagram-tools",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"name": "aws-tools",
|
|
60
|
+
"description": "AWS CLI wrapper tools (S3, EC2, Lambda, CloudWatch)",
|
|
61
|
+
"tags": ["aws", "cloud", "s3", "ec2", "lambda", "cloudwatch", "iam"],
|
|
62
|
+
"source": "https://github.com/dulus-plugins/aws-tools",
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class PluginRecommendation:
|
|
69
|
+
name: str
|
|
70
|
+
description: str
|
|
71
|
+
source: str
|
|
72
|
+
score: float
|
|
73
|
+
reasons: list[str]
|
|
74
|
+
installed: bool = False
|
|
75
|
+
enabled: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _tokenize(text: str) -> set[str]:
|
|
79
|
+
"""Lower-case word tokens from text."""
|
|
80
|
+
return set(re.findall(r"\b[a-z0-9_\-]+\b", text.lower()))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _score_against_context(
|
|
84
|
+
entry: dict,
|
|
85
|
+
context_tokens: set[str],
|
|
86
|
+
) -> tuple[float, list[str]]:
|
|
87
|
+
"""Return (score, reasons) for a marketplace entry vs context tokens."""
|
|
88
|
+
score = 0.0
|
|
89
|
+
reasons: list[str] = []
|
|
90
|
+
|
|
91
|
+
name_tokens = _tokenize(entry.get("name", ""))
|
|
92
|
+
desc_tokens = _tokenize(entry.get("description", ""))
|
|
93
|
+
tag_tokens: set[str] = set()
|
|
94
|
+
for tag in entry.get("tags", []):
|
|
95
|
+
tag_tokens.update(_tokenize(tag))
|
|
96
|
+
|
|
97
|
+
# Tag match: highest weight
|
|
98
|
+
tag_hits = tag_tokens & context_tokens
|
|
99
|
+
if tag_hits:
|
|
100
|
+
score += len(tag_hits) * 3.0
|
|
101
|
+
reasons.append(f"tags match: {', '.join(sorted(tag_hits))}")
|
|
102
|
+
|
|
103
|
+
# Name match
|
|
104
|
+
name_hits = name_tokens & context_tokens
|
|
105
|
+
if name_hits:
|
|
106
|
+
score += len(name_hits) * 2.0
|
|
107
|
+
reasons.append(f"name match: {', '.join(sorted(name_hits))}")
|
|
108
|
+
|
|
109
|
+
# Description match
|
|
110
|
+
desc_hits = desc_tokens & context_tokens - {"the", "a", "an", "and", "or", "of", "to", "in", "for", "with"}
|
|
111
|
+
if desc_hits:
|
|
112
|
+
score += len(desc_hits) * 0.5
|
|
113
|
+
|
|
114
|
+
return score, reasons
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def recommend_plugins(
|
|
118
|
+
context: str,
|
|
119
|
+
top_n: int = 5,
|
|
120
|
+
include_installed: bool = False,
|
|
121
|
+
) -> list[PluginRecommendation]:
|
|
122
|
+
"""
|
|
123
|
+
Given a natural-language context string (e.g. current task description or
|
|
124
|
+
user message), return up to top_n plugin recommendations sorted by relevance.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
context: Free-text description of the current task / need.
|
|
128
|
+
top_n: Maximum number of recommendations.
|
|
129
|
+
include_installed: If True, include already-installed plugins in results.
|
|
130
|
+
"""
|
|
131
|
+
context_tokens = _tokenize(context)
|
|
132
|
+
if not context_tokens:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
# Build installed set
|
|
136
|
+
installed_entries = list_plugins()
|
|
137
|
+
installed_names = {e.name for e in installed_entries}
|
|
138
|
+
installed_enabled = {e.name for e in installed_entries if e.enabled}
|
|
139
|
+
|
|
140
|
+
# Also add tags from installed plugins to context (cross-pollination)
|
|
141
|
+
for entry in installed_entries:
|
|
142
|
+
if entry.manifest:
|
|
143
|
+
for tag in entry.manifest.tags:
|
|
144
|
+
context_tokens.update(_tokenize(tag))
|
|
145
|
+
|
|
146
|
+
results: list[PluginRecommendation] = []
|
|
147
|
+
|
|
148
|
+
for mp_entry in BUILTIN_MARKETPLACE:
|
|
149
|
+
name = mp_entry["name"]
|
|
150
|
+
is_installed = name in installed_names
|
|
151
|
+
is_enabled = name in installed_enabled
|
|
152
|
+
|
|
153
|
+
if is_installed and not include_installed:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
score, reasons = _score_against_context(mp_entry, context_tokens)
|
|
157
|
+
if score > 0:
|
|
158
|
+
results.append(PluginRecommendation(
|
|
159
|
+
name=name,
|
|
160
|
+
description=mp_entry.get("description", ""),
|
|
161
|
+
source=mp_entry.get("source", ""),
|
|
162
|
+
score=score,
|
|
163
|
+
reasons=reasons,
|
|
164
|
+
installed=is_installed,
|
|
165
|
+
enabled=is_enabled,
|
|
166
|
+
))
|
|
167
|
+
|
|
168
|
+
results.sort(key=lambda r: r.score, reverse=True)
|
|
169
|
+
return results[:top_n]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def recommend_from_files(
|
|
173
|
+
paths: list[Path],
|
|
174
|
+
top_n: int = 5,
|
|
175
|
+
) -> list[PluginRecommendation]:
|
|
176
|
+
"""Recommend plugins based on the types of files in the current project."""
|
|
177
|
+
context_parts: list[str] = []
|
|
178
|
+
ext_map = {
|
|
179
|
+
".py": "python",
|
|
180
|
+
".ts": "typescript javascript",
|
|
181
|
+
".tsx": "typescript react javascript",
|
|
182
|
+
".js": "javascript",
|
|
183
|
+
".rs": "rust",
|
|
184
|
+
".go": "golang",
|
|
185
|
+
".java": "java",
|
|
186
|
+
".sql": "sql database",
|
|
187
|
+
".dockerfile": "docker container",
|
|
188
|
+
".yaml": "yaml config",
|
|
189
|
+
".yml": "yaml config docker",
|
|
190
|
+
".tf": "terraform aws cloud",
|
|
191
|
+
".md": "markdown docs",
|
|
192
|
+
}
|
|
193
|
+
for p in paths:
|
|
194
|
+
label = ext_map.get(p.suffix.lower(), "")
|
|
195
|
+
if label:
|
|
196
|
+
context_parts.append(label)
|
|
197
|
+
|
|
198
|
+
return recommend_plugins(" ".join(context_parts), top_n=top_n)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def format_recommendations(recs: list[PluginRecommendation]) -> str:
|
|
202
|
+
if not recs:
|
|
203
|
+
return "No plugin recommendations for the current context."
|
|
204
|
+
lines = ["Plugin recommendations:"]
|
|
205
|
+
for i, rec in enumerate(recs, 1):
|
|
206
|
+
status = " [installed]" if rec.installed else ""
|
|
207
|
+
lines.append(f" {i}. {rec.name}{status} — {rec.description}")
|
|
208
|
+
if rec.reasons:
|
|
209
|
+
lines.append(f" Reason: {'; '.join(rec.reasons)}")
|
|
210
|
+
lines.append(f" Install: /plugin install {rec.name}@{rec.source}")
|
|
211
|
+
return "\n".join(lines)
|