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
|
@@ -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")
|