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
memory/sessions.py ADDED
@@ -0,0 +1,100 @@
1
+ """Historical session search utility."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from config import DAILY_DIR, SESSION_HIST_FILE
8
+
9
+ def search_session_history(query: str, max_results: int = 5) -> list[dict]:
10
+ """Search for a query string across historical session logs.
11
+
12
+ Checks both history.json (master) and daily/ copier directories.
13
+ Returns list of hits: {session_id, saved_at, hits: [{role, content_snippet}]}.
14
+ """
15
+ query = query.lower()
16
+ all_sessions = []
17
+
18
+ # 1. Load history.json (master file)
19
+ if SESSION_HIST_FILE.exists():
20
+ try:
21
+ data = json.loads(SESSION_HIST_FILE.read_text(encoding="utf-8", errors="replace"))
22
+ all_sessions.extend(data.get("sessions", []))
23
+ except Exception:
24
+ pass
25
+
26
+ # WSL Fallback: If in WSL and history is empty, check Windows home host
27
+ import sys
28
+ if not all_sessions and sys.platform == "linux" and Path("/mnt/c").exists():
29
+ # Heuristic: try common Windows user paths
30
+ # This is a bit of a hack but helpful for users running in WSL
31
+ # who didn't symlink their .dulus folder yet.
32
+ try:
33
+ # Try to find a .dulus directory in any user folder on C:
34
+ c_users = Path("/mnt/c/Users")
35
+ for udir in c_users.iterdir():
36
+ if not udir.is_dir(): continue
37
+ win_hist = udir / ".dulus" / "sessions" / "history.json"
38
+ if win_hist.exists():
39
+ data = json.loads(win_hist.read_text(encoding="utf-8", errors="replace"))
40
+ all_sessions.extend(data.get("sessions", []))
41
+ break
42
+ except Exception:
43
+ pass
44
+
45
+ # 2. SUPPLEMENT: Scan daily folders for sessions not in history (if any)
46
+ # This ensures we don't miss the absolute latest if history.json wasn't written yet
47
+ known_ids = {s.get("session_id") for s in all_sessions if s.get("session_id")}
48
+
49
+ if DAILY_DIR.exists():
50
+ for day_dir in sorted(DAILY_DIR.iterdir(), reverse=True):
51
+ if not day_dir.is_dir():
52
+ continue
53
+ for session_file in sorted(day_dir.glob("session_*.json"), reverse=True):
54
+ try:
55
+ # Quick check: session ID is in filename session_HHMMSS_sid.json
56
+ sid = session_file.stem.split("_")[-1]
57
+ if sid in known_ids:
58
+ continue
59
+
60
+ s_data = json.loads(session_file.read_text(encoding="utf-8", errors="replace"))
61
+ all_sessions.append(s_data)
62
+ except Exception:
63
+ continue
64
+
65
+ # 3. Perform search
66
+ results = []
67
+ for sess in all_sessions:
68
+ session_id = sess.get("session_id", "unknown")
69
+ saved_at = sess.get("saved_at", "unknown")
70
+ messages = sess.get("messages", [])
71
+
72
+ session_hits = []
73
+ for msg in messages:
74
+ content = msg.get("content", "")
75
+ if not isinstance(content, str):
76
+ continue
77
+
78
+ if query in content.lower():
79
+ # Extract snippet
80
+ start = max(0, content.lower().find(query) - 60)
81
+ end = min(len(content), start + 200)
82
+ snippet = content[start:end].replace("\n", " ")
83
+ if start > 0: snippet = "..." + snippet
84
+ if end < len(content): snippet += "..."
85
+
86
+ session_hits.append({
87
+ "role": msg.get("role"),
88
+ "snippet": snippet
89
+ })
90
+
91
+ if session_hits:
92
+ results.append({
93
+ "session_id": session_id,
94
+ "saved_at": saved_at,
95
+ "hits": session_hits[:3] # limit hits per session to avoid bloat
96
+ })
97
+
98
+ # Sort sessions by recency (newest hit first)
99
+ results.sort(key=lambda x: x["saved_at"], reverse=True)
100
+ return results[:max_results]
memory/store.py ADDED
@@ -0,0 +1,395 @@
1
+ """File-based memory storage with user-level and project-level scopes.
2
+
3
+ Storage layout:
4
+ user scope : ~/.dulus/memory/<slug>.md
5
+ project scope : .dulus/memory/<slug>.md (relative to cwd)
6
+
7
+ Search uses token-based fuzzy matching with field weighting
8
+ (name 3×, description 2×, content 1×) for better recall than
9
+ simple substring matching.
10
+
11
+ MEMORY.md in each directory is the index file — rebuilt automatically after
12
+ every save/delete. It is loaded into the system prompt to give Dulus an
13
+ overview of available memories.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import difflib
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+
23
+ # ── Paths ──────────────────────────────────────────────────────────────────
24
+
25
+ USER_MEMORY_DIR = Path.home() / ".dulus" / "memory"
26
+ INDEX_FILENAME = "MEMORY.md"
27
+
28
+ # Maximum lines/bytes for the index file
29
+ MAX_INDEX_LINES = 200
30
+ MAX_INDEX_BYTES = 25_000
31
+
32
+
33
+ def get_project_memory_dir() -> Path:
34
+ """Return the project-local memory directory (relative to cwd)."""
35
+ return Path.cwd() / ".dulus-context" / "memory"
36
+
37
+
38
+ def get_memory_dir(scope: str = "user") -> Path:
39
+ """Return the memory directory for the given scope.
40
+
41
+ Args:
42
+ scope: "user" (global ~/.dulus/memory) or
43
+ "project" (.dulus/memory relative to cwd)
44
+ """
45
+ if scope == "project":
46
+ return get_project_memory_dir()
47
+ return USER_MEMORY_DIR
48
+
49
+
50
+ # ── Data model ─────────────────────────────────────────────────────────────
51
+
52
+ @dataclass
53
+ class MemoryEntry:
54
+ """A single memory entry loaded from a .md file.
55
+
56
+ Attributes:
57
+ name: human-readable name (also the display title in the index)
58
+ description: short one-line description (used for relevance decisions)
59
+ type: "user" | "feedback" | "project" | "reference"
60
+ hall: categorization — "facts" | "events" | "discoveries" |
61
+ "preferences" | "advice" | "" (empty = uncategorized)
62
+ content: body text of the memory
63
+ file_path: absolute path to the .md file on disk
64
+ created: date string, e.g. "2026-04-02"
65
+ scope: "user" | "project" — which directory this was loaded from
66
+ confidence: 0.0–1.0 reliability score (default 1.0 = explicit user statement)
67
+ source: origin: "user" | "model" | "tool" | "consolidator"
68
+ last_used_at: ISO date of last retrieval (updated on MemorySearch hits)
69
+ conflict_group: tag linking related/conflicting memories (e.g. "writing_style")
70
+ """
71
+ name: str
72
+ description: str
73
+ type: str
74
+ content: str
75
+ file_path: str = ""
76
+ created: str = ""
77
+ scope: str = "user"
78
+ hall: str = ""
79
+ confidence: float = 1.0
80
+ source: str = "user"
81
+ last_used_at: str = ""
82
+ conflict_group: str = ""
83
+ gold: bool = False
84
+
85
+
86
+ # ── Helpers ────────────────────────────────────────────────────────────────
87
+
88
+ import unicodedata
89
+
90
+ def _slugify(name: str) -> str:
91
+ """Convert name to a filesystem-safe slug (max 60 chars)."""
92
+ s = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore').decode('ascii')
93
+ s = s.lower().strip().replace(" ", "_")
94
+ s = re.sub(r"[^a-z0-9_]", "", s)
95
+ return s[:60]
96
+
97
+
98
+ def parse_frontmatter(text: str) -> tuple[dict, str]:
99
+ """Parse ---\\nkey: value\\n---\\nbody format.
100
+
101
+ Returns:
102
+ (meta_dict, body_str)
103
+ """
104
+ if not text.startswith("---"):
105
+ return {}, text
106
+ parts = text.split("---", 2)
107
+ if len(parts) < 3:
108
+ return {}, text
109
+ meta: dict = {}
110
+ for line in parts[1].strip().splitlines():
111
+ if ":" in line:
112
+ key, _, val = line.partition(":")
113
+ meta[key.strip()] = val.strip()
114
+ return meta, parts[2].strip()
115
+
116
+
117
+ def _format_entry_md(entry: MemoryEntry) -> str:
118
+ """Render a MemoryEntry as a markdown file with YAML frontmatter."""
119
+ lines = [
120
+ "---",
121
+ f"name: {entry.name}",
122
+ f"description: {entry.description}",
123
+ f"type: {entry.type}",
124
+ ]
125
+ if entry.hall:
126
+ lines.append(f"hall: {entry.hall}")
127
+ lines.append(f"created: {entry.created}")
128
+ if entry.confidence != 1.0:
129
+ lines.append(f"confidence: {entry.confidence:.2f}")
130
+ if entry.source and entry.source != "user":
131
+ lines.append(f"source: {entry.source}")
132
+ if entry.last_used_at:
133
+ lines.append(f"last_used_at: {entry.last_used_at}")
134
+ if entry.conflict_group:
135
+ lines.append(f"conflict_group: {entry.conflict_group}")
136
+ if entry.gold:
137
+ lines.append("gold: true")
138
+ lines.append("---")
139
+ lines.append(entry.content)
140
+ return "\n".join(lines) + "\n"
141
+
142
+
143
+ # ── Core storage operations ────────────────────────────────────────────────
144
+
145
+ def save_memory(entry: MemoryEntry, scope: str = "user") -> None:
146
+ """Write/update a memory file and rebuild the index for that scope.
147
+
148
+ If a memory with the same name (slug) already exists, it is overwritten.
149
+
150
+ Args:
151
+ entry: MemoryEntry to persist
152
+ scope: "user" or "project"
153
+ """
154
+ mem_dir = get_memory_dir(scope)
155
+ mem_dir.mkdir(parents=True, exist_ok=True)
156
+ slug = _slugify(entry.name)
157
+ fp = mem_dir / f"{slug}.md"
158
+ fp.write_text(_format_entry_md(entry), encoding="utf-8")
159
+ entry.file_path = str(fp)
160
+ entry.scope = scope
161
+ _rewrite_index(scope)
162
+
163
+
164
+ def delete_memory(name: str, scope: str = "user") -> None:
165
+ """Remove the memory file matching name and rebuild the index.
166
+
167
+ No error if not found.
168
+ """
169
+ mem_dir = get_memory_dir(scope)
170
+ slug = _slugify(name)
171
+ fp = mem_dir / f"{slug}.md"
172
+ if fp.exists():
173
+ fp.unlink()
174
+ _rewrite_index(scope)
175
+
176
+
177
+ def load_entries(scope: str = "user") -> list[MemoryEntry]:
178
+ """Scan all .md files (except MEMORY.md) in a scope and return entries.
179
+
180
+ Returns:
181
+ List of MemoryEntry sorted alphabetically by name.
182
+ """
183
+ mem_dir = get_memory_dir(scope)
184
+ if not mem_dir.exists():
185
+ return []
186
+ entries: list[MemoryEntry] = []
187
+ for fp in sorted(mem_dir.glob("*.md")):
188
+ if fp.name == INDEX_FILENAME:
189
+ continue
190
+ try:
191
+ text = fp.read_text(encoding="utf-8", errors="replace")
192
+ except Exception:
193
+ continue
194
+ meta, body = parse_frontmatter(text)
195
+ entries.append(MemoryEntry(
196
+ name=meta.get("name", fp.stem),
197
+ description=meta.get("description", ""),
198
+ type=meta.get("type", "user"),
199
+ content=body,
200
+ file_path=str(fp),
201
+ created=meta.get("created", ""),
202
+ scope=scope,
203
+ hall=meta.get("hall", ""),
204
+ confidence=float(meta.get("confidence", 1.0)),
205
+ source=meta.get("source", "user"),
206
+ last_used_at=meta.get("last_used_at", ""),
207
+ conflict_group=meta.get("conflict_group", ""),
208
+ gold=meta.get("gold", "").lower() == "true",
209
+ ))
210
+ return entries
211
+
212
+
213
+ def load_index(scope: str = "all") -> list[MemoryEntry]:
214
+ """Load memory entries from one or both scopes.
215
+
216
+ Args:
217
+ scope: "user", "project", or "all" (both combined)
218
+
219
+ Returns:
220
+ List of MemoryEntry (user entries first, then project).
221
+ """
222
+ if scope == "all":
223
+ return load_entries("user") + load_entries("project")
224
+ return load_entries(scope)
225
+
226
+
227
+ def _tokenize(text: str) -> list[str]:
228
+ """Split text into lowercase tokens (words)."""
229
+ import re
230
+ return re.findall(r'[a-záéíóúñü0-9_]+', text.lower())
231
+
232
+
233
+ def _token_score(query_tokens: list[str], text: str) -> float:
234
+ """Score how well query tokens match a text field.
235
+
236
+ For each query token, find the best match among text tokens using
237
+ SequenceMatcher (handles typos, partial matches, synonyms-by-prefix).
238
+ Returns average best-match ratio (0.0–1.0).
239
+ """
240
+ from difflib import SequenceMatcher
241
+ if not query_tokens or not text:
242
+ return 0.0
243
+ text_tokens = _tokenize(text)
244
+ if not text_tokens:
245
+ return 0.0
246
+
247
+ total = 0.0
248
+ for qt in query_tokens:
249
+ best = 0.0
250
+ for tt in text_tokens:
251
+ # Exact substring match = perfect score
252
+ if qt in tt or tt in qt:
253
+ best = 1.0
254
+ break
255
+ ratio = SequenceMatcher(None, qt, tt).ratio()
256
+ if ratio > best:
257
+ best = ratio
258
+ total += best
259
+ return total / len(query_tokens)
260
+
261
+
262
+ def search_memory(
263
+ query: str,
264
+ scope: str = "all",
265
+ hall: str = "",
266
+ min_score: float = 0.35,
267
+ ) -> list[MemoryEntry]:
268
+ """Token-based fuzzy search on name + description + content.
269
+
270
+ Scores each memory using weighted field matching:
271
+ name × 3.0 + description × 2.0 + content × 1.0
272
+
273
+ Args:
274
+ query: search query string
275
+ scope: "user", "project", or "all"
276
+ hall: optional hall filter ("facts", "events", etc.)
277
+ min_score: minimum relevance score to include (0.0–1.0)
278
+
279
+ Returns:
280
+ List of (MemoryEntry, score) tuples sorted by score descending.
281
+ For backward compat, if called without unpacking, entries are
282
+ accessible directly (score attached as _search_score attribute).
283
+ """
284
+ query_tokens = _tokenize(query)
285
+
286
+ # Empty query with hall filter = list all in that hall
287
+ if not query_tokens:
288
+ if hall:
289
+ results = [e for e in load_index(scope) if e.hall == hall]
290
+ for e in results:
291
+ e._search_score = 1.0 # type: ignore[attr-defined]
292
+ return results
293
+ return []
294
+
295
+ scored: list[tuple[MemoryEntry, float]] = []
296
+ for entry in load_index(scope):
297
+ # Hall filter
298
+ if hall and entry.hall != hall:
299
+ continue
300
+
301
+ # Weighted field scoring
302
+ name_score = _token_score(query_tokens, entry.name)
303
+ desc_score = _token_score(query_tokens, entry.description)
304
+ body_score = _token_score(query_tokens, entry.content[:4000])
305
+
306
+ # Lower name weight (was 3.0) so short generic names like "soul" or
307
+ # "preferences" don't dominate every query just because they fuzzy-
308
+ # match a token. Body now gets a slightly bigger vote.
309
+ total = (name_score * 2.0 + desc_score * 2.0 + body_score * 1.5) / 5.5
310
+
311
+ if total >= min_score:
312
+ entry._search_score = total # type: ignore[attr-defined]
313
+ scored.append((entry, total))
314
+
315
+ scored.sort(key=lambda x: x[1], reverse=True)
316
+ return [entry for entry, _ in scored]
317
+
318
+
319
+ def _rewrite_index(scope: str) -> None:
320
+ """Rebuild MEMORY.md for the given scope from all .md files in that dir."""
321
+ mem_dir = get_memory_dir(scope)
322
+ if not mem_dir.exists():
323
+ return
324
+ index_path = mem_dir / INDEX_FILENAME
325
+ entries = load_entries(scope)
326
+ lines = [
327
+ f"- [{e.name}]({Path(e.file_path).name}) — {e.description}"
328
+ for e in entries
329
+ ]
330
+ index_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
331
+
332
+
333
+ def get_index_content(scope: str = "user") -> str:
334
+ """Return raw MEMORY.md content for the given scope, or '' if absent."""
335
+ mem_dir = get_memory_dir(scope)
336
+ index_path = mem_dir / INDEX_FILENAME
337
+ if not index_path.exists():
338
+ return ""
339
+ return index_path.read_text(encoding="utf-8", errors="replace").strip()
340
+
341
+
342
+ def check_conflict(entry: "MemoryEntry", scope: str = "user") -> dict | None:
343
+ """Check whether a same-named memory already exists with different content.
344
+
345
+ Returns a dict with the existing memory's key fields if a conflict is found,
346
+ or None if no existing file or if the content is identical.
347
+ """
348
+ mem_dir = get_memory_dir(scope)
349
+ slug = _slugify(entry.name)
350
+ fp = mem_dir / f"{slug}.md"
351
+ if not fp.exists():
352
+ return None
353
+ try:
354
+ meta, existing_content = parse_frontmatter(fp.read_text(encoding="utf-8", errors="replace"))
355
+ except Exception:
356
+ return None
357
+ if existing_content.strip() == entry.content.strip():
358
+ return None
359
+ return {
360
+ "existing_content": existing_content.strip(),
361
+ "existing_confidence": float(meta.get("confidence", 1.0)),
362
+ "existing_created": meta.get("created", ""),
363
+ "existing_source": meta.get("source", "user"),
364
+ }
365
+
366
+
367
+ def touch_last_used(file_path: str) -> None:
368
+ """Update the last_used_at frontmatter field of a memory file to today.
369
+
370
+ Called by MemorySearch when a memory is returned so staleness/utility
371
+ tracking stays current. Silent on any error.
372
+ """
373
+ from datetime import date
374
+ fp = Path(file_path)
375
+ if not fp.exists():
376
+ return
377
+ try:
378
+ text = fp.read_text(encoding="utf-8", errors="replace")
379
+ meta, body = parse_frontmatter(text)
380
+ today = date.today().isoformat()
381
+ if meta.get("last_used_at") == today:
382
+ return # already up to date, skip the write
383
+ meta["last_used_at"] = today
384
+ # Rebuild frontmatter
385
+ fm_lines = ["---"]
386
+ for k in ("name", "description", "type", "hall", "created", "confidence",
387
+ "source", "last_used_at", "conflict_group", "gold"):
388
+ v = meta.get(k)
389
+ if v is not None and str(v):
390
+ fm_lines.append(f"{k}: {v}")
391
+ fm_lines.append("---")
392
+ new_text = "\n".join(fm_lines) + "\n" + body + "\n"
393
+ fp.write_text(new_text, encoding="utf-8")
394
+ except Exception:
395
+ pass