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