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
batch_api.py ADDED
@@ -0,0 +1,307 @@
1
+ """
2
+ Dulus Batch API — provider-agnostic OpenAI-compatible batch processing.
3
+
4
+ Works with any provider that supports the OpenAI Batch API format:
5
+ - OpenAI (api.openai.com)
6
+ - Kimi/Moonshot (api.moonshot.ai)
7
+ - Any OpenAI-compatible endpoint
8
+
9
+ Usage:
10
+ mgr = BatchManager(api_key="sk-...", base_url="https://api.openai.com")
11
+ jsonl = mgr.prepare_jsonl(["prompt1", "prompt2"], model="gpt-4o-mini")
12
+ file_id = mgr.upload_file(jsonl)
13
+ batch_id = mgr.create_batch(file_id)
14
+ """
15
+
16
+ import json
17
+ import urllib.request
18
+ import os
19
+ import time
20
+ from typing import Optional, List, Dict, Any
21
+
22
+ # ── Defaults ─────────────────────────────────────────────────────────────────
23
+
24
+ OPENAI_BASE_URL = "https://api.openai.com"
25
+ KIMI_BASE_URL = "https://api.moonshot.ai"
26
+
27
+ BATCH_SYSTEM_PROMPT = (
28
+ "You are Dulus, an AI assistant. You are processing a batch request — "
29
+ "respond directly to each task. Be concise, precise, and complete. "
30
+ "Output in the same language the user writes in. "
31
+ "No tool calls available — just answer with text."
32
+ )
33
+
34
+
35
+ # ── BatchManager ─────────────────────────────────────────────────────────────
36
+
37
+ class BatchManager:
38
+ """Provider-agnostic manager for the OpenAI-compatible Batch API."""
39
+
40
+ def __init__(self, api_key: str, base_url: str = OPENAI_BASE_URL):
41
+ self.api_key = api_key
42
+ self.base_url = base_url.rstrip("/")
43
+
44
+ def _headers(self, content_type: str = "application/json") -> dict:
45
+ return {
46
+ "Content-Type": content_type,
47
+ "Authorization": f"Bearer {self.api_key}",
48
+ }
49
+
50
+ # ── JSONL preparation ────────────────────────────────────────────────
51
+
52
+ def prepare_jsonl(
53
+ self,
54
+ prompts: List[str],
55
+ model: str = "gpt-4o-mini",
56
+ system_prompt: str = None,
57
+ endpoint: str = "/v1/chat/completions",
58
+ ) -> str:
59
+ """Convert a list of prompts into JSONL content for the Batch API.
60
+
61
+ Args:
62
+ prompts: List of user prompts.
63
+ model: Model name (provider-specific).
64
+ system_prompt: Defaults to BATCH_SYSTEM_PROMPT. Pass "" to omit.
65
+ endpoint: API endpoint for each request.
66
+ """
67
+ if system_prompt is None:
68
+ system_prompt = BATCH_SYSTEM_PROMPT
69
+
70
+ lines = []
71
+ ts = int(time.time())
72
+ for i, prompt in enumerate(prompts):
73
+ messages = []
74
+ if system_prompt:
75
+ messages.append({"role": "system", "content": system_prompt})
76
+ messages.append({"role": "user", "content": prompt})
77
+
78
+ request = {
79
+ "custom_id": f"req_{ts}_{i}",
80
+ "method": "POST",
81
+ "url": endpoint,
82
+ "body": {
83
+ "model": model,
84
+ "messages": messages,
85
+ },
86
+ }
87
+ lines.append(json.dumps(request, ensure_ascii=False))
88
+ return "\n".join(lines)
89
+
90
+ # ── File upload (multipart/form-data) ────────────────────────────────
91
+
92
+ def upload_file(self, jsonl_content: str, filename: str = "batch_input.jsonl") -> str:
93
+ """Upload JSONL content and return the file_id."""
94
+ url = f"{self.base_url}/v1/files"
95
+ boundary = f"----DulusBatch{int(time.time())}"
96
+
97
+ parts = []
98
+ # purpose field
99
+ parts.append(f"--{boundary}\r\n"
100
+ f'Content-Disposition: form-data; name="purpose"\r\n\r\n'
101
+ f"batch")
102
+ # file field
103
+ parts.append(f"--{boundary}\r\n"
104
+ f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
105
+ f"Content-Type: application/octet-stream\r\n\r\n"
106
+ f"{jsonl_content}")
107
+ parts.append(f"--{boundary}--\r\n")
108
+
109
+ full_body = "\r\n".join(parts).encode("utf-8")
110
+
111
+ req = urllib.request.Request(
112
+ url,
113
+ data=full_body,
114
+ headers={
115
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
116
+ "Authorization": f"Bearer {self.api_key}",
117
+ },
118
+ method="POST",
119
+ )
120
+
121
+ with urllib.request.urlopen(req) as resp:
122
+ return json.loads(resp.read().decode("utf-8"))["id"]
123
+
124
+ # ── Batch lifecycle ──────────────────────────────────────────────────
125
+
126
+ def create_batch(
127
+ self,
128
+ file_id: str,
129
+ endpoint: str = "/v1/chat/completions",
130
+ completion_window: str = "24h",
131
+ ) -> str:
132
+ """Create a batch from an uploaded file. Returns batch_id."""
133
+ url = f"{self.base_url}/v1/batches"
134
+ payload = {
135
+ "input_file_id": file_id,
136
+ "endpoint": endpoint,
137
+ "completion_window": completion_window,
138
+ }
139
+ req = urllib.request.Request(
140
+ url,
141
+ data=json.dumps(payload).encode("utf-8"),
142
+ headers=self._headers(),
143
+ method="POST",
144
+ )
145
+ with urllib.request.urlopen(req) as resp:
146
+ return json.loads(resp.read().decode("utf-8"))["id"]
147
+
148
+ def retrieve_batch(self, batch_id: str) -> Dict[str, Any]:
149
+ """Get batch status/info."""
150
+ url = f"{self.base_url}/v1/batches/{batch_id}"
151
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
152
+ with urllib.request.urlopen(req) as resp:
153
+ return json.loads(resp.read().decode("utf-8"))
154
+
155
+ def cancel_batch(self, batch_id: str) -> Dict[str, Any]:
156
+ """Cancel a running batch."""
157
+ url = f"{self.base_url}/v1/batches/{batch_id}/cancel"
158
+ req = urllib.request.Request(url, headers=self._headers(), method="POST")
159
+ with urllib.request.urlopen(req) as resp:
160
+ return json.loads(resp.read().decode("utf-8"))
161
+
162
+ def get_file_content(self, file_id: str) -> str:
163
+ """Download file content (e.g. batch results)."""
164
+ url = f"{self.base_url}/v1/files/{file_id}/content"
165
+ req = urllib.request.Request(url, headers=self._headers(), method="GET")
166
+ with urllib.request.urlopen(req) as resp:
167
+ return resp.read().decode("utf-8")
168
+
169
+
170
+ # ── Backward compat alias ────────────────────────────────────────────────────
171
+ KimiBatchManager = BatchManager # old name still works
172
+
173
+
174
+ # ── Local job persistence ────────────────────────────────────────────────────
175
+
176
+ _JOBS_DIR = os.path.join(os.path.expanduser("~"), ".dulus", "jobs")
177
+
178
+
179
+ def save_batch_job(batch_id: str, description: str = "", file_id: str = "",
180
+ provider: str = "unknown") -> str:
181
+ """Save a batch job record locally in ~/.dulus/jobs/."""
182
+ os.makedirs(_JOBS_DIR, exist_ok=True)
183
+ job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
184
+
185
+ job_data = {
186
+ "job_id": batch_id,
187
+ "id": batch_id,
188
+ "tool_name": "batch",
189
+ "provider": provider,
190
+ "params": {"description": description, "file_id": file_id},
191
+ "status": "created",
192
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
193
+ "description": description,
194
+ "file_id": file_id,
195
+ "batch_id": batch_id,
196
+ }
197
+
198
+ with open(job_file, "w", encoding="utf-8") as f:
199
+ json.dump(job_data, f, indent=2)
200
+ return job_file
201
+
202
+
203
+ def list_batch_jobs(include_pollers: bool = True, **_kw) -> List[Dict]:
204
+ """List saved batch jobs from ~/.dulus/jobs/."""
205
+ if not os.path.exists(_JOBS_DIR):
206
+ return []
207
+
208
+ batch_map: Dict[str, Dict] = {}
209
+ poller_jobs: List[Dict] = []
210
+ # Accept both old "kimi_batch" and new "batch" tool_name
211
+ _batch_names = {"kimi_batch", "batch"}
212
+ _poller_names = {"kimi_batch_poll", "batch_poll"}
213
+
214
+ for fname in os.listdir(_JOBS_DIR):
215
+ if not fname.endswith(".json"):
216
+ continue
217
+ try:
218
+ with open(os.path.join(_JOBS_DIR, fname), "r", encoding="utf-8") as f:
219
+ job = json.load(f)
220
+
221
+ tn = job.get("tool_name", "")
222
+ if tn in _batch_names:
223
+ bid = job.get("batch_id") or job.get("id")
224
+ if bid:
225
+ batch_map[bid] = job
226
+
227
+ elif include_pollers and tn in _poller_names:
228
+ poller_jobs.append(job)
229
+ br = job.get("batch_result", {})
230
+ if br:
231
+ bid = br.get("id")
232
+ if bid and bid in batch_map:
233
+ batch_map[bid]["status"] = br.get("status", "unknown")
234
+ batch_map[bid]["request_counts"] = br.get("request_counts", {})
235
+ batch_map[bid]["output_file_id"] = br.get("output_file_id")
236
+ batch_map[bid]["completed_at"] = br.get("completed_at")
237
+ batch_map[bid]["_poller_job_id"] = job.get("job_id")
238
+ except Exception:
239
+ continue
240
+
241
+ # Pollers for batches not yet in map → synthetic entry
242
+ for poller in poller_jobs:
243
+ br = poller.get("batch_result", {})
244
+ bid = br.get("id")
245
+ if bid and bid not in batch_map:
246
+ batch_map[bid] = {
247
+ "job_id": bid, "id": bid,
248
+ "tool_name": "batch",
249
+ "status": br.get("status", "unknown"),
250
+ "created_at": poller.get("created_at"),
251
+ "description": f"(from poller {poller.get('job_id', '?')[:8]}...)",
252
+ "batch_id": bid,
253
+ "request_counts": br.get("request_counts", {}),
254
+ "output_file_id": br.get("output_file_id"),
255
+ "completed_at": br.get("completed_at"),
256
+ "_from_poller": True,
257
+ "_poller_job_id": poller.get("job_id"),
258
+ }
259
+
260
+ jobs = list(batch_map.values())
261
+ jobs.sort(key=lambda x: x.get("created_at", ""), reverse=True)
262
+ return jobs
263
+
264
+
265
+ def update_batch_job_status(batch_id: str, status_info: Dict[str, Any]) -> bool:
266
+ """Update a batch job's status in its local file."""
267
+ job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
268
+ if not os.path.exists(job_file):
269
+ return False
270
+ try:
271
+ with open(job_file, "r", encoding="utf-8") as f:
272
+ job = json.load(f)
273
+ for key in ("status", "request_counts", "output_file_id", "completed_at"):
274
+ if key in status_info:
275
+ job[key] = status_info[key]
276
+ with open(job_file, "w", encoding="utf-8") as f:
277
+ json.dump(job, f, indent=2)
278
+ return True
279
+ except Exception:
280
+ return False
281
+
282
+
283
+ def get_batch_job_by_id(batch_id: str) -> Optional[Dict]:
284
+ """Get a batch job by ID (checks both batch and poller files)."""
285
+ # Direct file
286
+ job_file = os.path.join(_JOBS_DIR, f"{batch_id}.json")
287
+ if os.path.exists(job_file):
288
+ try:
289
+ with open(job_file, "r", encoding="utf-8") as f:
290
+ return json.load(f)
291
+ except Exception:
292
+ pass
293
+
294
+ # Scan pollers
295
+ if os.path.exists(_JOBS_DIR):
296
+ for fname in os.listdir(_JOBS_DIR):
297
+ if not fname.endswith(".json"):
298
+ continue
299
+ try:
300
+ with open(os.path.join(_JOBS_DIR, fname), "r", encoding="utf-8") as f:
301
+ job = json.load(f)
302
+ if job.get("tool_name") in ("kimi_batch_poll", "batch_poll"):
303
+ if job.get("params", {}).get("batch_id") == batch_id:
304
+ return job
305
+ except Exception:
306
+ continue
307
+ return None
checkpoint/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Checkpoint system: automatic file snapshots with rewind support."""
2
+ from .types import FileBackup, Snapshot, MAX_SNAPSHOTS
3
+ from .store import (
4
+ track_file_edit,
5
+ make_snapshot,
6
+ list_snapshots,
7
+ get_snapshot,
8
+ rewind_files,
9
+ files_changed_since,
10
+ delete_session_checkpoints,
11
+ cleanup_old_sessions,
12
+ reset_file_versions,
13
+ )
14
+ from .hooks import (
15
+ set_session,
16
+ get_tracked_edits,
17
+ reset_tracked,
18
+ install_hooks,
19
+ )
20
+
21
+ __all__ = [
22
+ "FileBackup", "Snapshot", "MAX_SNAPSHOTS",
23
+ "track_file_edit", "make_snapshot", "list_snapshots", "get_snapshot",
24
+ "rewind_files", "files_changed_since",
25
+ "delete_session_checkpoints", "cleanup_old_sessions", "reset_file_versions",
26
+ "set_session", "get_tracked_edits", "reset_tracked", "install_hooks",
27
+ ]
checkpoint/hooks.py ADDED
@@ -0,0 +1,90 @@
1
+ """Checkpoint hooks: intercept Write/Edit/NotebookEdit to back up files before modification.
2
+
3
+ Import this module after tools are registered to install the hooks.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from . import store
10
+
11
+ # ── Module state ────────────────────────────────────────────────────────────
12
+
13
+ _current_session_id: str | None = None
14
+ _tracked_edits: dict[str, str | None] = {} # file_path → backup_filename
15
+
16
+
17
+ def set_session(session_id: str) -> None:
18
+ global _current_session_id
19
+ _current_session_id = session_id
20
+
21
+
22
+ def get_tracked_edits() -> dict[str, str | None]:
23
+ """Return the current interval's tracked edits (for make_snapshot)."""
24
+ return dict(_tracked_edits)
25
+
26
+
27
+ def reset_tracked() -> None:
28
+ """Clear tracked edits after a snapshot is created."""
29
+ _tracked_edits.clear()
30
+
31
+
32
+ # ── Backup logic ────────────────────────────────────────────────────────────
33
+
34
+ def _backup_before_write(file_path: str) -> None:
35
+ """Back up a file before it is modified (first-write-wins per snapshot interval)."""
36
+ if _current_session_id is None:
37
+ return
38
+ if file_path in _tracked_edits:
39
+ return # already backed up this interval
40
+
41
+ backup_name = store.track_file_edit(_current_session_id, file_path)
42
+ _tracked_edits[file_path] = backup_name
43
+
44
+
45
+ # ── Hook installation ───────────────────────────────────────────────────────
46
+
47
+ _hooks_installed = False
48
+
49
+
50
+ def install_hooks() -> None:
51
+ """Wrap Write/Edit/NotebookEdit tool functions to call backup before execution."""
52
+ global _hooks_installed
53
+ if _hooks_installed:
54
+ return
55
+ _hooks_installed = True
56
+
57
+ from tool_registry import get_tool
58
+
59
+ # Hook Write
60
+ write_tool = get_tool("Write")
61
+ if write_tool:
62
+ original_write = write_tool.func
63
+ def hooked_write(params, config):
64
+ fp = params.get("file_path", "")
65
+ if fp:
66
+ _backup_before_write(fp)
67
+ return original_write(params, config)
68
+ write_tool.func = hooked_write
69
+
70
+ # Hook Edit
71
+ edit_tool = get_tool("Edit")
72
+ if edit_tool:
73
+ original_edit = edit_tool.func
74
+ def hooked_edit(params, config):
75
+ fp = params.get("file_path", "")
76
+ if fp:
77
+ _backup_before_write(fp)
78
+ return original_edit(params, config)
79
+ edit_tool.func = hooked_edit
80
+
81
+ # Hook NotebookEdit
82
+ nb_tool = get_tool("NotebookEdit")
83
+ if nb_tool:
84
+ original_nb = nb_tool.func
85
+ def hooked_nb(params, config):
86
+ fp = params.get("notebook_path", "")
87
+ if fp:
88
+ _backup_before_write(fp)
89
+ return original_nb(params, config)
90
+ nb_tool.func = hooked_nb