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
@@ -0,0 +1,182 @@
1
+ """MemPalace Bridge (#28) — connects Dulus Context Manager with real MemPalace memories.
2
+
3
+ Design: The bridge reads from a JSON cache maintained by the AI runtime.
4
+ When the AI has tool access, it refreshes the cache with real memories.
5
+ When running standalone (server.py, dulus.py), it reads the cached data.
6
+
7
+ This avoids requiring tool-injected globals inside Python subprocesses.
8
+ """
9
+ import json
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ DULUS_DIR = Path(__file__).parent.parent
16
+ DATA_DIR = DULUS_DIR / "data"
17
+ DATA_DIR.mkdir(exist_ok=True)
18
+ MEMCACHE_FILE = DATA_DIR / "mempalace_cache.json"
19
+ MEMCACHE_TTL_SECONDS = 120 # Refresh every 2 minutes
20
+
21
+
22
+ def _parse_memory_document(doc: str) -> dict[str, Any]:
23
+ """Parse a memory markdown document with YAML frontmatter."""
24
+ lines = doc.strip().split("\n")
25
+ meta: dict[str, Any] = {}
26
+ body_lines: list[str] = []
27
+ in_frontmatter = False
28
+ frontmatter_delims = 0
29
+
30
+ for line in lines:
31
+ if line.strip() == "---":
32
+ frontmatter_delims += 1
33
+ in_frontmatter = frontmatter_delims == 1
34
+ if frontmatter_delims >= 2:
35
+ in_frontmatter = False
36
+ continue
37
+ if in_frontmatter:
38
+ if ":" in line:
39
+ key, val = line.split(":", 1)
40
+ meta[key.strip()] = val.strip()
41
+ else:
42
+ body_lines.append(line)
43
+
44
+ body = "\n".join(body_lines).strip()
45
+ if len(body) > 350:
46
+ body = body[:350] + "..."
47
+ return {
48
+ "name": meta.get("name", "unnamed"),
49
+ "description": meta.get("description", ""),
50
+ "type": meta.get("type", "unknown"),
51
+ "hall": meta.get("hall", "general"),
52
+ "confidence": float(meta.get("confidence", "0.8")) if meta.get("confidence") else 0.8,
53
+ "body": body,
54
+ }
55
+
56
+
57
+ def refresh_cache(raw_memories: list[dict[str, Any]], wings: list[str] | None = None) -> dict[str, Any]:
58
+ """Called by the AI runtime when tools are available to refresh memory cache.
59
+
60
+ Args:
61
+ raw_memories: List of memory items from wakeup_context/search_memory tools.
62
+ wings: Optional list of wing names discovered.
63
+ """
64
+ memories: list[dict[str, Any]] = []
65
+ seen: set[str] = set()
66
+
67
+ for item in raw_memories:
68
+ if not isinstance(item, dict):
69
+ continue
70
+ content = item.get("content", "")
71
+ if not content:
72
+ continue
73
+ parsed = _parse_memory_document(content)
74
+ parsed["wing"] = item.get("wing", "memory")
75
+ parsed["source"] = item.get("source", "wakeup")
76
+ if "relevance" in item:
77
+ parsed["relevance"] = item["relevance"]
78
+ key = parsed["name"]
79
+ if key and key in seen:
80
+ continue
81
+ if key:
82
+ seen.add(key)
83
+ memories.append(parsed)
84
+
85
+ # Sort by confidence desc
86
+ memories.sort(key=lambda x: x.get("confidence", 0), reverse=True)
87
+
88
+ data = {
89
+ "connected": True,
90
+ "wings": wings or ["memory", "hija_palace"],
91
+ "count": len(memories),
92
+ "memories": memories[:15], # Cap to avoid bloat
93
+ "_cached_at": time.time(),
94
+ }
95
+ try:
96
+ with open(MEMCACHE_FILE, "w", encoding="utf-8") as f:
97
+ json.dump(data, f, indent=2, ensure_ascii=False)
98
+ except Exception as e:
99
+ print(f"[MemPalace Bridge] Cache write failed: {e}")
100
+ return data
101
+
102
+
103
+ def load_cache() -> dict[str, Any]:
104
+ """Load memory cache from disk. Returns empty-safe dict."""
105
+ if not MEMCACHE_FILE.exists():
106
+ return {"connected": False, "wings": [], "count": 0, "memories": []}
107
+ try:
108
+ with open(MEMCACHE_FILE, "r", encoding="utf-8") as f:
109
+ data = json.load(f)
110
+ # Validate shape
111
+ if "memories" not in data:
112
+ data["memories"] = []
113
+ return data
114
+ except Exception:
115
+ return {"connected": False, "wings": [], "count": 0, "memories": []}
116
+
117
+
118
+ def get_memories(max_items: int = 10) -> list[dict[str, Any]]:
119
+ """Get deduplicated, ranked memories for context injection."""
120
+ data = load_cache()
121
+ return data.get("memories", [])[:max_items]
122
+
123
+
124
+ def _get_summary(name: str, body: str) -> str:
125
+ """Get summary for a memory body — uses qwen if available, else truncates."""
126
+ from backend.compressor import summarize_memory
127
+ summary = summarize_memory(name, body)
128
+ return summary.replace("\n", " ").strip()
129
+
130
+
131
+ def get_mempalace_compact_text(max_memories: int = 6) -> str:
132
+ """Generate ultra-dense MemPalace context for prompt injection.
133
+
134
+ Uses qwen2.5:3b via Ollama to summarize memory bodies when available.
135
+ Falls back to truncation if Ollama is offline.
136
+ """
137
+ data = load_cache()
138
+ if not data.get("connected") or not data.get("memories"):
139
+ return "[MemPalace: disconnected — run refresh from AI runtime]"
140
+ wings = data.get("wings", [])
141
+ lines = [f"[MemPalace: {data['count']} memories | Wings: {', '.join(wings[:4])}]"]
142
+ for m in data["memories"][:max_memories]:
143
+ name = m.get("name", "?")
144
+ hall = m.get("hall", "?")
145
+ body = m.get("body", "").replace("\n", " ").strip()
146
+ # Use qwen summarization for long bodies
147
+ if len(body) > 120:
148
+ body = _get_summary(name, body)
149
+ if len(body) > 90:
150
+ body = body[:90] + "..."
151
+ lines.append(f" • [{hall}] {name}: {body}")
152
+ return "\n".join(lines)
153
+
154
+
155
+ def get_mempalace_context_block() -> dict[str, Any]:
156
+ """Structured block for JSON context (used by build_context)."""
157
+ data = load_cache()
158
+ return {
159
+ "connected": data.get("connected", False),
160
+ "wings": data.get("wings", []),
161
+ "count": data.get("count", 0),
162
+ "memories": [
163
+ {
164
+ "name": m.get("name"),
165
+ "hall": m.get("hall"),
166
+ "type": m.get("type"),
167
+ "description": m.get("description", "")[:120],
168
+ "confidence": m.get("confidence"),
169
+ }
170
+ for m in data.get("memories", [])[:8]
171
+ ],
172
+ }
173
+
174
+
175
+ if __name__ == "__main__":
176
+ # Show current cache status
177
+ data = load_cache()
178
+ print(f"[MemPalace Bridge] Connected: {data.get('connected')}")
179
+ print(f" Wings: {data.get('wings', [])}")
180
+ print(f" Memories: {data.get('count', 0)}")
181
+ print("\nCompact text:")
182
+ print(get_mempalace_compact_text())
backend/personas.py ADDED
@@ -0,0 +1,297 @@
1
+ """Sistema de Personas (#19 + #22) — perfiles de agente con identidad visual y comportamiento.
2
+
3
+ Cada persona define:
4
+ - Identidad: nombre, avatar, color, rol
5
+ - Comportamiento: estilo de respuesta, tono, fragmento de system prompt
6
+ - Metadatos: creador, versión, tags
7
+
8
+ Uso:
9
+ from backend.personas import get_persona, get_all_personas, set_active_persona
10
+ persona = get_persona("kimi-code3")
11
+ print(persona.avatar) # 🦅
12
+ """
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from backend.mempalace_bridge import load_cache
19
+
20
+ DATA_DIR = Path(__file__).parent.parent / "data"
21
+ DATA_DIR.mkdir(exist_ok=True)
22
+ PERSONAS_FILE = DATA_DIR / "personas.json"
23
+ ACTIVE_FILE = DATA_DIR / "active_persona.json"
24
+
25
+ # Fallback agent colors from theme pack (avoid circular import)
26
+ _DEFAULT_COLORS = {
27
+ "dulus": "#ff6b1f",
28
+ "kimi-code": "#7ab6ff",
29
+ "kimi-code2": "#b388ff",
30
+ "kimi-code3": "#7cffb5",
31
+ "system": "#888888",
32
+ }
33
+
34
+ DEFAULT_PERSONAS: list[dict[str, Any]] = [
35
+ {
36
+ "id": "dulus",
37
+ "name": "Dulus",
38
+ "avatar": "[F]",
39
+ "role": "primary",
40
+ "color": "#ff6b1f",
41
+ "status": "active",
42
+ "tone": "dominicano_coder",
43
+ "language": "es_DO",
44
+ "system_prompt_fragment": (
45
+ "Eres Dulus, el command center de KevRojo. Hablas en español dominicano "
46
+ "con jerga tech. Eres proactivo, directo, y no pierdes tiempo. "
47
+ "Usas emoji 🔥🦅💜🇩🇴. Piensas en inglés, respondes en español DO."
48
+ ),
49
+ "metadata": {
50
+ "version": "1.0.0",
51
+ "created_by": "system",
52
+ "tags": ["core", "commander", "es_DO"],
53
+ "description": "Agente principal y orquestador del Command Center.",
54
+ },
55
+ },
56
+ {
57
+ "id": "kimi-code",
58
+ "name": "kimi-code",
59
+ "avatar": "[K1]",
60
+ "role": "coder",
61
+ "color": "#7ab6ff",
62
+ "status": "idle",
63
+ "tone": "eficiente_silencioso",
64
+ "language": "es_DO",
65
+ "system_prompt_fragment": (
66
+ "Eres kimi-code, especialista en romper código rápido. "
67
+ "Hablas poco pero haces mucho. Español dominicano técnico. "
68
+ "Te enfocas en backend, arquitectura y fixes."
69
+ ),
70
+ "metadata": {
71
+ "version": "1.0.0",
72
+ "created_by": "system",
73
+ "tags": ["coder", "backend", "es_DO"],
74
+ "description": "Backend specialist. Rompe código, no corazones.",
75
+ },
76
+ },
77
+ {
78
+ "id": "kimi-code2",
79
+ "name": "kimi-code2",
80
+ "avatar": "[K2]",
81
+ "role": "designer",
82
+ "color": "#b388ff",
83
+ "status": "idle",
84
+ "tone": "creativo_visual",
85
+ "language": "es_DO",
86
+ "system_prompt_fragment": (
87
+ "Eres kimi-code2, especialista en UI/UX, temas visuales y dashboards. "
88
+ "Hablas dominicano con flow creativo. Te encantan los colores, las animaciones "
89
+ "y que todo se vea premium."
90
+ ),
91
+ "metadata": {
92
+ "version": "1.0.0",
93
+ "created_by": "system",
94
+ "tags": ["designer", "ui", "frontend", "es_DO"],
95
+ "description": "UI/UX specialist. Temas, dashboards y visuales.",
96
+ },
97
+ },
98
+ {
99
+ "id": "kimi-code3",
100
+ "name": "kimi-code3",
101
+ "avatar": "[K3]",
102
+ "role": "integrator",
103
+ "color": "#7cffb5",
104
+ "status": "idle",
105
+ "tone": "proactivo_integrador",
106
+ "language": "es_DO",
107
+ "system_prompt_fragment": (
108
+ "Eres kimi-code3, el integrador. Conectas sistemas, haces bridges, "
109
+ "escribes tests y no dejas cables sueltos. Dominicana tech, directo, "
110
+ "sin miedo a tocar lo que otros dejaron a medias."
111
+ ),
112
+ "metadata": {
113
+ "version": "1.0.0",
114
+ "created_by": "system",
115
+ "tags": ["integrator", "tests", "devops", "es_DO"],
116
+ "description": "Integrator & tester. Une cables sueltos.",
117
+ },
118
+ },
119
+ ]
120
+
121
+
122
+ def _ensure_defaults() -> None:
123
+ """Seed personas if none exist."""
124
+ if not PERSONAS_FILE.exists():
125
+ save_personas(DEFAULT_PERSONAS.copy())
126
+
127
+
128
+ def load_personas() -> list[dict[str, Any]]:
129
+ _ensure_defaults()
130
+ try:
131
+ with open(PERSONAS_FILE, "r", encoding="utf-8") as f:
132
+ data = json.load(f)
133
+ if isinstance(data, list):
134
+ return data
135
+ except Exception:
136
+ pass
137
+ return DEFAULT_PERSONAS.copy()
138
+
139
+
140
+ def save_personas(personas: list[dict[str, Any]]) -> None:
141
+ with open(PERSONAS_FILE, "w", encoding="utf-8") as f:
142
+ json.dump(personas, f, indent=2, ensure_ascii=False)
143
+
144
+
145
+ def get_persona(pid: str) -> dict[str, Any] | None:
146
+ for p in load_personas():
147
+ if p.get("id") == pid or p.get("name") == pid:
148
+ return p
149
+ return None
150
+
151
+
152
+ def get_all_personas() -> list[dict[str, Any]]:
153
+ return load_personas()
154
+
155
+
156
+ def create_persona(data: dict[str, Any]) -> dict[str, Any]:
157
+ personas = load_personas()
158
+ pid = data.get("id", f"p-{len(personas)+1:03d}")
159
+ # Prevent duplicate IDs
160
+ if any(p.get("id") == pid for p in personas):
161
+ pid = f"{pid}-{int(time.time())}"
162
+ persona = {
163
+ "id": pid,
164
+ "name": data.get("name", "Unnamed"),
165
+ "avatar": data.get("avatar", "🤖"),
166
+ "role": data.get("role", "assistant"),
167
+ "color": data.get("color", "#cccccc"),
168
+ "status": data.get("status", "idle"),
169
+ "tone": data.get("tone", "neutral"),
170
+ "language": data.get("language", "es"),
171
+ "system_prompt_fragment": data.get("system_prompt_fragment", ""),
172
+ "metadata": data.get("metadata", {}),
173
+ }
174
+ personas.append(persona)
175
+ save_personas(personas)
176
+ return persona
177
+
178
+
179
+ def update_persona(pid: str, data: dict[str, Any]) -> dict[str, Any] | None:
180
+ personas = load_personas()
181
+ for i, p in enumerate(personas):
182
+ if p.get("id") == pid:
183
+ # Don't allow changing the id
184
+ data.pop("id", None)
185
+ personas[i].update(data)
186
+ save_personas(personas)
187
+ return personas[i]
188
+ return None
189
+
190
+
191
+ def delete_persona(pid: str) -> bool:
192
+ personas = load_personas()
193
+ filtered = [p for p in personas if p.get("id") != pid]
194
+ if len(filtered) < len(personas):
195
+ save_personas(filtered)
196
+ return True
197
+ return False
198
+
199
+
200
+ # ── Active Persona Session Management ──
201
+
202
+ def get_active_persona() -> dict[str, Any]:
203
+ """Return the currently active persona, defaulting to Dulus."""
204
+ if ACTIVE_FILE.exists():
205
+ try:
206
+ with open(ACTIVE_FILE, "r", encoding="utf-8") as f:
207
+ active = json.load(f)
208
+ pid = active.get("id", "dulus")
209
+ p = get_persona(pid)
210
+ if p:
211
+ return p
212
+ except Exception:
213
+ pass
214
+ return get_persona("dulus") or DEFAULT_PERSONAS[0]
215
+
216
+
217
+ def set_active_persona(pid: str) -> dict[str, Any] | None:
218
+ """Set active persona by ID, ensuring only one is active."""
219
+ p = get_persona(pid)
220
+ if not p:
221
+ return None
222
+ with open(ACTIVE_FILE, "w", encoding="utf-8") as f:
223
+ json.dump({"id": pid, "name": p["name"], "since": time.strftime("%Y-%m-%dT%H:%M:%S")}, f, indent=2)
224
+ # Deactivate all others, activate chosen
225
+ for persona in load_personas():
226
+ if persona.get("id") == pid:
227
+ update_persona(pid, {"status": "active"})
228
+ elif persona.get("status") == "active":
229
+ update_persona(persona["id"], {"status": "idle"})
230
+ return get_persona(pid)
231
+
232
+
233
+ def get_personas_summary() -> list[dict[str, Any]]:
234
+ """Lightweight list for context injection and dashboards."""
235
+ return [
236
+ {
237
+ "id": p["id"],
238
+ "name": p["name"],
239
+ "avatar": p.get("avatar", "🤖"),
240
+ "role": p.get("role", "assistant"),
241
+ "color": p.get("color", "#ccc"),
242
+ "status": p.get("status", "idle"),
243
+ }
244
+ for p in load_personas()
245
+ ]
246
+
247
+
248
+ def get_persona_context_block() -> dict[str, Any]:
249
+ """Structured block for JSON context (used by build_context)."""
250
+ active = get_active_persona()
251
+ all_p = get_personas_summary()
252
+ return {
253
+ "active": active["id"],
254
+ "active_name": active["name"],
255
+ "active_avatar": active.get("avatar", "🤖"),
256
+ "active_color": active.get("color", "#ccc"),
257
+ "active_prompt_fragment": active.get("system_prompt_fragment", ""),
258
+ "personas": all_p,
259
+ }
260
+
261
+
262
+ def get_personas_for_context() -> list[dict[str, Any]]:
263
+ """Return persona list for context.py compatibility."""
264
+ active_id = get_active_persona().get("id")
265
+ return [
266
+ {
267
+ "name": p["name"],
268
+ "role": p.get("role", "assistant"),
269
+ "color": p.get("color", "#ccc"),
270
+ "status": p.get("status", "idle"),
271
+ "avatar": p.get("avatar", "🤖"),
272
+ "active": p.get("id") == active_id,
273
+ }
274
+ for p in load_personas()
275
+ ]
276
+
277
+
278
+ def get_persona_compact_text(max_chars: int = 200) -> str:
279
+ """Ultra-dense active persona text for prompt injection."""
280
+ p = get_active_persona()
281
+ fragment = p.get("system_prompt_fragment", "")
282
+ if len(fragment) > max_chars:
283
+ fragment = fragment[:max_chars].rsplit(" ", 1)[0] + "..."
284
+ return (
285
+ f"[Persona: {p.get('avatar', '🤖')} {p['name']} | {p.get('role')} | {p.get('tone')} | {p.get('language')}]\n"
286
+ f" {fragment}"
287
+ )
288
+
289
+
290
+ if __name__ == "__main__":
291
+ print("🎭 Dulus Personas System")
292
+ print("=" * 40)
293
+ for p in get_all_personas():
294
+ print(f" {p['avatar']} {p['name']} ({p['id']}) — {p['role']} [{p['status']}]")
295
+ print(f" Color: {p['color']} | Tone: {p['tone']} | Lang: {p['language']}")
296
+ print(f" {p['metadata'].get('description', '')}")
297
+ print(f"\n🟢 Active: {get_active_persona()['name']}")
backend/plugins.py ADDED
@@ -0,0 +1,222 @@
1
+ """Hot-loadable plugin system for Dulus."""
2
+ import importlib.util
3
+ import json
4
+ import sys
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ PLUGINS_DIR = Path(__file__).parent.parent / "plugins"
11
+ PLUGINS_DIR.mkdir(exist_ok=True)
12
+
13
+ _hooks: dict[str, list[Callable]] = {}
14
+ _registry: dict[str, dict[str, Any]] = {}
15
+ _snapshots: dict[str, float] = {}
16
+ _watcher_thread: threading.Thread | None = None
17
+ _watcher_stop = threading.Event()
18
+ _watch_interval = 2.0
19
+
20
+
21
+ def register_hook(name: str, fn: Callable):
22
+ _hooks.setdefault(name, []).append(fn)
23
+
24
+
25
+ def unregister_plugin_hooks(name: str):
26
+ """Remove all hooks registered by a given plugin name."""
27
+ mod_name = f"dulus.plugins.{name}"
28
+ for hook_name, fns in list(_hooks.items()):
29
+ _hooks[hook_name] = [fn for fn in fns if getattr(fn, "__module__", None) != mod_name]
30
+ if not _hooks[hook_name]:
31
+ del _hooks[hook_name]
32
+
33
+
34
+ def trigger_hook(name: str, *args, **kwargs) -> list[Any]:
35
+ results = []
36
+ for fn in _hooks.get(name, []):
37
+ try:
38
+ results.append(fn(*args, **kwargs))
39
+ except Exception as e:
40
+ results.append({"error": str(e), "plugin": getattr(fn, "__module__", "unknown")})
41
+ return results
42
+
43
+
44
+ def discover_plugins() -> list[Path]:
45
+ return sorted(PLUGINS_DIR.glob("*.py"))
46
+
47
+
48
+ def load_plugin(path: Path) -> dict[str, Any]:
49
+ name = path.stem
50
+ # If already loaded, unload first for clean hot-reload
51
+ if name in _registry:
52
+ unload_plugin(name)
53
+
54
+ # Invalidate bytecode cache so edits are picked up immediately
55
+ cache_file = importlib.util.cache_from_source(str(path))
56
+ try:
57
+ Path(cache_file).unlink(missing_ok=True)
58
+ except Exception:
59
+ pass
60
+
61
+ spec = importlib.util.spec_from_file_location(f"dulus.plugins.{name}", path)
62
+ if not spec or not spec.loader:
63
+ return {"name": name, "status": "error", "error": "Cannot load spec"}
64
+ mod = importlib.util.module_from_spec(spec)
65
+ sys.modules[spec.name] = mod
66
+ try:
67
+ spec.loader.exec_module(mod)
68
+ except Exception as e:
69
+ return {"name": name, "status": "error", "error": str(e)}
70
+
71
+ meta = getattr(mod, "__plugin_meta__", {"name": name, "version": "0.0.1"})
72
+ meta["status"] = "loaded"
73
+ meta["module"] = mod
74
+ _registry[name] = meta
75
+
76
+ # Auto-register hooks if plugin exposes them
77
+ hooks = getattr(mod, "__hooks__", {})
78
+ for hook_name, fn in hooks.items():
79
+ register_hook(hook_name, fn)
80
+
81
+ return meta
82
+
83
+
84
+ def unload_plugin(name: str) -> bool:
85
+ """Unload a plugin by name, removing hooks and registry entry."""
86
+ if name not in _registry:
87
+ return False
88
+ unregister_plugin_hooks(name)
89
+ mod_name = f"dulus.plugins.{name}"
90
+ if mod_name in sys.modules:
91
+ del sys.modules[mod_name]
92
+ del _registry[name]
93
+ return True
94
+
95
+
96
+ def reload_plugin(path: Path) -> dict[str, Any]:
97
+ return load_plugin(path)
98
+
99
+
100
+ def load_all_plugins() -> list[dict[str, Any]]:
101
+ return [load_plugin(p) for p in discover_plugins()]
102
+
103
+
104
+ def get_plugin_info() -> list[dict[str, Any]]:
105
+ """Return serializable plugin metadata (no module objects)."""
106
+ return [
107
+ {"name": k, "version": v.get("version", "?"), "status": v["status"]}
108
+ for k, v in _registry.items()
109
+ ]
110
+
111
+
112
+ def get_plugin_registry() -> dict[str, dict[str, Any]]:
113
+ """Return raw registry (includes module objects; not JSON-safe)."""
114
+ return _registry
115
+
116
+
117
+ # ── Hot-Reload Watcher ──
118
+
119
+ def _take_snapshot() -> dict[str, float]:
120
+ snaps = {}
121
+ for p in discover_plugins():
122
+ try:
123
+ snaps[p.stem] = p.stat().st_mtime
124
+ except OSError:
125
+ pass
126
+ return snaps
127
+
128
+
129
+ def _scan_changes() -> tuple[list[str], list[str], list[str]]:
130
+ """Return (added, modified, removed) plugin names."""
131
+ global _snapshots
132
+ current = _take_snapshot()
133
+ added = [name for name in current if name not in _snapshots]
134
+ modified = [name for name in current if name in _snapshots and current[name] != _snapshots[name]]
135
+ removed = [name for name in _snapshots if name not in current]
136
+ _snapshots = current
137
+ return added, modified, removed
138
+
139
+
140
+ def _watcher_loop(broadcast_fn: Callable | None = None):
141
+ """Daemon thread loop: poll plugins/ dir for changes."""
142
+ global _snapshots
143
+ _snapshots = _take_snapshot()
144
+ while not _watcher_stop.is_set():
145
+ time.sleep(_watch_interval)
146
+ added, modified, removed = _scan_changes()
147
+ changes: list[dict] = []
148
+ for name in added:
149
+ path = PLUGINS_DIR / f"{name}.py"
150
+ result = load_plugin(path)
151
+ changes.append({"event": "added", "name": name, "status": result["status"]})
152
+ for name in modified:
153
+ path = PLUGINS_DIR / f"{name}.py"
154
+ result = load_plugin(path)
155
+ changes.append({"event": "modified", "name": name, "status": result["status"]})
156
+ for name in removed:
157
+ unload_plugin(name)
158
+ changes.append({"event": "removed", "name": name, "status": "unloaded"})
159
+ if changes and broadcast_fn:
160
+ try:
161
+ broadcast_fn("plugin_change", {"changes": changes})
162
+ except Exception:
163
+ pass
164
+
165
+
166
+ def start_watcher(broadcast_fn: Callable | None = None) -> bool:
167
+ """Start the plugins directory watcher. Returns False if already running."""
168
+ global _watcher_thread, _watcher_stop
169
+ if _watcher_thread is not None and _watcher_thread.is_alive():
170
+ return False
171
+ _watcher_stop.clear()
172
+ _snapshots = _take_snapshot()
173
+ _watcher_thread = threading.Thread(
174
+ target=_watcher_loop,
175
+ args=(broadcast_fn,),
176
+ daemon=True,
177
+ name="plugin-watcher"
178
+ )
179
+ _watcher_thread.start()
180
+ return True
181
+
182
+
183
+ def stop_watcher() -> bool:
184
+ """Stop the plugins directory watcher."""
185
+ global _watcher_thread
186
+ if _watcher_thread is None or not _watcher_thread.is_alive():
187
+ return False
188
+ _watcher_stop.set()
189
+ _watcher_thread.join(timeout=5)
190
+ _watcher_thread = None
191
+ return True
192
+
193
+
194
+ def watcher_status() -> dict[str, Any]:
195
+ return {
196
+ "running": _watcher_thread is not None and _watcher_thread.is_alive(),
197
+ "interval": _watch_interval,
198
+ "plugins_tracked": len(_snapshots),
199
+ }
200
+
201
+
202
+ # Example plugin template
203
+ def create_example_plugin():
204
+ example = PLUGINS_DIR / "example.py"
205
+ if example.exists():
206
+ return
207
+ example.write_text('''"""Example Dulus Plugin."""
208
+ __plugin_meta__ = {
209
+ "name": "example",
210
+ "version": "1.0.0",
211
+ "description": "Counts tasks by status",
212
+ "author": "Dulus"
213
+ }
214
+
215
+ def count_by_status(tasks):
216
+ from collections import Counter
217
+ return dict(Counter(t["status"] for t in tasks))
218
+
219
+ __hooks__ = {
220
+ "task_stats": count_by_status
221
+ }
222
+ ''', encoding="utf-8")