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