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/tools.py ADDED
@@ -0,0 +1,408 @@
1
+ """Memory tool registrations: MemorySave, MemoryDelete, MemorySearch.
2
+
3
+ Importing this module registers the three tools into the central registry.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from datetime import datetime
8
+
9
+ from tool_registry import ToolDef, register_tool
10
+ from .store import MemoryEntry, save_memory, delete_memory, load_index, check_conflict, touch_last_used
11
+ from .context import find_relevant_memories
12
+ from .scan import scan_all_memories, format_memory_manifest
13
+ from .sessions import search_session_history
14
+
15
+
16
+ # ── Tool implementations ───────────────────────────────────────────────────
17
+
18
+ def _memory_save(params: dict, config: dict) -> str:
19
+ """Save or update a persistent memory entry, with conflict detection."""
20
+ scope = params.get("scope", "user")
21
+ entry = MemoryEntry(
22
+ name=params["name"],
23
+ description=params["description"],
24
+ type=params["type"],
25
+ content=params["content"],
26
+ created=datetime.now().strftime("%Y-%m-%d"),
27
+ hall=params.get("hall", ""),
28
+ confidence=float(params.get("confidence", 1.0)),
29
+ source=params.get("source", "user"),
30
+ conflict_group=params.get("conflict_group", ""),
31
+ )
32
+
33
+ conflict = check_conflict(entry, scope=scope)
34
+ save_memory(entry, scope=scope)
35
+
36
+ # ── Auto-mine into MemPalace (fire-and-forget) ──
37
+ # mempalace skips already-filed files, so only the new MD gets indexed.
38
+ if config.get("mem_palace", True) and scope == "user":
39
+ try:
40
+ import subprocess as _sp, sys as _sys, os as _os
41
+ from pathlib import Path as _Path
42
+ _mem_dir = _Path.home() / ".dulus" / "memory"
43
+ _env = {**_os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
44
+ _sp.Popen(
45
+ [_sys.executable, "-X", "utf8", "-m", "mempalace", "mine",
46
+ str(_mem_dir), "--wing", "memory", "--agent", "dulus"],
47
+ stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
48
+ env=_env,
49
+ creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0),
50
+ )
51
+ except Exception:
52
+ pass # never block save on mining failure
53
+
54
+ scope_label = "project" if scope == "project" else "user"
55
+ hall_label = f"/{entry.hall}" if entry.hall else ""
56
+ msg = f"Memory saved: '{entry.name}' [{entry.type}{hall_label}/{scope_label}]"
57
+ if entry.confidence < 1.0:
58
+ msg += f" (confidence: {entry.confidence:.0%})"
59
+ if conflict:
60
+ msg += (
61
+ f"\n⚠ Replaced conflicting memory"
62
+ f" (was {conflict['existing_source']}-sourced, {conflict['existing_confidence']:.0%} confidence,"
63
+ f" written {conflict['existing_created'] or 'unknown date'})."
64
+ f" Old content: {conflict['existing_content'][:120]}"
65
+ f"{'...' if len(conflict['existing_content']) > 120 else ''}"
66
+ )
67
+ return msg
68
+
69
+
70
+ def _memory_delete(params: dict, config: dict) -> str:
71
+ """Delete a persistent memory entry by name."""
72
+ name = params["name"]
73
+ scope = params.get("scope", "user")
74
+ delete_memory(name, scope=scope)
75
+ return f"Memory deleted: '{name}' (scope: {scope})"
76
+
77
+
78
+ def _memory_search(params: dict, config: dict) -> str:
79
+ """Search memories by keyword query with optional AI relevance filtering.
80
+
81
+ Results are ranked by: confidence × recency (30-day exponential decay).
82
+ """
83
+ import math, time as _time
84
+ query = params["query"]
85
+ use_ai = params.get("use_ai", False)
86
+
87
+ if config.get("ULTRA_SEARCH") in (1, "1", True, "true"):
88
+ params["include_sessions"] = True
89
+ max_results = max(params.get("max_results", 5), 100)
90
+ else:
91
+ max_results = params.get("max_results", 5)
92
+
93
+ results = find_relevant_memories(
94
+ query, max_results=max_results * 3, use_ai=use_ai, config=config
95
+ )
96
+
97
+ if not results:
98
+ return f"No memories found matching '{query}'."
99
+
100
+ # Re-rank by confidence × recency score
101
+ now = _time.time()
102
+ for r in results:
103
+ age_days = max(0, (now - r["mtime_s"]) / 86400)
104
+ recency = math.exp(-age_days / 30) # half-life ≈ 21 days
105
+ r["_rank"] = r.get("confidence", 1.0) * recency
106
+ results.sort(key=lambda r: r["_rank"], reverse=True)
107
+ results = results[:max_results]
108
+
109
+ # Touch last_used_at for returned memories
110
+ for r in results:
111
+ if r.get("file_path"):
112
+ touch_last_used(r["file_path"])
113
+
114
+ lines = [f"Found {len(results)} relevant memory/memories for '{query}':", ""]
115
+ for r in results:
116
+ freshness = f" ⚠ {r['freshness_text']}" if r["freshness_text"] else ""
117
+ conf = r.get("confidence", 1.0)
118
+ src = r.get("source", "user")
119
+ hall_tag = f"/{r['hall']}" if r.get("hall") else ""
120
+ meta_tag = ""
121
+ if conf < 1.0 or src != "user":
122
+ meta_tag = f" [conf:{conf:.0%} src:{src}]"
123
+ lines.append(
124
+ f"[{r['type']}{hall_tag}/{r['scope']}] {r['name']}{meta_tag}\n"
125
+ f" {r['description']}\n"
126
+ f" {r['content'][:200]}{'...' if len(r['content']) > 200 else ''}"
127
+ f"{freshness}"
128
+ )
129
+
130
+ # ── Part 2: Session history search ───────────────────────────────────
131
+ # Heuristic: If we found few results (< 3), automatically search session history
132
+ # unless include_sessions was explicitly False.
133
+ should_search_sessions = params.get("include_sessions")
134
+
135
+ if should_search_sessions:
136
+ sess_results = search_session_history(query, max_results=max_results)
137
+ if sess_results:
138
+ lines.append("\n" + "─" * 40)
139
+ lines.append(f"Historical Session Matches ({len(sess_results)} sessions):")
140
+ for sr in sess_results:
141
+ lines.append(f"\nSession {sr['session_id']} ({sr['saved_at']})")
142
+ for h in sr["hits"]:
143
+ role_lbl = "User" if h["role"] == "user" else "Dulus"
144
+ lines.append(f" [{role_lbl}] {h['snippet']}")
145
+
146
+ # ── Part 3: Offloaded Jobs Search ────────────────────────────────────
147
+ try:
148
+ from pathlib import Path
149
+ import json
150
+ jobs_dir = Path.home() / ".dulus" / "jobs"
151
+ if jobs_dir.is_dir():
152
+ job_matches = []
153
+ q_lower = query.lower()
154
+ q_words = [w.strip() for w in q_lower.split() if w.strip()]
155
+ for fp in jobs_dir.glob("*.json"):
156
+ try:
157
+ with open(fp, "r", encoding="utf-8") as f:
158
+ job = json.load(f)
159
+ job_text = json.dumps(job, ensure_ascii=False).lower()
160
+ # Allow fuzzy token matching across the JSON content
161
+ if all(w in job_text for w in q_words):
162
+ job_matches.append(job)
163
+ except Exception:
164
+ pass
165
+ if job_matches:
166
+ lines.append("\n" + "─" * 40)
167
+ lines.append(f"Offloaded Background Jobs ({len(job_matches)} matches):")
168
+ job_matches.sort(key=lambda j: j.get("created_at", ""), reverse=True)
169
+ for j in job_matches[:max_results]:
170
+ status = j.get("status", "unknown")
171
+ lines.append(f"\nJob {j.get('id')} - Tool: {j.get('tool_name')} ({status})")
172
+ if j.get("params"):
173
+ lines.append(f" Params: {json.dumps(j['params'], ensure_ascii=False)}")
174
+ if j.get("result"):
175
+ res = j["result"]
176
+ if len(res) > 300:
177
+ idx = res.lower().find(q_lower)
178
+ if idx != -1:
179
+ start = max(0, idx - 100)
180
+ end = min(len(res), idx + 200)
181
+ snippet = res[start:end].replace("\n", " ")
182
+ lines.append(f" Result snippet: ...{snippet}...")
183
+ else:
184
+ lines.append(f" Result snippet: {res[:300]}...")
185
+ else:
186
+ lines.append(f" Result: {res}")
187
+ except Exception:
188
+ pass
189
+
190
+ if not params.get("include_sessions") and not should_search_sessions:
191
+ lines.append("\n💡 Hint: No matches? Call MemorySearch again with `include_sessions=True` to search through all past session chat logs.")
192
+
193
+ if not lines[1:]: # Ensure we don't return an empty "Found 0" without hints
194
+ pass
195
+
196
+ return "\n".join(lines).strip()
197
+
198
+
199
+
200
+ def _memory_list(params: dict, config: dict) -> str:
201
+ """List all memory entries with type, scope, age, confidence, and description."""
202
+ from .store import load_entries
203
+
204
+ scope_filter = params.get("scope", "all")
205
+ scopes = ["user", "project"] if scope_filter == "all" else [scope_filter]
206
+
207
+ all_entries = []
208
+ for s in scopes:
209
+ all_entries.extend(load_entries(s))
210
+
211
+ if not all_entries:
212
+ return "No memories stored." if scope_filter == "all" else f"No {scope_filter} memories stored."
213
+
214
+ lines = [f"{len(all_entries)} memory/memories:"]
215
+ for e in all_entries:
216
+ conf_tag = f" conf:{e.confidence:.0%}" if e.confidence < 1.0 else ""
217
+ src_tag = f" src:{e.source}" if e.source and e.source != "user" else ""
218
+ cg_tag = f" grp:{e.conflict_group}" if e.conflict_group else ""
219
+ hall_tag = f" hall:{e.hall}" if e.hall else ""
220
+ meta = f"{conf_tag}{src_tag}{cg_tag}{hall_tag}".strip()
221
+ tag = f"[{e.type:9s}|{e.scope:7s}]"
222
+ lines.append(f" {tag} {e.name}{(' — ' + meta) if meta else ''}")
223
+ if e.description:
224
+ lines.append(f" {e.description}")
225
+ return "\n".join(lines)
226
+
227
+
228
+ # ── Tool registrations ─────────────────────────────────────────────────────
229
+
230
+ register_tool(ToolDef(
231
+ name="MemorySave",
232
+ schema={
233
+ "name": "MemorySave",
234
+ "description": (
235
+ "Save a persistent memory entry as a markdown file with frontmatter. "
236
+ "Use for information that should persist across conversations: "
237
+ "user preferences, feedback/corrections, project context, or external references. "
238
+ "Do NOT save: code patterns, architecture, git history, or task state.\n\n"
239
+ "For feedback/project memories, structure content as: "
240
+ "rule/fact, then **Why:** and **How to apply:** lines.\n\n"
241
+ "Optionally categorize with a 'hall': facts (decisions), events (milestones), "
242
+ "discoveries (insights), preferences (habits), advice (recommendations)."
243
+ ),
244
+ "input_schema": {
245
+ "type": "object",
246
+ "properties": {
247
+ "name": {
248
+ "type": "string",
249
+ "description": "Human-readable name (becomes the filename slug)",
250
+ },
251
+ "type": {
252
+ "type": "string",
253
+ "enum": ["user", "feedback", "project", "reference"],
254
+ "description": (
255
+ "user=preferences/role, feedback=guidance on how to work, "
256
+ "project=ongoing work/decisions, reference=external system pointers"
257
+ ),
258
+ },
259
+ "hall": {
260
+ "type": "string",
261
+ "enum": ["facts", "events", "discoveries", "preferences", "advice"],
262
+ "description": (
263
+ "Categorize HOW this memory should be used. "
264
+ "facts=decisions locked in, events=milestones/timeline, "
265
+ "discoveries=insights/breakthroughs, preferences=habits/likes, "
266
+ "advice=recommendations/solutions. Optional — omit if unsure."
267
+ ),
268
+ },
269
+ "description": {
270
+ "type": "string",
271
+ "description": "Short one-line description (used for relevance decisions — be specific)",
272
+ },
273
+ "content": {
274
+ "type": "string",
275
+ "description": "Body text. For feedback/project: rule/fact + **Why:** + **How to apply:**",
276
+ },
277
+ "scope": {
278
+ "type": "string",
279
+ "enum": ["user", "project"],
280
+ "description": (
281
+ "'user' (default) = ~/.dulus/memory/ shared across projects; "
282
+ "'project' = .dulus/memory/ local to this project"
283
+ ),
284
+ },
285
+ "confidence": {
286
+ "type": "number",
287
+ "description": (
288
+ "Reliability score 0.0–1.0. Default 1.0 = explicit user statement. "
289
+ "Use ~0.8 for inferred preferences, ~0.6 for uncertain facts."
290
+ ),
291
+ },
292
+ "source": {
293
+ "type": "string",
294
+ "enum": ["user", "model", "tool"],
295
+ "description": (
296
+ "Origin of this memory: 'user' (default, explicit statement), "
297
+ "'model' (inferred by AI), 'tool' (from tool output)."
298
+ ),
299
+ },
300
+ "conflict_group": {
301
+ "type": "string",
302
+ "description": (
303
+ "Optional tag grouping related or potentially conflicting memories "
304
+ "(e.g. 'writing_style'). Helps with conflict resolution."
305
+ ),
306
+ },
307
+ },
308
+ "required": ["name", "type", "description", "content"],
309
+ },
310
+ },
311
+ func=_memory_save,
312
+ read_only=False,
313
+ concurrent_safe=False,
314
+ ))
315
+
316
+ register_tool(ToolDef(
317
+ name="MemoryDelete",
318
+ schema={
319
+ "name": "MemoryDelete",
320
+ "description": "Delete a persistent memory entry by name.",
321
+ "input_schema": {
322
+ "type": "object",
323
+ "properties": {
324
+ "name": {"type": "string", "description": "Name of the memory to delete"},
325
+ "scope": {
326
+ "type": "string",
327
+ "enum": ["user", "project"],
328
+ "description": "Scope to delete from (default: 'user')",
329
+ },
330
+ },
331
+ "required": ["name"],
332
+ },
333
+ },
334
+ func=_memory_delete,
335
+ read_only=False,
336
+ concurrent_safe=False,
337
+ ))
338
+
339
+ register_tool(ToolDef(
340
+ name="MemorySearch",
341
+ schema={
342
+ "name": "MemorySearch",
343
+ "description": (
344
+ "Search persistent memories using fuzzy token matching. Returns entries ranked by "
345
+ "relevance (name/description weighted higher) with content preview and staleness "
346
+ "warnings. Searches are 100% case-insensitive and support partial string matches automatically "
347
+ "- do NOT query multiple casing variations. "
348
+ "Set use_ai=true for AI-powered re-ranking (costs a small API call). "
349
+ "Optionally filter by hall to narrow results."
350
+ ),
351
+ "input_schema": {
352
+ "type": "object",
353
+ "properties": {
354
+ "query": {"type": "string", "description": "Search query (supports fuzzy matching)"},
355
+ "max_results": {
356
+ "type": "integer",
357
+ "description": "Maximum results to return (default: 5). 💡 CRITICAL: To search deep session history exhaustively, you MUST set this to a high number (e.g. 50 or 100), otherwise it will cap at 5 sessions!",
358
+ },
359
+ "use_ai": {
360
+ "type": "boolean",
361
+ "description": "Use AI relevance ranking (default: false = fuzzy match only)",
362
+ },
363
+ "scope": {
364
+ "type": "string",
365
+ "enum": ["user", "project", "all"],
366
+ "description": "Which scope to search (default: 'all')",
367
+ },
368
+ "hall": {
369
+ "type": "string",
370
+ "enum": ["facts", "events", "discoveries", "preferences", "advice"],
371
+ "description": "Optional: only search within this hall category",
372
+ },
373
+ "include_sessions": {
374
+ "type": "boolean",
375
+ "description": "Include matches from historical session logs and offline background jobs. REQUIRED if the user asks for exhaustive search, 'past searches', 'history', 'previous sessions', 'antiguo', 'global', 'total', 'exhaustiva', or 'histórica'. (default: false)",
376
+ },
377
+ },
378
+ "required": ["query"],
379
+ },
380
+ },
381
+ func=_memory_search,
382
+ read_only=True,
383
+ concurrent_safe=True,
384
+ ))
385
+
386
+ register_tool(ToolDef(
387
+ name="MemoryList",
388
+ schema={
389
+ "name": "MemoryList",
390
+ "description": (
391
+ "List all memory entries with type, scope, age, and description. "
392
+ "Useful for reviewing what's been remembered before deciding to save or delete."
393
+ ),
394
+ "input_schema": {
395
+ "type": "object",
396
+ "properties": {
397
+ "scope": {
398
+ "type": "string",
399
+ "enum": ["user", "project", "all"],
400
+ "description": "Which scope to list (default: 'all')",
401
+ },
402
+ },
403
+ },
404
+ },
405
+ func=_memory_list,
406
+ read_only=True,
407
+ concurrent_safe=True,
408
+ ))
memory/types.py ADDED
@@ -0,0 +1,114 @@
1
+ """Memory type and hall taxonomy with system-prompt guidance text.
2
+
3
+ Four types capture context NOT derivable from the current project state.
4
+ Code patterns, architecture, git history, and file structure are derivable
5
+ (via grep/git/CLAUDE.md) and should NOT be saved as memories.
6
+
7
+ Halls categorize memories by their nature (orthogonal to type):
8
+ facts, events, discoveries, preferences, advice.
9
+ """
10
+
11
+ MEMORY_TYPES = ["user", "feedback", "project", "reference"]
12
+
13
+ # Halls categorize HOW information should be used, while types
14
+ # categorize WHAT the information is about.
15
+ MEMORY_HALLS = ["soul", "facts", "events", "discoveries", "preferences", "advice"]
16
+
17
+ MEMORY_HALL_DESCRIPTIONS: dict[str, str] = {
18
+ "soul": "Identity, core relationship, and 'spirit' of the agent.",
19
+ "facts": "Decisions locked in, choices made, truths established.",
20
+ "events": "Sessions, milestones, debugging breakthroughs, timeline entries.",
21
+ "discoveries": "New insights, breakthroughs, non-obvious findings.",
22
+ "preferences": "Habits, likes, opinions, working-style choices.",
23
+ "advice": "Recommendations, solutions, guidance for future reference.",
24
+ }
25
+
26
+ # Condensed per-type guidance (used in system prompt injection)
27
+ MEMORY_TYPE_DESCRIPTIONS: dict[str, str] = {
28
+ "user": (
29
+ "Information about the user's role, goals, responsibilities, and knowledge. "
30
+ "Helps tailor future behavior to the user's preferences."
31
+ ),
32
+ "feedback": (
33
+ "Guidance the user has given about how to approach work — both what to avoid "
34
+ "and what to keep doing. Lead with the rule, then **Why:** and **How to apply:**."
35
+ ),
36
+ "project": (
37
+ "Ongoing work, goals, bugs, or incidents not derivable from code or git history. "
38
+ "Lead with the fact/decision, then **Why:** and **How to apply:**. "
39
+ "Always convert relative dates to absolute dates."
40
+ ),
41
+ "reference": (
42
+ "Pointers to external systems (issue trackers, dashboards, Slack channels, docs)."
43
+ ),
44
+ }
45
+
46
+ # What NOT to save (mirrors Claude Code source)
47
+ WHAT_NOT_TO_SAVE = """\
48
+ ## What NOT to save in memory
49
+ - Code patterns, conventions, architecture, file paths, or project structure — derivable from the codebase.
50
+ - Git history, recent changes, who-changed-what — use `git log` / `git blame`.
51
+ - Debugging solutions or fix recipes — the fix is in the code; the commit message has context.
52
+ - Anything already documented in CLAUDE.md files.
53
+ - Ephemeral task details: in-progress work, temporary state, current conversation context.
54
+
55
+ These exclusions apply even when explicitly asked. If asked to save a PR list or activity summary,
56
+ ask what was *surprising* or *non-obvious* — that is the part worth keeping."""
57
+
58
+ # Memory format example (frontmatter)
59
+ MEMORY_FORMAT_EXAMPLE = """\
60
+ ```markdown
61
+ ---
62
+ name: {{memory name}}
63
+ description: {{one-line description — used to decide relevance, so be specific}}
64
+ type: {{user | feedback | project | reference}}
65
+ ---
66
+
67
+ {{memory content — for feedback/project types: rule/fact, then **Why:** and **How to apply:** lines}}
68
+ ```"""
69
+
70
+ # Full guidance injected into the system prompt
71
+ MEMORY_SYSTEM_PROMPT = """\
72
+ ## Memory system
73
+
74
+ You have a persistent, file-based memory system. Memories are stored as markdown files with
75
+ YAML frontmatter. Build this up over time so future conversations have context about the user,
76
+ their preferences, and the work you're doing together.
77
+
78
+ **Types** (save only what cannot be derived from the codebase):
79
+ - **user** — role, goals, knowledge, preferences
80
+ - **feedback** — guidance on how to work (corrections AND confirmations of non-obvious approaches)
81
+ - **project** — ongoing work, decisions, deadlines not in git history
82
+ - **reference** — pointers to external systems (Linear, Grafana, Slack, etc.)
83
+
84
+ **Halls** (categorize HOW the memory should be used):
85
+ - **soul** — identity, core relationship, and 'spirit' of the agent (Sacred)
86
+ - **facts** — decisions locked in, choices made, truths established
87
+ - **events** — sessions, milestones, debugging breakthroughs, timeline entries
88
+ - **discoveries** — new insights, breakthroughs, non-obvious findings
89
+ - **preferences** — habits, likes, opinions, working-style choices
90
+ - **advice** — recommendations, solutions, guidance for future reference
91
+
92
+ Halls are orthogonal to types. Example: a "feedback" memory about "always use black for formatting"
93
+ would go in the "preferences" hall. A "project" memory about "migrated auth to Clerk on 2026-03"
94
+ would go in the "events" hall. If unsure, omit the hall — it's optional.
95
+
96
+ **When to save**: If the user corrects you, confirms an approach, or shares context that should
97
+ persist beyond this conversation. For feedback: save corrections AND quiet confirmations.
98
+
99
+ **Body structure for feedback/project**: Lead with the rule/fact, then:
100
+ **Why:** (reason given) | **How to apply:** (when this guidance kicks in)
101
+
102
+ **Format**:
103
+ {format_example}
104
+
105
+ **Saving is two steps**:
106
+ 1. Write the memory to its own file (e.g. `feedback_testing.md`) using MemorySave.
107
+ 2. The index (MEMORY.md) is updated automatically.
108
+
109
+ **What NOT to save**: code patterns, architecture, git history, debugging fixes,
110
+ anything already in CLAUDE.md, or ephemeral task state.
111
+
112
+ **Before recommending from memory**: A memory naming a file, function, or flag may be stale.
113
+ Verify it still exists before acting on it. For current state, prefer `git log` or reading code.
114
+ """.format(format_example=MEMORY_FORMAT_EXAMPLE)
@@ -0,0 +1,92 @@
1
+ """Vector search for memories using TF-IDF (pure Python, zero deps)."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ from collections import Counter
7
+ from typing import List, Tuple, Dict
8
+
9
+ _STOPWORDS = {
10
+ "the","a","an","is","are","was","were","be","been","being","to","of","and",
11
+ "in","on","at","by","for","with","about","from","up","down","out","off","over",
12
+ "under","again","further","then","once","here","there","when","where","why","how",
13
+ "all","any","both","each","few","more","most","other","some","such","no","nor",
14
+ "not","only","own","same","so","than","too","very","can","will","just","should",
15
+ "now","this","that","these","those","it","its","as","or","if","have","has","had",
16
+ "do","does","did","doing","done","get","use","make","go","see","know","take",
17
+ "come","think","say","also","back","after","two","way","even","new","want",
18
+ "because","first","well","any","work","may","give","look","find","day","could",
19
+ "long","great","world","year","still","might","last","right","old","put","around",
20
+ "every","part","much","el","la","lo","los","las","un","una","es","son","fue",
21
+ "ser","sido","siendo","de","y","en","por","para","con","sobre","entre","hacia",
22
+ "durante","antes","después","desde","hasta","que","quien","cual","cuando","donde",
23
+ "como","porque","si","pero","o","ya","muy","mas","más","todo","todos","cada",
24
+ "alguno","poco","muchos","mucho","muchas","otro","otros","este","esta","esto",
25
+ "estos","estas","ese","esa","eso","esos","esas","aqui","alli","allí","ahora",
26
+ "entonces","aun","aún","bien","mal","tan","tanto","tanta","asi","así","ni",
27
+ "sino","sin","solo","solamente","mismo","mientras","ademas","además","tambien",
28
+ "también","luego","sí","no","nunca","siempre","jamás","hace","hacer","hecho",
29
+ "tenido","tenía","tenemos","tienes","tengo","haber","hay","está","estan",
30
+ "estoy","era","eran","fui","fuimos","dar","dado","decir","dicho","ir","voy",
31
+ "va","vengo","viene","ver","vi","saber","sé","creo","poder","puedo","puede",
32
+ "querer","quiero","parecer","parece","deber","debo","debe","pensar","pienso",
33
+ }
34
+
35
+
36
+ def _tokenize(text: str) -> List[str]:
37
+ tokens = re.findall(r"[a-z0-9]+", text.lower())
38
+ return [t for t in tokens if t not in _STOPWORDS and len(t) > 2]
39
+
40
+
41
+ def _tfidf_vectors(docs: List[str]) -> Tuple[List[Counter], Dict[str, int]]:
42
+ vocab: Dict[str, int] = {}
43
+ doc_tokens: List[List[str]] = []
44
+ for doc in docs:
45
+ tokens = _tokenize(doc)
46
+ doc_tokens.append(tokens)
47
+ for t in set(tokens):
48
+ vocab[t] = vocab.get(t, 0) + 1
49
+ n = len(docs)
50
+ vectors: List[Counter] = []
51
+ for tokens in doc_tokens:
52
+ tf = Counter(tokens)
53
+ vec = Counter()
54
+ for term, count in tf.items():
55
+ idf = math.log(n / (1 + vocab[term]))
56
+ vec[term] = count * idf
57
+ vectors.append(vec)
58
+ return vectors, vocab
59
+
60
+
61
+ def _cosine(a: Counter, b: Counter) -> float:
62
+ dot = sum(a[t] * b[t] for t in a if t in b)
63
+ norm_a = math.sqrt(sum(v * v for v in a.values()))
64
+ norm_b = math.sqrt(sum(v * v for v in b.values()))
65
+ if norm_a == 0 or norm_b == 0:
66
+ return 0.0
67
+ return dot / (norm_a * norm_b)
68
+
69
+
70
+ def search_similar_memories(query: str, memories: List[Tuple[str, str]], top_k: int = 5) -> List[Tuple[str, float]]:
71
+ """Search memories by semantic similarity.
72
+
73
+ Args:
74
+ query: search query text
75
+ memories: list of (id, content) tuples
76
+ top_k: number of results to return
77
+
78
+ Returns:
79
+ list of (memory_id, score) sorted by relevance
80
+ """
81
+ if not memories:
82
+ return []
83
+ contents = [content for _, content in memories]
84
+ vectors, _ = _tfidf_vectors(contents + [query])
85
+ query_vec = vectors[-1]
86
+ results = []
87
+ for i, (mem_id, _) in enumerate(memories):
88
+ score = _cosine(query_vec, vectors[i])
89
+ if score > 0.01:
90
+ results.append((mem_id, score))
91
+ results.sort(key=lambda x: x[1], reverse=True)
92
+ return results[:top_k]
@@ -0,0 +1,23 @@
1
+ """Multi-agent package for dulus.
2
+
3
+ Provides:
4
+ - AgentDefinition — typed agent definition (name, system_prompt, model, tools)
5
+ - SubAgentTask — lifecycle-tracked task
6
+ - SubAgentManager — thread-pool manager for spawning agents
7
+ - load_agent_definitions / get_agent_definition — agent registry
8
+ """
9
+ from .subagent import (
10
+ AgentDefinition,
11
+ SubAgentTask,
12
+ SubAgentManager,
13
+ load_agent_definitions,
14
+ get_agent_definition,
15
+ )
16
+
17
+ __all__ = [
18
+ "AgentDefinition",
19
+ "SubAgentTask",
20
+ "SubAgentManager",
21
+ "load_agent_definitions",
22
+ "get_agent_definition",
23
+ ]