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
backend/context.py ADDED
@@ -0,0 +1,329 @@
1
+ """Smart Context Manager (#23 + #28) — generates optimized context for LLM sessions."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from backend.compressor import compress
10
+ from backend.mempalace_bridge import get_mempalace_compact_text, get_mempalace_context_block
11
+ from backend.personas import get_personas_for_context, get_active_persona, get_persona_compact_text
12
+ from backend.tasks import load_tasks
13
+
14
+ DATA_DIR = Path(__file__).parent.parent / "data"
15
+ DATA_DIR.mkdir(exist_ok=True)
16
+ CONTEXT_FILE = DATA_DIR / "context.json"
17
+
18
+
19
+ def run_git(args: list[str]) -> str:
20
+ try:
21
+ return subprocess.check_output(
22
+ ["git"] + args, cwd=Path(__file__).parent.parent, stderr=subprocess.DEVNULL, text=True
23
+ )
24
+ except Exception:
25
+ return ""
26
+
27
+
28
+ def get_recent_commits(n: int = 5) -> list[dict[str, str]]:
29
+ out = run_git(["log", f"-{n}", "--pretty=format:%h|%s|%an|%ad", "--date=short"])
30
+ commits = []
31
+ for line in out.strip().split("\n"):
32
+ if "|" in line:
33
+ h, s, a, d = line.split("|", 3)
34
+ commits.append({"hash": h, "subject": s, "author": a, "date": d})
35
+ return commits
36
+
37
+
38
+ def get_changed_files() -> list[str]:
39
+ out = run_git(["diff", "--name-only", "HEAD~1"])
40
+ return [f for f in out.strip().split("\n") if f]
41
+
42
+
43
+ def get_repo_stats() -> dict[str, Any]:
44
+ root = Path(__file__).parent.parent
45
+ stats = {"files": 0, "lines": 0, "languages": {}}
46
+ for path in root.rglob("*"):
47
+ if path.is_file() and ".git" not in path.parts and "__pycache__" not in path.parts:
48
+ stats["files"] += 1
49
+ ext = path.suffix or "no_ext"
50
+ try:
51
+ lc = sum(1 for _ in path.open("r", encoding="utf-8", errors="ignore"))
52
+ stats["lines"] += lc
53
+ stats["languages"][ext] = stats["languages"].get(ext, 0) + lc
54
+ except Exception:
55
+ pass
56
+ return stats
57
+
58
+
59
+ def get_active_tasks_summary() -> list[dict[str, Any]]:
60
+ tasks = load_tasks()
61
+ return [
62
+ {"id": t["id"], "subject": t["subject"], "status": t["status"], "owner": t["owner"], "phase": t.get("metadata", {}).get("phase", "")}
63
+ for t in tasks if t["status"] in ("pending", "in_progress")
64
+ ]
65
+
66
+
67
+ def build_context() -> dict[str, Any]:
68
+ """Build comprehensive session context with real MemPalace memories."""
69
+ active = get_active_persona()
70
+ context = {
71
+ "session": {
72
+ "mode": "proactive",
73
+ "agent": active["name"],
74
+ "agent_id": active["id"],
75
+ "user": "KevRojo",
76
+ "location": "RD"
77
+ },
78
+ "project": {
79
+ "name": "Dulus Command Center",
80
+ "repo_stats": get_repo_stats(),
81
+ "recent_commits": get_recent_commits(),
82
+ "recent_changes": get_changed_files()
83
+ },
84
+ "tasks": {
85
+ "active": get_active_tasks_summary(),
86
+ "total": len(get_active_tasks_summary())
87
+ },
88
+ "agents": get_personas_for_context(),
89
+ "persona": {
90
+ "id": active["id"],
91
+ "name": active["name"],
92
+ "role": active["role"],
93
+ "color": active["color"],
94
+ "avatar": active.get("avatar", "🤖"),
95
+ "tone": active["tone"],
96
+ },
97
+ "memory": get_mempalace_context_block()
98
+ }
99
+ with open(CONTEXT_FILE, "w", encoding="utf-8") as f:
100
+ json.dump(context, f, indent=2, ensure_ascii=False)
101
+ return context
102
+
103
+
104
+ def load_context() -> dict[str, Any]:
105
+ if CONTEXT_FILE.exists():
106
+ with open(CONTEXT_FILE, "r", encoding="utf-8") as f:
107
+ return json.load(f)
108
+ return build_context()
109
+
110
+
111
+ # ─────────── Token & Smart Context Management ───────────
112
+
113
+ def get_user_max_tokens() -> int:
114
+ try:
115
+ config_file = Path.home() / ".dulus" / "config.json"
116
+ if config_file.exists():
117
+ with open(config_file, "r", encoding="utf-8") as f:
118
+ data = json.loads(f.read())
119
+ return int(data.get("max_tokens", 8000))
120
+ except Exception:
121
+ pass
122
+ return 8000
123
+
124
+ MAX_CONTEXT_TOKENS = get_user_max_tokens()
125
+ COMPACT_THRESHOLD = 0.75
126
+ EMERGENCY_THRESHOLD = 0.90
127
+ COMPACTION_HISTORY: list[dict[str, Any]] = []
128
+
129
+
130
+ def estimate_tokens(text: str) -> int:
131
+ """Rough token estimation: ~4 chars per token for English/code."""
132
+ if not text:
133
+ return 0
134
+ return max(1, len(text) // 4)
135
+
136
+
137
+ def get_context_mode(token_pct: float) -> str:
138
+ if token_pct >= EMERGENCY_THRESHOLD:
139
+ return "emergency"
140
+ if token_pct >= COMPACT_THRESHOLD:
141
+ return "compact"
142
+ return "normal"
143
+
144
+
145
+ def record_compaction(reason: str, before_tokens: int, after_tokens: int) -> None:
146
+ COMPACTION_HISTORY.append({
147
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
148
+ "reason": reason,
149
+ "before_tokens": before_tokens,
150
+ "after_tokens": after_tokens,
151
+ "saved_tokens": before_tokens - after_tokens,
152
+ })
153
+ # Keep last 20
154
+ while len(COMPACTION_HISTORY) > 20:
155
+ COMPACTION_HISTORY.pop(0)
156
+
157
+
158
+ def build_smart_context() -> dict[str, Any]:
159
+ """Build context with token estimation and mode detection.
160
+
161
+ When mode is compact or emergency, applies rule-based compression
162
+ to keep context under budget. qwen2.5:3b is used for memory
163
+ summarization via mempalace_bridge, not for full-context compression.
164
+ """
165
+ ctx = build_context()
166
+ compact_text = get_compact_context()
167
+ tokens = estimate_tokens(compact_text)
168
+
169
+ try:
170
+ import sys
171
+ if "webchat_server" in sys.modules:
172
+ from webchat_server import STATE
173
+ if STATE and hasattr(STATE, "messages"):
174
+ for msg in STATE.messages:
175
+ tokens += estimate_tokens(str(msg))
176
+ except Exception:
177
+ pass
178
+
179
+ pct = round(tokens / MAX_CONTEXT_TOKENS, 4)
180
+ mode = get_context_mode(pct)
181
+
182
+ compressed_text = compact_text
183
+ compressor_method = "none"
184
+
185
+ if mode in ("compact", "emergency"):
186
+ target = 400 if mode == "compact" else 200
187
+ result = compress(compact_text, max_tokens=target)
188
+ compressed_text = result["compressed"]
189
+ compressor_method = result["method"]
190
+ record_compaction(
191
+ reason=f"auto-{mode}",
192
+ before_tokens=result["before_tokens"],
193
+ after_tokens=result["after_tokens"],
194
+ )
195
+
196
+ ctx["smart_context"] = {
197
+ "tokens_used": estimate_tokens(compressed_text),
198
+ "tokens_max": MAX_CONTEXT_TOKENS,
199
+ "usage_percent": pct,
200
+ "mode": mode,
201
+ "thresholds": {
202
+ "compact": COMPACT_THRESHOLD,
203
+ "emergency": EMERGENCY_THRESHOLD,
204
+ },
205
+ "compact_text": compressed_text,
206
+ "compressor_method": compressor_method,
207
+ "compaction_history": COMPACTION_HISTORY,
208
+ }
209
+
210
+ with open(CONTEXT_FILE, "w", encoding="utf-8") as f:
211
+ json.dump(ctx, f, indent=2, ensure_ascii=False)
212
+ return ctx
213
+
214
+
215
+ def force_compaction() -> dict[str, Any]:
216
+ """Manually force compression of the context."""
217
+ ctx = build_context()
218
+ compact_text = get_compact_context()
219
+ result = compress(compact_text, max_tokens=200)
220
+ compressed_text = result["compressed"]
221
+ compressor_method = result["method"]
222
+ record_compaction(
223
+ reason="manual-force",
224
+ before_tokens=result["before_tokens"],
225
+ after_tokens=result["after_tokens"],
226
+ )
227
+
228
+ # Actually trim the STATE.messages array so live token count decreases
229
+ try:
230
+ import sys
231
+ if "webchat_server" in sys.modules:
232
+ from webchat_server import STATE
233
+ if STATE and hasattr(STATE, "messages") and len(STATE.messages) > 10:
234
+ # Keep system block (first message) and the last ~6 messages
235
+ new_msgs = [STATE.messages[0]]
236
+
237
+ # Add a system message notifying of the compaction
238
+ new_msgs.append({
239
+ "role": "system",
240
+ "content": "[SYSTEM EVENT: Conversation history was forcefully compacted by the user. Older messages were purged.]"
241
+ })
242
+
243
+ # Handle the remaining messages carefully to avoid breaking API tool_call parity
244
+ raw_kept = STATE.messages[-6:]
245
+ sanitized_kept = []
246
+ for m in raw_kept:
247
+ # Drop tool responses entirely to avoid orphaned IDs
248
+ if m.get("role") == "tool":
249
+ continue
250
+
251
+ sm = dict(m)
252
+ # Strip any outgoing tool_calls from assistant messages
253
+ if "tool_calls" in sm:
254
+ del sm["tool_calls"]
255
+ if "tool_call_id" in sm:
256
+ del sm["tool_call_id"]
257
+
258
+ # If this leaves an assistant message with NO content, drop it too
259
+ if sm.get("role") == "assistant" and not sm.get("content"):
260
+ continue
261
+
262
+ # Ensure content is stringified if it was a list of chunks
263
+ if isinstance(sm.get("content"), list):
264
+ sm["content"] = "\n".join(
265
+ c.get("text", "") for c in sm["content"] if c.get("type", "") == "text"
266
+ )
267
+
268
+ sanitized_kept.append(sm)
269
+
270
+ new_msgs.extend(sanitized_kept)
271
+ STATE.messages = new_msgs
272
+ import webchat_server
273
+ webchat_server.broadcast_event("chat_cleared", {}) # Force UI refresh if needed
274
+ except Exception as e:
275
+ print("Compaction physical trim error:", e)
276
+
277
+ tokens = estimate_tokens(compressed_text)
278
+
279
+ try:
280
+ import sys
281
+ if "webchat_server" in sys.modules:
282
+ from webchat_server import STATE
283
+ if STATE and hasattr(STATE, "messages"):
284
+ for msg in STATE.messages:
285
+ tokens += estimate_tokens(str(msg))
286
+ except Exception:
287
+ pass
288
+
289
+ pct = round(tokens / MAX_CONTEXT_TOKENS, 4)
290
+ mode = get_context_mode(pct)
291
+
292
+ ctx["smart_context"] = {
293
+ "tokens_used": tokens,
294
+ "tokens_max": MAX_CONTEXT_TOKENS,
295
+ "usage_percent": pct,
296
+ "mode": "compact",
297
+ "thresholds": {"compact": COMPACT_THRESHOLD, "emergency": EMERGENCY_THRESHOLD},
298
+ "compact_text": compressed_text,
299
+ "compressor_method": compressor_method,
300
+ "compaction_history": COMPACTION_HISTORY,
301
+ }
302
+ with open(CONTEXT_FILE, "w", encoding="utf-8") as f:
303
+ json.dump(ctx, f, indent=2, ensure_ascii=False)
304
+ return ctx
305
+
306
+
307
+ def get_compact_context(max_tokens_estimate: int = 800) -> str:
308
+ """Generate ultra-dense text context for LLM prompt injection."""
309
+ ctx = build_context()
310
+ lines = [
311
+ "[DULUS CONTEXT]",
312
+ f"Session: {ctx['session']['mode']} | Agent: {ctx['session']['agent']} | User: {ctx['session']['user']}",
313
+ f"Project: {ctx['project']['name']} | Files: {ctx['project']['repo_stats']['files']} | Lines: {ctx['project']['repo_stats']['lines']}",
314
+ "Active Tasks:"
315
+ ]
316
+ for t in ctx["tasks"]["active"][:5]:
317
+ lines.append(f" • {t['id']} [{t['status']}] {t['subject']} ({t['owner']}, {t['phase']})")
318
+ lines.append("Agents:")
319
+ for a in ctx["agents"]:
320
+ marker = " [ACTIVE]" if a.get("active") else ""
321
+ lines.append(f" • {a.get('avatar', '🤖')} {a['name']} ({a['role']}) - {a['status']}{marker}")
322
+ lines.append("Recent Commits:")
323
+ for c in ctx["project"]["recent_commits"][:3]:
324
+ lines.append(f" • {c['hash']} {c['subject']} by {c['author']}")
325
+ # ── Persona activa (#19/#22) ──
326
+ lines.append(get_persona_compact_text())
327
+ # ── MemPalace real memories (#28) ──
328
+ lines.append(get_mempalace_compact_text())
329
+ return "\n".join(lines)
backend/githook.py ADDED
@@ -0,0 +1,166 @@
1
+ """Git hook management for Dulus."""
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ HOOK_TEMPLATE = '''#!/usr/bin/env python3
8
+ """Dulus Pre-Commit Hook — auto-installed by `dulus git-hook install`"""
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ BOLD = "\033[1m"
14
+ RED = "\033[91m"
15
+ GREEN = "\033[92m"
16
+ YELLOW = "\033[93m"
17
+ RESET = "\033[0m"
18
+ MAX_MB = 10
19
+
20
+ def log(msg, level="info"):
21
+ colors = {"error": RED, "ok": GREEN, "warn": YELLOW, "info": ""}
22
+ print(f"{colors.get(level, '')}{BOLD}[dulus-hook]{RESET} {msg}")
23
+
24
+ def get_staged():
25
+ r = subprocess.run(
26
+ ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
27
+ capture_output=True, text=True
28
+ )
29
+ return [f for f in r.stdout.strip().split("\\n") if f]
30
+
31
+ def check_trailing(files):
32
+ issues = []
33
+ for f in files:
34
+ p = Path(f)
35
+ if not p.exists() or p.stat().st_size > 1024*1024:
36
+ continue
37
+ try:
38
+ with open(f, "r", encoding="utf-8") as fh:
39
+ for i, line in enumerate(fh, 1):
40
+ if line.rstrip() != line.rstrip(" \\t"):
41
+ issues.append((f, i, "trailing whitespace"))
42
+ except Exception:
43
+ pass
44
+ return issues
45
+
46
+ def check_syntax(files):
47
+ issues = []
48
+ for f in files:
49
+ if not f.endswith(".py"):
50
+ continue
51
+ r = subprocess.run([sys.executable, "-m", "py_compile", f],
52
+ capture_output=True, text=True)
53
+ if r.returncode != 0:
54
+ issues.append((f, 0, f"syntax error: {r.stderr.strip()[:80]}"))
55
+ return issues
56
+
57
+ def check_size(files):
58
+ return [(f, 0, f"file > {MAX_MB}MB")
59
+ for f in files if Path(f).exists() and Path(f).stat().st_size > MAX_MB*1024*1024]
60
+
61
+ def check_tasks(files):
62
+ p = Path(".dulus-context/tasks.json")
63
+ if not p.exists():
64
+ return []
65
+ try:
66
+ import json
67
+ data = json.loads(p.read_text(encoding="utf-8"))
68
+ for t in data.get("tasks", []):
69
+ if t.get("status") == "completed" and t.get("blocked_by"):
70
+ return [(str(p), 0, f"Task #{t['id']} completed but has blockers")]
71
+ except Exception:
72
+ pass
73
+ return []
74
+
75
+ def main():
76
+ log("Running Dulus pre-commit checks...", "info")
77
+ files = get_staged()
78
+ if not files:
79
+ log("No staged files - skipping", "ok")
80
+ sys.exit(0)
81
+
82
+ checks = [
83
+ ("trailing whitespace", check_trailing),
84
+ ("Python syntax", check_syntax),
85
+ ("large files", check_size),
86
+ ("task consistency", check_tasks),
87
+ ]
88
+ all_issues = []
89
+ for name, fn in checks:
90
+ issues = fn(files)
91
+ if issues:
92
+ all_issues.extend(issues)
93
+ log(f"{name}: {len(issues)} issue(s)", "warn")
94
+
95
+ if all_issues:
96
+ log(f"Found {len(all_issues)} issue(s):", "error")
97
+ for f, line, msg in all_issues[:10]:
98
+ loc = f":{line}" if line else ""
99
+ print(f" {RED}[X]{RESET} {f}{loc} - {msg}")
100
+ if len(all_issues) > 10:
101
+ print(f" ... and {len(all_issues)-10} more")
102
+ log("Commit blocked. Fix or use --no-verify to bypass.", "error")
103
+ sys.exit(1)
104
+
105
+ log("All checks passed! Dulus out.", "ok")
106
+ sys.exit(0)
107
+
108
+ if __name__ == "__main__":
109
+ main()
110
+ '''
111
+
112
+
113
+ def _hook_path():
114
+ git_dir = Path(".git")
115
+ if not git_dir.exists():
116
+ return None
117
+ return git_dir / "hooks" / "pre-commit"
118
+
119
+
120
+ def is_dulus_hook(path: Path) -> bool:
121
+ return path.exists() and "Dulus Pre-Commit Hook" in path.read_text(encoding="utf-8")
122
+
123
+
124
+ def install():
125
+ hook = _hook_path()
126
+ if hook is None:
127
+ print("[X] Not a git repository.")
128
+ sys.exit(1)
129
+
130
+ if hook.exists():
131
+ backup = hook.with_suffix(".backup")
132
+ hook.rename(backup)
133
+ print(f"[BK] Backed up existing hook to {backup.name}")
134
+
135
+ hook.write_text(HOOK_TEMPLATE, encoding="utf-8")
136
+ try:
137
+ hook.chmod(0o755)
138
+ except Exception:
139
+ pass
140
+ print("[OK] Dulus pre-commit hook installed!")
141
+ print(" Checks: trailing whitespace / Python syntax / large files / task consistency")
142
+
143
+
144
+ def uninstall():
145
+ hook = _hook_path()
146
+ if hook is None:
147
+ print("[X] Not a git repository.")
148
+ sys.exit(1)
149
+
150
+ if is_dulus_hook(hook):
151
+ hook.unlink()
152
+ print("[OK] Dulus pre-commit hook removed.")
153
+ else:
154
+ print("[!] No Dulus hook found.")
155
+
156
+
157
+ def status():
158
+ hook = _hook_path()
159
+ if hook is None:
160
+ print("[X] Not a git repository.")
161
+ sys.exit(1)
162
+
163
+ if is_dulus_hook(hook):
164
+ print("[OK] Dulus pre-commit hook is active.")
165
+ else:
166
+ print("[--] Dulus pre-commit hook is NOT installed.")
backend/marketplace.py ADDED
@@ -0,0 +1,141 @@
1
+ """Plugin Marketplace — esqueleto y registry de plugins disponibles. (#20)
2
+
3
+ Este módulo maneja:
4
+ - Registry local de plugins conocidos
5
+ - Metadatos de plugins del marketplace
6
+ - Instalación simulada/remota de plugins
7
+ """
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ DATA_DIR = Path(__file__).parent.parent / "data"
13
+ DATA_DIR.mkdir(exist_ok=True)
14
+ MARKETPLACE_FILE = DATA_DIR / "marketplace.json"
15
+
16
+ # Plugins pre-registrados en el marketplace oficial
17
+ DEFAULT_REGISTRY: list[dict[str, Any]] = [
18
+ {
19
+ "id": "mp-themes",
20
+ "name": "Theme Switcher",
21
+ "version": "1.0.0",
22
+ "author": "Dulus Team",
23
+ "description": "Switch between Cyberpunk, Sakura, Sunset and Gold themes in real-time.",
24
+ "tags": ["ui", "themes", "dashboard"],
25
+ "downloads": 420,
26
+ "rating": 4.8,
27
+ "installed": False,
28
+ "source": "builtin",
29
+ },
30
+ {
31
+ "id": "mp-git-stats",
32
+ "name": "Git Stats Visualizer",
33
+ "version": "0.9.0",
34
+ "author": "kimi-code",
35
+ "description": "Visualize commit history, contributor stats and code churn.",
36
+ "tags": ["git", "visualization", "stats"],
37
+ "downloads": 128,
38
+ "rating": 4.5,
39
+ "installed": False,
40
+ "source": "community",
41
+ },
42
+ {
43
+ "id": "mp-agent-profiles",
44
+ "name": "Agent Profiles",
45
+ "version": "1.1.0",
46
+ "author": "kimi-code2",
47
+ "description": "Personas system with avatars, colors and identity per agent.",
48
+ "tags": ["agents", "personas", "identity"],
49
+ "downloads": 256,
50
+ "rating": 4.9,
51
+ "installed": False,
52
+ "source": "community",
53
+ },
54
+ {
55
+ "id": "mp-mempalace-bridge",
56
+ "name": "MemPalace Bridge",
57
+ "version": "0.5.0",
58
+ "author": "Dulus Team",
59
+ "description": "Connect Smart Context to MemPalace for infinite agent memory.",
60
+ "tags": ["memory", "integration", "mempalace"],
61
+ "downloads": 69,
62
+ "rating": 4.2,
63
+ "installed": False,
64
+ "source": "official",
65
+ },
66
+ ]
67
+
68
+
69
+ def load_registry() -> list[dict[str, Any]]:
70
+ if MARKETPLACE_FILE.exists():
71
+ try:
72
+ with open(MARKETPLACE_FILE, "r", encoding="utf-8") as f:
73
+ data = json.load(f)
74
+ if isinstance(data, list) and data:
75
+ return data
76
+ except Exception:
77
+ pass
78
+ save_registry(DEFAULT_REGISTRY)
79
+ return DEFAULT_REGISTRY.copy()
80
+
81
+
82
+ def save_registry(registry: list[dict[str, Any]]) -> None:
83
+ with open(MARKETPLACE_FILE, "w", encoding="utf-8") as f:
84
+ json.dump(registry, f, indent=2, ensure_ascii=False)
85
+
86
+
87
+ def get_plugin_by_id(plugin_id: str) -> dict[str, Any] | None:
88
+ for p in load_registry():
89
+ if p["id"] == plugin_id:
90
+ return p
91
+ return None
92
+
93
+
94
+ def install_plugin(plugin_id: str) -> dict[str, Any] | None:
95
+ registry = load_registry()
96
+ for p in registry:
97
+ if p["id"] == plugin_id:
98
+ p["installed"] = True
99
+ p["downloads"] = p.get("downloads", 0) + 1
100
+ save_registry(registry)
101
+ return p
102
+ return None
103
+
104
+
105
+ def uninstall_plugin(plugin_id: str) -> dict[str, Any] | None:
106
+ registry = load_registry()
107
+ for p in registry:
108
+ if p["id"] == plugin_id:
109
+ p["installed"] = False
110
+ save_registry(registry)
111
+ return p
112
+ return None
113
+
114
+
115
+ def search_plugins(query: str = "", tag: str = "") -> list[dict[str, Any]]:
116
+ results = load_registry()
117
+ if query:
118
+ q = query.lower()
119
+ results = [p for p in results if q in p["name"].lower() or q in p["description"].lower()]
120
+ if tag:
121
+ results = [p for p in results if tag in p.get("tags", [])]
122
+ return results
123
+
124
+
125
+ def get_stats() -> dict[str, Any]:
126
+ registry = load_registry()
127
+ return {
128
+ "total_plugins": len(registry),
129
+ "installed": sum(1 for p in registry if p["installed"]),
130
+ "total_downloads": sum(p.get("downloads", 0) for p in registry),
131
+ "categories": list(set(t for p in registry for t in p.get("tags", []))),
132
+ }
133
+
134
+
135
+ if __name__ == "__main__":
136
+ print("🛒 Dulus Plugin Marketplace v0.1")
137
+ print("=" * 40)
138
+ for p in load_registry():
139
+ status = "✅" if p["installed"] else "⬜"
140
+ print(f"{status} {p['name']} v{p['version']} — {p['description'][:50]}...")
141
+ print(f"\nStats: {json.dumps(get_stats(), indent=2)}")