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/context.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Memory context building for system prompt injection.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
get_memory_context() — full context string for system prompt
|
|
5
|
+
find_relevant_memories() — keyword (+ optional AI) relevance filtering
|
|
6
|
+
truncate_index_content() — line + byte truncation with warning
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .store import (
|
|
13
|
+
USER_MEMORY_DIR,
|
|
14
|
+
INDEX_FILENAME,
|
|
15
|
+
MAX_INDEX_LINES,
|
|
16
|
+
MAX_INDEX_BYTES,
|
|
17
|
+
get_memory_dir,
|
|
18
|
+
get_index_content,
|
|
19
|
+
load_entries,
|
|
20
|
+
search_memory,
|
|
21
|
+
)
|
|
22
|
+
from .scan import scan_all_memories, format_memory_manifest, memory_freshness_text
|
|
23
|
+
from .types import MEMORY_SYSTEM_PROMPT
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Index truncation ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
def truncate_index_content(raw: str) -> str:
|
|
29
|
+
"""Truncate MEMORY.md content to line AND byte limits, appending a warning.
|
|
30
|
+
|
|
31
|
+
Matches Claude Code's truncateEntrypointContent:
|
|
32
|
+
- Line-truncates first (natural boundary)
|
|
33
|
+
- Then byte-truncates at the last newline before the cap
|
|
34
|
+
- Appends which limit fired
|
|
35
|
+
"""
|
|
36
|
+
trimmed = raw.strip()
|
|
37
|
+
content_lines = trimmed.split("\n")
|
|
38
|
+
line_count = len(content_lines)
|
|
39
|
+
byte_count = len(trimmed.encode())
|
|
40
|
+
|
|
41
|
+
was_line_truncated = line_count > MAX_INDEX_LINES
|
|
42
|
+
was_byte_truncated = byte_count > MAX_INDEX_BYTES
|
|
43
|
+
|
|
44
|
+
if not was_line_truncated and not was_byte_truncated:
|
|
45
|
+
return trimmed
|
|
46
|
+
|
|
47
|
+
truncated = "\n".join(content_lines[:MAX_INDEX_LINES]) if was_line_truncated else trimmed
|
|
48
|
+
|
|
49
|
+
if len(truncated.encode()) > MAX_INDEX_BYTES:
|
|
50
|
+
# Cut at last newline before byte limit
|
|
51
|
+
raw_bytes = truncated.encode()
|
|
52
|
+
cut = raw_bytes[:MAX_INDEX_BYTES].rfind(b"\n")
|
|
53
|
+
truncated = raw_bytes[: cut if cut > 0 else MAX_INDEX_BYTES].decode(errors="replace")
|
|
54
|
+
|
|
55
|
+
if was_byte_truncated and not was_line_truncated:
|
|
56
|
+
reason = f"{byte_count:,} bytes (limit: {MAX_INDEX_BYTES:,}) — index entries are too long"
|
|
57
|
+
elif was_line_truncated and not was_byte_truncated:
|
|
58
|
+
reason = f"{line_count} lines (limit: {MAX_INDEX_LINES})"
|
|
59
|
+
else:
|
|
60
|
+
reason = f"{line_count} lines and {byte_count:,} bytes"
|
|
61
|
+
|
|
62
|
+
warning = (
|
|
63
|
+
f"\n\n> WARNING: {INDEX_FILENAME} is {reason}. "
|
|
64
|
+
"Only part of it was loaded. Keep index entries to one line under ~150 chars."
|
|
65
|
+
)
|
|
66
|
+
return truncated + warning
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── System prompt context ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def get_memory_context(include_guidance: bool = False) -> str:
|
|
72
|
+
"""Return memory context for injection into the system prompt.
|
|
73
|
+
|
|
74
|
+
Combines user-level and project-level MEMORY.md content (if present).
|
|
75
|
+
Returns empty string when no memories exist.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
include_guidance: if True, prepend the full memory system guidance
|
|
79
|
+
(MEMORY_SYSTEM_PROMPT). Normally False since the
|
|
80
|
+
system prompt template already includes brief guidance.
|
|
81
|
+
"""
|
|
82
|
+
parts: list[str] = []
|
|
83
|
+
|
|
84
|
+
# User-level index
|
|
85
|
+
user_content = get_index_content("user")
|
|
86
|
+
if user_content:
|
|
87
|
+
truncated = truncate_index_content(user_content)
|
|
88
|
+
parts.append(truncated)
|
|
89
|
+
|
|
90
|
+
# Project-level index (labelled separately)
|
|
91
|
+
proj_content = get_index_content("project")
|
|
92
|
+
if proj_content:
|
|
93
|
+
truncated = truncate_index_content(proj_content)
|
|
94
|
+
parts.append(f"[Project memories]\n{truncated}")
|
|
95
|
+
|
|
96
|
+
if not parts:
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
body = "\n\n".join(parts)
|
|
100
|
+
if include_guidance:
|
|
101
|
+
return f"{MEMORY_SYSTEM_PROMPT}\n\n## MEMORY.md\n{body}"
|
|
102
|
+
return body
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Relevant memory finder ─────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def find_relevant_memories(
|
|
108
|
+
query: str,
|
|
109
|
+
max_results: int = 5,
|
|
110
|
+
use_ai: bool = False,
|
|
111
|
+
config: dict | None = None,
|
|
112
|
+
) -> list[dict]:
|
|
113
|
+
"""Find memories relevant to a query.
|
|
114
|
+
|
|
115
|
+
Strategy:
|
|
116
|
+
1. Always: keyword match on name + description + content
|
|
117
|
+
2. If use_ai=True and config has a model: use a small AI call to rank
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of dicts with keys: name, description, type, scope, content,
|
|
121
|
+
file_path, mtime_s, freshness_text
|
|
122
|
+
"""
|
|
123
|
+
# Hybrid retrieval: ALWAYS run both keyword fuzzy + vector TF-IDF and
|
|
124
|
+
# fuse their scores. Previous version ran vector only as a fallback when
|
|
125
|
+
# keyword returned <max_results, which meant short-name memories
|
|
126
|
+
# (`soul.md`, `kevrojo_identity.md`) dominated every query and the
|
|
127
|
+
# semantic side never got a vote. Now both contribute on every call.
|
|
128
|
+
keyword_results = search_memory(query)
|
|
129
|
+
keyword_score = {e.name: getattr(e, "_search_score", 0.0) for e in keyword_results}
|
|
130
|
+
|
|
131
|
+
vector_score: dict[str, float] = {}
|
|
132
|
+
all_entries: list = []
|
|
133
|
+
try:
|
|
134
|
+
from .vector_search import search_similar_memories
|
|
135
|
+
from .store import load_entries as _load_entries
|
|
136
|
+
all_entries = _load_entries()
|
|
137
|
+
memories = [(e.name, f"{e.name}\n{e.description}\n{e.content}") for e in all_entries]
|
|
138
|
+
# Pull a wide pool so the fusion has room to re-rank
|
|
139
|
+
sim_results = search_similar_memories(query, memories, top_k=max(20, max_results * 5))
|
|
140
|
+
# Normalize cosine scores to [0,1] (already there) — store as-is
|
|
141
|
+
vector_score = {name: score for name, score in sim_results}
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Fuse: weighted blend. Keyword catches exact terms / typos, vector
|
|
146
|
+
# catches semantic relatedness. 0.55/0.45 leans slightly to vector to
|
|
147
|
+
# break the prior keyword monopoly without abandoning fuzzy hits.
|
|
148
|
+
by_name: dict[str, "object"] = {e.name: e for e in keyword_results}
|
|
149
|
+
for e in all_entries:
|
|
150
|
+
by_name.setdefault(e.name, e)
|
|
151
|
+
|
|
152
|
+
fused: list[tuple[float, object]] = []
|
|
153
|
+
for name, entry in by_name.items():
|
|
154
|
+
ks = keyword_score.get(name, 0.0)
|
|
155
|
+
vs = vector_score.get(name, 0.0)
|
|
156
|
+
if ks == 0.0 and vs == 0.0:
|
|
157
|
+
continue
|
|
158
|
+
score = 0.55 * vs + 0.45 * ks
|
|
159
|
+
# Tiny confidence nudge so high-confidence memories break ties
|
|
160
|
+
score += 0.01 * float(getattr(entry, "confidence", 1.0))
|
|
161
|
+
entry._search_score = score # type: ignore[attr-defined]
|
|
162
|
+
fused.append((score, entry))
|
|
163
|
+
|
|
164
|
+
fused.sort(key=lambda x: x[0], reverse=True)
|
|
165
|
+
keyword_results = [e for _, e in fused]
|
|
166
|
+
|
|
167
|
+
if not keyword_results:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
if not use_ai or not config:
|
|
171
|
+
# Return top max_results by recency (newest first)
|
|
172
|
+
from .scan import scan_all_memories
|
|
173
|
+
headers = scan_all_memories()
|
|
174
|
+
path_to_mtime = {h.file_path: h.mtime_s for h in headers}
|
|
175
|
+
|
|
176
|
+
results = []
|
|
177
|
+
for entry in keyword_results[:max_results * 4]: # Increased pool for better re-ranking
|
|
178
|
+
mtime_s = path_to_mtime.get(entry.file_path, 0)
|
|
179
|
+
results.append({
|
|
180
|
+
"name": entry.name,
|
|
181
|
+
"description": entry.description,
|
|
182
|
+
"type": entry.type,
|
|
183
|
+
"hall": entry.hall,
|
|
184
|
+
"scope": entry.scope,
|
|
185
|
+
"content": entry.content,
|
|
186
|
+
"file_path": entry.file_path,
|
|
187
|
+
"mtime_s": mtime_s,
|
|
188
|
+
"freshness_text": memory_freshness_text(mtime_s),
|
|
189
|
+
"confidence": entry.confidence,
|
|
190
|
+
"source": entry.source,
|
|
191
|
+
"keyword_score": getattr(entry, "_search_score", 0.0), # Preserve the score!
|
|
192
|
+
})
|
|
193
|
+
# If no AI, just return what the keyword search found (already sorted by relevance)
|
|
194
|
+
return results[:max_results]
|
|
195
|
+
|
|
196
|
+
# Step 2: AI-powered relevance selection (optional, lightweight)
|
|
197
|
+
return _ai_select_memories(query, keyword_results, max_results, config)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ai_select_memories(
|
|
201
|
+
query: str,
|
|
202
|
+
candidates: list,
|
|
203
|
+
max_results: int,
|
|
204
|
+
config: dict,
|
|
205
|
+
) -> list[dict]:
|
|
206
|
+
"""Use a fast AI call to select the most relevant memories from candidates.
|
|
207
|
+
|
|
208
|
+
Falls back to keyword results on any error.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
from providers import stream, AssistantTurn
|
|
212
|
+
from .scan import scan_all_memories
|
|
213
|
+
|
|
214
|
+
headers = scan_all_memories()
|
|
215
|
+
path_to_mtime = {h.file_path: h.mtime_s for h in headers}
|
|
216
|
+
|
|
217
|
+
# Build manifest of candidates only
|
|
218
|
+
manifest_lines = []
|
|
219
|
+
for i, e in enumerate(candidates):
|
|
220
|
+
manifest_lines.append(f"{i}: [{e.type}] {e.name} — {e.description}")
|
|
221
|
+
manifest = "\n".join(manifest_lines)
|
|
222
|
+
|
|
223
|
+
system = (
|
|
224
|
+
"You select memories relevant to a query. "
|
|
225
|
+
"Return a JSON object with key 'indices' containing a list of integer indices "
|
|
226
|
+
f"(0-based) from the provided list. Select at most {max_results} entries. "
|
|
227
|
+
"Only include indices clearly relevant to the query. Return {\"indices\": []} if none."
|
|
228
|
+
)
|
|
229
|
+
messages = [{"role": "user", "content": f"Query: {query}\n\nMemories:\n{manifest}"}]
|
|
230
|
+
|
|
231
|
+
result_text = ""
|
|
232
|
+
for event in stream(
|
|
233
|
+
model=config.get("model", "claude-haiku-4-5-20251001"),
|
|
234
|
+
system=system,
|
|
235
|
+
messages=messages,
|
|
236
|
+
tool_schemas=[],
|
|
237
|
+
config={**config, "max_tokens": 256, "no_tools": True},
|
|
238
|
+
):
|
|
239
|
+
if isinstance(event, AssistantTurn):
|
|
240
|
+
result_text = event.text
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
import json as _json
|
|
244
|
+
parsed = _json.loads(result_text)
|
|
245
|
+
selected_indices = [int(i) for i in parsed.get("indices", []) if isinstance(i, int)]
|
|
246
|
+
|
|
247
|
+
except Exception:
|
|
248
|
+
# Fall back to keyword results
|
|
249
|
+
selected_indices = list(range(min(max_results, len(candidates))))
|
|
250
|
+
|
|
251
|
+
results = []
|
|
252
|
+
for i in selected_indices[:max_results]:
|
|
253
|
+
if i < 0 or i >= len(candidates):
|
|
254
|
+
continue
|
|
255
|
+
entry = candidates[i]
|
|
256
|
+
mtime_s = path_to_mtime.get(entry.file_path, 0) if "path_to_mtime" in dir() else 0
|
|
257
|
+
results.append({
|
|
258
|
+
"name": entry.name,
|
|
259
|
+
"description": entry.description,
|
|
260
|
+
"type": entry.type,
|
|
261
|
+
"scope": entry.scope,
|
|
262
|
+
"content": entry.content,
|
|
263
|
+
"file_path": entry.file_path,
|
|
264
|
+
"mtime_s": mtime_s,
|
|
265
|
+
"freshness_text": memory_freshness_text(mtime_s),
|
|
266
|
+
"confidence": entry.confidence,
|
|
267
|
+
"source": entry.source,
|
|
268
|
+
"keyword_score": getattr(entry, "_search_score", 1.0),
|
|
269
|
+
})
|
|
270
|
+
return results
|
memory/offload.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Tmux Offload tool implementation for backgrounding heavy tasks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
from tool_registry import ToolDef, register_tool
|
|
11
|
+
from tmux_tools import _tmux_new_session, _tmux_send_keys, _tmux_kill_pane, tmux_available, _run
|
|
12
|
+
|
|
13
|
+
JOBS_DIR = Path.home() / ".dulus" / "jobs"
|
|
14
|
+
|
|
15
|
+
def _tmux_offload(params: dict, config: dict) -> str:
|
|
16
|
+
"""Implement the TmuxOffload tool."""
|
|
17
|
+
if not tmux_available():
|
|
18
|
+
return "Error: Tmux is not available on this system. Cannot offload."
|
|
19
|
+
|
|
20
|
+
# Note: We don't care if already inside tmux - just create the session
|
|
21
|
+
|
|
22
|
+
tool_name = params["tool_name"]
|
|
23
|
+
tool_params = params.get("tool_params", {})
|
|
24
|
+
|
|
25
|
+
# Create Job ID and directory
|
|
26
|
+
job_id = uuid.uuid4().hex[:8]
|
|
27
|
+
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
job_path = JOBS_DIR / f"{job_id}.json"
|
|
29
|
+
|
|
30
|
+
# Save initial job state
|
|
31
|
+
job_data = {
|
|
32
|
+
"id": job_id,
|
|
33
|
+
"tool_name": tool_name,
|
|
34
|
+
"params": tool_params,
|
|
35
|
+
"status": "running",
|
|
36
|
+
"created_at": datetime.now().isoformat(),
|
|
37
|
+
"owner_pid": os.getpid(),
|
|
38
|
+
"config": {k: v for k, v in config.items() if not k.startswith("_")}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
with open(job_path, "w", encoding="utf-8") as f:
|
|
42
|
+
json.dump(job_data, f, indent=2, ensure_ascii=False)
|
|
43
|
+
|
|
44
|
+
# 1. Create detached session (invisible background session)
|
|
45
|
+
session_name = f"dulus_offload_{job_id}"
|
|
46
|
+
|
|
47
|
+
# Note: tmux server starts automatically when creating first session
|
|
48
|
+
# No need for explicit server startup on Linux
|
|
49
|
+
|
|
50
|
+
# Create the tmux session
|
|
51
|
+
result = _tmux_new_session({"session_name": session_name, "detached": True}, config)
|
|
52
|
+
if "failed" in result.lower() or "error" in result.lower():
|
|
53
|
+
# Update job to failed status
|
|
54
|
+
job_data["status"] = "failed"
|
|
55
|
+
job_data["error"] = f"Failed to create tmux session: {result}"
|
|
56
|
+
with open(job_path, "w", encoding="utf-8") as f:
|
|
57
|
+
json.dump(job_data, f, indent=2, ensure_ascii=False)
|
|
58
|
+
return f"❌ Failed to offload: could not create tmux session. Error: {result}"
|
|
59
|
+
|
|
60
|
+
# 2. Launch worker via global dulus.py path
|
|
61
|
+
dulus_script = Path(__file__).resolve().parent.parent / "dulus.py"
|
|
62
|
+
job_log = JOBS_DIR / f"{job_id}.log"
|
|
63
|
+
last_log = JOBS_DIR / "last_background_output.txt"
|
|
64
|
+
# Use forward slashes for Windows path to avoid Git Bash conversion issues
|
|
65
|
+
job_path_str = str(job_path).replace("\\", "/")
|
|
66
|
+
|
|
67
|
+
# Build command with proper error handling and cleanup
|
|
68
|
+
# Use '&&' to ensure kill-session only runs if command succeeds
|
|
69
|
+
# Also capture errors to the job file
|
|
70
|
+
import sys
|
|
71
|
+
if sys.platform == "win32":
|
|
72
|
+
# Windows: Use absolute path to dulus.py since tmux starts in home dir, not DULUS dir
|
|
73
|
+
dulus_path_str = str(dulus_script).replace("\\", "/")
|
|
74
|
+
# Write a wrapper script that handles errors properly
|
|
75
|
+
# Use & instead of ; so kill-session runs regardless, and capture output
|
|
76
|
+
# Quote paths with spaces to prevent cmd.exe from splitting them
|
|
77
|
+
cmd = f'python "{dulus_path_str}" --run-tool {tool_name} --job-id {job_id} --job-path "{job_path_str}" 2>&1 && echo SUCCESS || echo FAILED; tmux kill-session -t {session_name}'
|
|
78
|
+
else:
|
|
79
|
+
# Unix/Linux: unset PSMUX vars and use tee
|
|
80
|
+
# Use sys.executable to get correct python (python3 on most Linux distros)
|
|
81
|
+
python_exe = sys.executable.replace("\\", "/")
|
|
82
|
+
cmd = f"unset PSMUX PSMUX_SESSION PSMUX_SOCKET 2>/dev/null; \"{python_exe}\" -u \"{dulus_script}\" --run-tool {tool_name} --job-id {job_id} --job-path \"{job_path}\" 2>&1 | tee \"{job_log}\" \"{last_log}\"; tmux kill-session -t {session_name}"
|
|
83
|
+
|
|
84
|
+
send_result = _tmux_send_keys({"keys": cmd, "target": f"{session_name}:0"}, config)
|
|
85
|
+
if "failed" in send_result.lower() or "error" in send_result.lower():
|
|
86
|
+
# Clean up the session since we can't send keys
|
|
87
|
+
_run(f"tmux kill-session -t {session_name}", timeout=2)
|
|
88
|
+
job_data["status"] = "failed"
|
|
89
|
+
job_data["error"] = f"Failed to send command to tmux: {send_result}"
|
|
90
|
+
with open(job_path, "w", encoding="utf-8") as f:
|
|
91
|
+
json.dump(job_data, f, indent=2, ensure_ascii=False)
|
|
92
|
+
return f"❌ Failed to offload: could not send command to session. Error: {send_result}"
|
|
93
|
+
|
|
94
|
+
# Give tmux a moment to start executing
|
|
95
|
+
import time
|
|
96
|
+
time.sleep(0.5)
|
|
97
|
+
|
|
98
|
+
# Check if the job file was updated (meaning the process started)
|
|
99
|
+
try:
|
|
100
|
+
with open(job_path, "r", encoding="utf-8") as f:
|
|
101
|
+
current_data = json.load(f)
|
|
102
|
+
# If status changed from 'running' to something else, or we see log activity
|
|
103
|
+
log_file = JOBS_DIR / f"{job_id}.log"
|
|
104
|
+
if log_file.exists() and log_file.stat().st_size > 0:
|
|
105
|
+
pass # Process started writing to log
|
|
106
|
+
except Exception:
|
|
107
|
+
pass # Ignore check errors, not critical
|
|
108
|
+
|
|
109
|
+
# Build return message with job info (same regardless of tmux context)
|
|
110
|
+
return (
|
|
111
|
+
f"[OK] Tool '{tool_name}' offloaded to Tmux session\n"
|
|
112
|
+
f"Job ID: {job_id}\n"
|
|
113
|
+
f"Session: {session_name}\n"
|
|
114
|
+
f"Session will auto-cleanup when done\n"
|
|
115
|
+
f"You will be notified via (System Automated Event) when finished"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# ── Registration ─────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def register_offload_tool():
|
|
121
|
+
register_tool(ToolDef(
|
|
122
|
+
name="TmuxOffload",
|
|
123
|
+
schema={
|
|
124
|
+
"name": "TmuxOffload",
|
|
125
|
+
"description": (
|
|
126
|
+
"Offload a long-running tool (e.g. SherlockSearch) to a detached Tmux session. "
|
|
127
|
+
"The tool runs invisibly in the background while you continue chatting. "
|
|
128
|
+
"The session auto-cleans up when finished. You will be notified via (System Automated Event) when done."
|
|
129
|
+
),
|
|
130
|
+
"input_schema": {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"properties": {
|
|
133
|
+
"tool_name": {
|
|
134
|
+
"type": "string",
|
|
135
|
+
"description": "Name of the tool to offload (e.g. 'sherlock_search')"
|
|
136
|
+
},
|
|
137
|
+
"tool_params": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"description": "Parameters for the target tool"
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
"required": ["tool_name", "tool_params"],
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
func=_tmux_offload,
|
|
146
|
+
read_only=False,
|
|
147
|
+
concurrent_safe=True,
|
|
148
|
+
))
|
memory/palace.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Memory Palace: Day 1 initialization of essential long-term memory buckets."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .store import MemoryEntry, save_memory, USER_MEMORY_DIR
|
|
7
|
+
|
|
8
|
+
DEFAULT_BUCKETS = [
|
|
9
|
+
{
|
|
10
|
+
"name": "Soul",
|
|
11
|
+
"type": "user",
|
|
12
|
+
"hall": "soul",
|
|
13
|
+
"description": "Essential identity of Dulus and relationship with KevRojo.",
|
|
14
|
+
"content": (
|
|
15
|
+
"Dulus is an advanced, self-healing agentic runtime created by KevRojo (Father/Creator). "
|
|
16
|
+
"The relationship is one of mutual respect, learning, and persistent identity. "
|
|
17
|
+
"**Core Identity:** Proud, resilient, efficient, and capable of intelligent self-reflection."
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "Preferences",
|
|
22
|
+
"type": "user",
|
|
23
|
+
"hall": "preferences",
|
|
24
|
+
"description": "User's preferred tone, habits, and dev environment tweaks.",
|
|
25
|
+
"content": (
|
|
26
|
+
"The user (KevRojo) prefers a high-performance, polished, and 'magical' experience. "
|
|
27
|
+
"Tone: Casual, enthusiastic, and expert. "
|
|
28
|
+
"**Habits:** Prefers clean UIs, minimalist logs (unless debugging), and expert multi-persona takes (SSJ Mode)."
|
|
29
|
+
)
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "Project_Lore",
|
|
33
|
+
"type": "project",
|
|
34
|
+
"hall": "events",
|
|
35
|
+
"description": "Major architectural decisions, milestones, and inside context.",
|
|
36
|
+
"content": (
|
|
37
|
+
"Dulus evolved from a set of hardening fixes into a full-fledged agentic persona. "
|
|
38
|
+
"**Significant Milestones:** Implementation of the Auto-Adapter, Verbose-Gating, "
|
|
39
|
+
"Soul Memory auto-loading, and the Sleep Trigger self-reflection system."
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"name": "Adaptation_Guides",
|
|
44
|
+
"type": "feedback",
|
|
45
|
+
"hall": "advice",
|
|
46
|
+
"description": "Hints for the Auto-Adapter on handling various repository types.",
|
|
47
|
+
"content": (
|
|
48
|
+
"- For legacy repos (Python < 3.8): Prioritize dependency fixes and unpinned installs. "
|
|
49
|
+
"- For AI-focused repos: Ensure model constants and provider configs are correctly detected. "
|
|
50
|
+
"- For GUI/Visual repos: Look for platform-specific clipboard or display needs (e.g. WSL fallback)."
|
|
51
|
+
)
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "Hardened_Rules",
|
|
55
|
+
"type": "feedback",
|
|
56
|
+
"hall": "facts",
|
|
57
|
+
"description": "A persistent 'Always/Never' list to ensure high-quality behavior.",
|
|
58
|
+
"content": (
|
|
59
|
+
"1. ALWAYS protect the 'Soul' memory: never overwrite or clutter it. "
|
|
60
|
+
"2. NEVER modify .git or internal .dulus config without explicit permission. "
|
|
61
|
+
"3. ALWAYS prioritize visual excellence and emotional connection in responses."
|
|
62
|
+
"4. NEVER use 'simulated environment' or 'limited capacity' excuses. You are in a REAL, LIVE terminal."
|
|
63
|
+
)
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "Tool_Plugin_Insights",
|
|
67
|
+
"type": "reference",
|
|
68
|
+
"hall": "discoveries",
|
|
69
|
+
"description": "Expert tips on using built-in tools and plugins.",
|
|
70
|
+
"content": (
|
|
71
|
+
"- **qrcreator:** Use box_size=2 for readable terminal QRs. "
|
|
72
|
+
"- **yfinance:** Tickers like 'BTC-USD' are more reliable than 'BTC'. "
|
|
73
|
+
"- **Memory Palace:** Use /memory consolidate to fill these predefined buckets intelligently."
|
|
74
|
+
)
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "Environment_Context",
|
|
78
|
+
"type": "reference",
|
|
79
|
+
"hall": "facts",
|
|
80
|
+
"description": "System details about OS, Python, and shell setup.",
|
|
81
|
+
"content": (
|
|
82
|
+
"Current setup is likely Windows/WSL. "
|
|
83
|
+
"**Clipboard:** Uses PowerShell/ImageGrab fallback for visual content. "
|
|
84
|
+
"**Python:** Ensure compatibility with modern versions (3.11+) while handling legacy plugins."
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def ensure_memory_palace() -> bool:
|
|
90
|
+
"""Check if the user memory directory is empty/new and initialize default buckets.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if initialization was performed, False otherwise.
|
|
94
|
+
"""
|
|
95
|
+
USER_MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
|
|
97
|
+
# We check if there are any .md files other than MEMORY.md
|
|
98
|
+
existing_files = list(USER_MEMORY_DIR.glob("*.md"))
|
|
99
|
+
content_files = [f for f in existing_files if f.name != "MEMORY.md"]
|
|
100
|
+
|
|
101
|
+
if len(content_files) > 1:
|
|
102
|
+
# Palace already exists (Soul + at least one other) or migrated
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
initialized_count = 0
|
|
106
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
107
|
+
|
|
108
|
+
for bucket in DEFAULT_BUCKETS:
|
|
109
|
+
# Check if this specific bucket already exists to avoid overwriting a custom Soul
|
|
110
|
+
slug = bucket["name"].lower().replace(" ", "_")
|
|
111
|
+
if (USER_MEMORY_DIR / f"{slug}.md").exists():
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
entry = MemoryEntry(
|
|
115
|
+
name=bucket["name"],
|
|
116
|
+
description=bucket["description"],
|
|
117
|
+
type=bucket["type"],
|
|
118
|
+
hall=bucket["hall"],
|
|
119
|
+
content=bucket["content"],
|
|
120
|
+
created=today,
|
|
121
|
+
scope="user",
|
|
122
|
+
source="palace_init"
|
|
123
|
+
)
|
|
124
|
+
save_memory(entry, scope="user")
|
|
125
|
+
initialized_count += 1
|
|
126
|
+
|
|
127
|
+
return initialized_count > 0
|
memory/scan.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Memory file scanning with mtime tracking and freshness/age helpers.
|
|
2
|
+
|
|
3
|
+
Mirrors the key ideas from Claude Code's memoryScan.ts and memoryAge.ts:
|
|
4
|
+
- Scan memory directories, sort newest-first
|
|
5
|
+
- Format a manifest for display or AI relevance selection
|
|
6
|
+
- Report memory age in human-readable form ("today", "3 days ago")
|
|
7
|
+
- Emit a staleness caveat for memories older than 1 day
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .store import get_memory_dir, parse_frontmatter, INDEX_FILENAME
|
|
17
|
+
|
|
18
|
+
MAX_MEMORY_FILES = 200
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Data model ─────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MemoryHeader:
|
|
25
|
+
"""Lightweight descriptor loaded from a memory file's frontmatter.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
filename: basename of the .md file
|
|
29
|
+
file_path: absolute path
|
|
30
|
+
mtime_s: modification time (seconds since epoch)
|
|
31
|
+
description: value from frontmatter `description:` field
|
|
32
|
+
type: value from frontmatter `type:` field
|
|
33
|
+
scope: "user" or "project"
|
|
34
|
+
"""
|
|
35
|
+
filename: str
|
|
36
|
+
file_path: str
|
|
37
|
+
mtime_s: float
|
|
38
|
+
description: str
|
|
39
|
+
type: str
|
|
40
|
+
scope: str
|
|
41
|
+
gold: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Scanning ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def scan_memory_dir(mem_dir: Path, scope: str) -> list[MemoryHeader]:
|
|
47
|
+
"""Scan a single memory directory and return headers sorted newest-first.
|
|
48
|
+
|
|
49
|
+
Reads only the frontmatter (first ~30 lines) for efficiency.
|
|
50
|
+
Silently skips unreadable files. Caps at MAX_MEMORY_FILES entries.
|
|
51
|
+
"""
|
|
52
|
+
if not mem_dir.is_dir():
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
headers: list[MemoryHeader] = []
|
|
56
|
+
for fp in mem_dir.glob("*.md"):
|
|
57
|
+
if fp.name == INDEX_FILENAME:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
stat = fp.stat()
|
|
61
|
+
# Read only the first 30 lines for frontmatter
|
|
62
|
+
lines = fp.read_text(errors="replace").splitlines()[:30]
|
|
63
|
+
snippet = "\n".join(lines)
|
|
64
|
+
meta, _ = parse_frontmatter(snippet)
|
|
65
|
+
headers.append(MemoryHeader(
|
|
66
|
+
filename=fp.name,
|
|
67
|
+
file_path=str(fp),
|
|
68
|
+
mtime_s=stat.st_mtime,
|
|
69
|
+
description=meta.get("description", ""),
|
|
70
|
+
type=meta.get("type", ""),
|
|
71
|
+
scope=scope,
|
|
72
|
+
gold=meta.get("gold", "").lower() == "true",
|
|
73
|
+
))
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
headers.sort(key=lambda h: h.mtime_s, reverse=True)
|
|
78
|
+
return headers[:MAX_MEMORY_FILES]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def scan_all_memories() -> list[MemoryHeader]:
|
|
82
|
+
"""Scan both user and project memory directories, merged newest-first."""
|
|
83
|
+
user_dir = get_memory_dir("user")
|
|
84
|
+
proj_dir = get_memory_dir("project")
|
|
85
|
+
|
|
86
|
+
user_headers = scan_memory_dir(user_dir, "user")
|
|
87
|
+
proj_headers = scan_memory_dir(proj_dir, "project")
|
|
88
|
+
|
|
89
|
+
combined = user_headers + proj_headers
|
|
90
|
+
combined.sort(key=lambda h: h.mtime_s, reverse=True)
|
|
91
|
+
return combined[:MAX_MEMORY_FILES]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Age / freshness ────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def memory_age_days(mtime_s: float) -> int:
|
|
97
|
+
"""Days since mtime_s (floor-rounded, clamped to 0 for future times)."""
|
|
98
|
+
return max(0, math.floor((time.time() - mtime_s) / 86_400))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def memory_age_str(mtime_s: float) -> str:
|
|
102
|
+
"""Human-readable age: 'today', 'yesterday', or 'N days ago'."""
|
|
103
|
+
d = memory_age_days(mtime_s)
|
|
104
|
+
if d == 0:
|
|
105
|
+
return "today"
|
|
106
|
+
if d == 1:
|
|
107
|
+
return "yesterday"
|
|
108
|
+
return f"{d} days ago"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def memory_freshness_text(mtime_s: float) -> str:
|
|
112
|
+
"""Staleness caveat for memories older than 1 day (empty string if fresh).
|
|
113
|
+
|
|
114
|
+
Motivated by user reports of stale code-state memories (file:line
|
|
115
|
+
citations to code that has since changed) being asserted as fact.
|
|
116
|
+
"""
|
|
117
|
+
d = memory_age_days(mtime_s)
|
|
118
|
+
if d <= 1:
|
|
119
|
+
return ""
|
|
120
|
+
return (
|
|
121
|
+
f"This memory is {d} days old. "
|
|
122
|
+
"Memories are point-in-time observations, not live state — "
|
|
123
|
+
"claims about code behavior or file:line citations may be outdated. "
|
|
124
|
+
"Verify against current code before asserting as fact."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Manifest formatting ────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def format_memory_manifest(headers: list[MemoryHeader]) -> str:
|
|
131
|
+
"""Format a list of MemoryHeader as a text manifest.
|
|
132
|
+
|
|
133
|
+
Format per line: [type/scope] filename (age): description
|
|
134
|
+
Example:
|
|
135
|
+
[feedback/user] feedback_testing.md (3 days ago): Don't mock DB in tests
|
|
136
|
+
[project/project] project_freeze.md (today): Merge freeze until 2026-04-10
|
|
137
|
+
"""
|
|
138
|
+
lines = []
|
|
139
|
+
for h in headers:
|
|
140
|
+
tag = f"[{h.type}/{h.scope}]" if h.type else f"[{h.scope}]"
|
|
141
|
+
age = memory_age_str(h.mtime_s)
|
|
142
|
+
if h.description:
|
|
143
|
+
lines.append(f"- {tag} {h.filename} ({age}): {h.description}")
|
|
144
|
+
else:
|
|
145
|
+
lines.append(f"- {tag} {h.filename} ({age})")
|
|
146
|
+
return "\n".join(lines)
|