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
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)
|
memory/vector_search.py
ADDED
|
@@ -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]
|
multi_agent/__init__.py
ADDED
|
@@ -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
|
+
]
|