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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. 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)