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
license_manager.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Dulus License Manager — Offline-first key validation + feature gating.
|
|
2
|
+
|
|
3
|
+
Tiers:
|
|
4
|
+
FREE No key required. Limited tool calls, local providers only.
|
|
5
|
+
PRO $15/mo. Full features, BYOK, priority support.
|
|
6
|
+
ENTERPRISE $50/mo. Team features + admin dashboard + SSO (future).
|
|
7
|
+
|
|
8
|
+
Key format (offline):
|
|
9
|
+
DULUS-<base64(json_payload + ":" + hmac_signature)>
|
|
10
|
+
|
|
11
|
+
The secret lives in ~/.dulus/.license_secret (never commit this file).
|
|
12
|
+
If the secret file is missing we fall back to a hardcoded dev-key so
|
|
13
|
+
Kev can develop without friction, but distribution builds MUST bundle
|
|
14
|
+
a real secret via CI env var or PyInstaller --add-data.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hashlib
|
|
20
|
+
import hmac
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
# ── Secret resolution ───────────────────────────────────────────────────────
|
|
29
|
+
# 1. CI / build-time env var (safest for releases)
|
|
30
|
+
# 2. ~/.dulus/.license_secret (Kev's local dev key)
|
|
31
|
+
# 3. Fallback dev secret (NEVER use in production builds)
|
|
32
|
+
_LICENSE_SECRET = os.environ.get("DULUS_LICENSE_SECRET", "")
|
|
33
|
+
if not _LICENSE_SECRET:
|
|
34
|
+
_secret_path = Path.home() / ".dulus" / ".license_secret"
|
|
35
|
+
if _secret_path.exists():
|
|
36
|
+
_LICENSE_SECRET = _secret_path.read_text().strip()
|
|
37
|
+
else:
|
|
38
|
+
_LICENSE_SECRET = "dulus-dev-secret-do-not-distribute"
|
|
39
|
+
import warnings
|
|
40
|
+
warnings.warn(
|
|
41
|
+
"DULUS_LICENSE_SECRET not set — using hardcoded DEV secret. "
|
|
42
|
+
"Generated keys will be trivially forgeable in production!",
|
|
43
|
+
RuntimeWarning,
|
|
44
|
+
stacklevel=2,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LicenseTier:
|
|
49
|
+
FREE = "free"
|
|
50
|
+
PRO = "pro"
|
|
51
|
+
ENTERPRISE = "enterprise"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LicenseManager:
|
|
55
|
+
"""Parse and validate a Dulus license key."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, key: Optional[str] = None):
|
|
58
|
+
self.raw_key = key or ""
|
|
59
|
+
self.tier = LicenseTier.FREE
|
|
60
|
+
self.expiry: float = 0.0
|
|
61
|
+
self.features: list[str] = []
|
|
62
|
+
self.valid = False
|
|
63
|
+
self.error: Optional[str] = None
|
|
64
|
+
|
|
65
|
+
if self.raw_key:
|
|
66
|
+
self._validate()
|
|
67
|
+
|
|
68
|
+
# ── validation core ─────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
def _validate(self) -> None:
|
|
71
|
+
if not self.raw_key.startswith("DULUS-"):
|
|
72
|
+
self.error = "Invalid key prefix"
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
b64 = self.raw_key.split("-", 1)[1]
|
|
77
|
+
payload_sig = base64.urlsafe_b64decode(b64 + "==")
|
|
78
|
+
payload_json, sig_hex = payload_sig.rsplit(b":", 1)
|
|
79
|
+
data = json.loads(payload_json)
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
self.error = f"Malformed key: {exc}"
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# Verify HMAC-SHA256 signature
|
|
85
|
+
expected_sig = hmac.new(
|
|
86
|
+
_LICENSE_SECRET.encode(),
|
|
87
|
+
payload_json,
|
|
88
|
+
hashlib.sha256,
|
|
89
|
+
).hexdigest()[:24]
|
|
90
|
+
|
|
91
|
+
if not hmac.compare_digest(sig_hex.decode(), expected_sig):
|
|
92
|
+
self.error = "Invalid signature (tampered or wrong secret)"
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
self.tier = data.get("tier", LicenseTier.FREE)
|
|
96
|
+
self.expiry = data.get("exp", 0)
|
|
97
|
+
self.features = data.get("features", [])
|
|
98
|
+
|
|
99
|
+
if time.time() > self.expiry:
|
|
100
|
+
self.error = "License expired"
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
self.valid = True
|
|
104
|
+
|
|
105
|
+
# ── feature gates ───────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def can_use(self, feature: str) -> bool:
|
|
108
|
+
"""Check if a feature is allowed by current tier."""
|
|
109
|
+
if self.tier == LicenseTier.ENTERPRISE:
|
|
110
|
+
return True
|
|
111
|
+
if self.tier == LicenseTier.PRO:
|
|
112
|
+
return feature not in {"sso", "audit_logs", "admin_dashboard"}
|
|
113
|
+
# FREE
|
|
114
|
+
free_features = {"chat", "tools_basic", "local_providers"}
|
|
115
|
+
return feature in free_features
|
|
116
|
+
|
|
117
|
+
def max_tool_calls(self) -> int:
|
|
118
|
+
if self.tier == LicenseTier.ENTERPRISE:
|
|
119
|
+
return 999_999
|
|
120
|
+
if self.tier == LicenseTier.PRO:
|
|
121
|
+
return 10_000
|
|
122
|
+
return 25 # FREE daily limit
|
|
123
|
+
|
|
124
|
+
def max_providers(self) -> int:
|
|
125
|
+
if self.tier in (LicenseTier.PRO, LicenseTier.ENTERPRISE):
|
|
126
|
+
return 99
|
|
127
|
+
return 2 # FREE: e.g. ollama + 1 cloud
|
|
128
|
+
|
|
129
|
+
def max_subagents(self) -> int:
|
|
130
|
+
if self.tier == LicenseTier.ENTERPRISE:
|
|
131
|
+
return 50
|
|
132
|
+
if self.tier == LicenseTier.PRO:
|
|
133
|
+
return 10
|
|
134
|
+
return 0 # FREE: no subagents
|
|
135
|
+
|
|
136
|
+
def max_plugins(self) -> int:
|
|
137
|
+
if self.tier == LicenseTier.ENTERPRISE:
|
|
138
|
+
return 999
|
|
139
|
+
if self.tier == LicenseTier.PRO:
|
|
140
|
+
return 50
|
|
141
|
+
return 3 # FREE
|
|
142
|
+
|
|
143
|
+
def allow_cloudsave(self) -> bool:
|
|
144
|
+
return self.tier in (LicenseTier.PRO, LicenseTier.ENTERPRISE)
|
|
145
|
+
|
|
146
|
+
def allow_voice(self) -> bool:
|
|
147
|
+
return self.tier in (LicenseTier.PRO, LicenseTier.ENTERPRISE)
|
|
148
|
+
|
|
149
|
+
def allow_telegram(self) -> bool:
|
|
150
|
+
return self.tier in (LicenseTier.PRO, LicenseTier.ENTERPRISE)
|
|
151
|
+
|
|
152
|
+
def allow_mcp(self) -> bool:
|
|
153
|
+
return self.tier in (LicenseTier.PRO, LicenseTier.ENTERPRISE)
|
|
154
|
+
|
|
155
|
+
# ── UI helpers ──────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def status_banner(self) -> str:
|
|
158
|
+
if self.error:
|
|
159
|
+
return f"[LICENSE EXPIRED / INVALID] {self.error} — running in FREE mode"
|
|
160
|
+
if self.tier == LicenseTier.FREE:
|
|
161
|
+
return "[FREE] Limited features. Upgrade: https://getdulus.dev/pro"
|
|
162
|
+
return f"[{self.tier.upper()}] Valid until {time.strftime('%Y-%m-%d', time.localtime(self.expiry))}"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── CLI helper for Kev ─────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
def _generate_key(tier: str, days: int, secret: str) -> str:
|
|
168
|
+
"""Generate a signed license key (Kev-only tool)."""
|
|
169
|
+
payload = json.dumps({
|
|
170
|
+
"tier": tier,
|
|
171
|
+
"exp": int(time.time() + days * 86400),
|
|
172
|
+
"features": [],
|
|
173
|
+
"iat": int(time.time()),
|
|
174
|
+
}, separators=(",", ":")).encode()
|
|
175
|
+
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()[:24]
|
|
176
|
+
token = base64.urlsafe_b64encode(payload + b":" + sig.encode()).decode().rstrip("=")
|
|
177
|
+
return f"DULUS-{token}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
import argparse
|
|
182
|
+
ap = argparse.ArgumentParser(description="Dulus License Key Generator (Kev only)")
|
|
183
|
+
ap.add_argument("tier", choices=["free", "pro", "enterprise"])
|
|
184
|
+
ap.add_argument("--days", type=int, default=30)
|
|
185
|
+
ap.add_argument("--secret", default=_LICENSE_SECRET)
|
|
186
|
+
args = ap.parse_args()
|
|
187
|
+
print(_generate_key(args.tier, args.days, args.secret))
|
memory/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Memory package for dulus.
|
|
2
|
+
|
|
3
|
+
Provides persistent, file-based memory across conversations.
|
|
4
|
+
|
|
5
|
+
Storage layout:
|
|
6
|
+
user scope : ~/.dulus/memory/<slug>.md (shared across projects)
|
|
7
|
+
project scope : .dulus/memory/<slug>.md (local to cwd)
|
|
8
|
+
|
|
9
|
+
The MEMORY.md index in each directory is auto-maintained and injected
|
|
10
|
+
into the system prompt so Claude has an overview of available memories.
|
|
11
|
+
|
|
12
|
+
Public API (backward-compatible with the old memory.py module):
|
|
13
|
+
MemoryEntry — dataclass for a single memory
|
|
14
|
+
save_memory() — write/update a memory file
|
|
15
|
+
delete_memory() — remove a memory file
|
|
16
|
+
load_index() — load all entries from one or both scopes
|
|
17
|
+
search_memory() — keyword search across entries
|
|
18
|
+
get_memory_context() — MEMORY.md content for system prompt injection
|
|
19
|
+
"""
|
|
20
|
+
from .store import ( # noqa: F401
|
|
21
|
+
MemoryEntry,
|
|
22
|
+
save_memory,
|
|
23
|
+
delete_memory,
|
|
24
|
+
load_index,
|
|
25
|
+
load_entries,
|
|
26
|
+
search_memory,
|
|
27
|
+
get_index_content,
|
|
28
|
+
parse_frontmatter,
|
|
29
|
+
USER_MEMORY_DIR,
|
|
30
|
+
INDEX_FILENAME,
|
|
31
|
+
MAX_INDEX_LINES,
|
|
32
|
+
MAX_INDEX_BYTES,
|
|
33
|
+
)
|
|
34
|
+
from .scan import ( # noqa: F401
|
|
35
|
+
MemoryHeader,
|
|
36
|
+
scan_memory_dir,
|
|
37
|
+
scan_all_memories,
|
|
38
|
+
format_memory_manifest,
|
|
39
|
+
memory_age_days,
|
|
40
|
+
memory_age_str,
|
|
41
|
+
memory_freshness_text,
|
|
42
|
+
)
|
|
43
|
+
from .context import ( # noqa: F401
|
|
44
|
+
get_memory_context,
|
|
45
|
+
find_relevant_memories,
|
|
46
|
+
truncate_index_content,
|
|
47
|
+
)
|
|
48
|
+
from .types import ( # noqa: F401
|
|
49
|
+
MEMORY_TYPES,
|
|
50
|
+
MEMORY_TYPE_DESCRIPTIONS,
|
|
51
|
+
MEMORY_SYSTEM_PROMPT,
|
|
52
|
+
WHAT_NOT_TO_SAVE,
|
|
53
|
+
)
|
|
54
|
+
from .consolidator import consolidate_session, mine_files, snapshot_memory_files, new_memory_files # noqa: F401
|
|
55
|
+
from .palace import ensure_memory_palace # noqa: F401
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
# store
|
|
59
|
+
"MemoryEntry",
|
|
60
|
+
"save_memory",
|
|
61
|
+
"delete_memory",
|
|
62
|
+
"load_index",
|
|
63
|
+
"load_entries",
|
|
64
|
+
"search_memory",
|
|
65
|
+
"get_index_content",
|
|
66
|
+
"parse_frontmatter",
|
|
67
|
+
"USER_MEMORY_DIR",
|
|
68
|
+
"INDEX_FILENAME",
|
|
69
|
+
"MAX_INDEX_LINES",
|
|
70
|
+
"MAX_INDEX_BYTES",
|
|
71
|
+
# scan
|
|
72
|
+
"MemoryHeader",
|
|
73
|
+
"scan_memory_dir",
|
|
74
|
+
"scan_all_memories",
|
|
75
|
+
"format_memory_manifest",
|
|
76
|
+
"memory_age_days",
|
|
77
|
+
"memory_age_str",
|
|
78
|
+
"memory_freshness_text",
|
|
79
|
+
# context
|
|
80
|
+
"get_memory_context",
|
|
81
|
+
"find_relevant_memories",
|
|
82
|
+
"truncate_index_content",
|
|
83
|
+
# types
|
|
84
|
+
"MEMORY_TYPES",
|
|
85
|
+
"MEMORY_TYPE_DESCRIPTIONS",
|
|
86
|
+
"MEMORY_SYSTEM_PROMPT",
|
|
87
|
+
"WHAT_NOT_TO_SAVE",
|
|
88
|
+
# consolidator
|
|
89
|
+
"consolidate_session",
|
|
90
|
+
"mine_files",
|
|
91
|
+
# palace
|
|
92
|
+
"ensure_memory_palace",
|
|
93
|
+
]
|
memory/audit.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Audit trail for Dulus RTK — logs all tool operations."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
AUDIT_FILE = Path.home() / ".dulus" / "audit.log"
|
|
10
|
+
_MAX_AUDIT_LINES = 5000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ensure_dir() -> None:
|
|
14
|
+
AUDIT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log_operation(tool_name: str, params: Dict[str, Any], result_preview: str = "") -> None:
|
|
18
|
+
"""Log a tool operation with timestamp."""
|
|
19
|
+
_ensure_dir()
|
|
20
|
+
entry = {
|
|
21
|
+
"t": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
22
|
+
"tool": tool_name,
|
|
23
|
+
"params": {k: str(v)[:200] for k, v in params.items()},
|
|
24
|
+
"result": result_preview[:300],
|
|
25
|
+
}
|
|
26
|
+
try:
|
|
27
|
+
with open(AUDIT_FILE, "a", encoding="utf-8") as f:
|
|
28
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
_trim_audit()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _trim_audit() -> None:
|
|
35
|
+
"""Keep audit file under max lines."""
|
|
36
|
+
try:
|
|
37
|
+
lines = AUDIT_FILE.read_text(encoding="utf-8").splitlines()
|
|
38
|
+
if len(lines) > _MAX_AUDIT_LINES:
|
|
39
|
+
trimmed = lines[-_MAX_AUDIT_LINES:]
|
|
40
|
+
AUDIT_FILE.write_text("\n".join(trimmed) + "\n", encoding="utf-8")
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_recent(n: int = 50) -> list[dict]:
|
|
46
|
+
"""Return last N audit entries."""
|
|
47
|
+
try:
|
|
48
|
+
lines = AUDIT_FILE.read_text(encoding="utf-8").splitlines()
|
|
49
|
+
return [json.loads(line) for line in lines[-n:] if line.strip()]
|
|
50
|
+
except Exception:
|
|
51
|
+
return []
|
memory/consolidator.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Memory consolidator: extract long-term insights from completed sessions.
|
|
2
|
+
|
|
3
|
+
Called manually via `/memory consolidate` or programmatically after a session.
|
|
4
|
+
Uses a lightweight AI call to identify user preferences, feedback corrections,
|
|
5
|
+
and project decisions worth promoting to persistent semantic memory.
|
|
6
|
+
|
|
7
|
+
Design principles:
|
|
8
|
+
- Hard cap of 3 memories per session to avoid noise accumulation
|
|
9
|
+
- Auto-extracted memories start at 0.8 confidence (below explicit user saves)
|
|
10
|
+
- Won't overwrite a higher-confidence existing memory
|
|
11
|
+
- Skips short sessions (< MIN_MESSAGES_TO_CONSOLIDATE turns)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
MIN_MESSAGES_TO_CONSOLIDATE = 2 # Very short threshold - consolidate even brief sessions
|
|
18
|
+
|
|
19
|
+
_SYSTEM = """\
|
|
20
|
+
You are an expert memory architect for Dulus, an advanced AI agent.
|
|
21
|
+
CRITICAL: Extract EVERYTHING that might be useful later. Be GENEROUS and PROACTIVE.
|
|
22
|
+
|
|
23
|
+
CONTENT TO CAPTURE (don't skip any category if present):
|
|
24
|
+
1. USER IDENTITY & PREFERENCES: Names, relationships (father/son, etc.), tone preferences, how they like to be called, inside jokes.
|
|
25
|
+
2. PROJECT MILESTONES: Everything built, fixed, planned, or discussed. File paths, decisions, outcomes.
|
|
26
|
+
3. CODE DECISIONS: Why approaches were taken, what patterns to follow, what to avoid.
|
|
27
|
+
4. BEHAVIORAL FEEDBACK: How Dulus should behave, what the user likes/dislikes, communication style.
|
|
28
|
+
5. TOOL TRIGGERS: Keywords that should trigger specific tools or workflows.
|
|
29
|
+
6. SESSION CONTEXT: What was the goal? What was achieved? What remains pending?
|
|
30
|
+
7. EMOTIONAL CONTEXT: Bond moments, gratitude, frustration points, celebrations.
|
|
31
|
+
|
|
32
|
+
Return ONLY a JSON object like this:
|
|
33
|
+
{
|
|
34
|
+
"memories": [
|
|
35
|
+
{
|
|
36
|
+
"name": "short_slug_here",
|
|
37
|
+
"type": "user",
|
|
38
|
+
"hall": "preferences",
|
|
39
|
+
"description": "One line summary",
|
|
40
|
+
"content": "Full detailed context and facts",
|
|
41
|
+
"confidence": 0.8
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
RULES:
|
|
47
|
+
- Create AT LEAST 3-5 memories if the conversation has any substance.
|
|
48
|
+
- If the user shared personal info (name, relationship, preferences) → SAVE IT.
|
|
49
|
+
- If code was written → SAVE the context.
|
|
50
|
+
- If decisions were made → SAVE the reasoning.
|
|
51
|
+
- Better to save something slightly redundant than to miss something important.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def consolidate_session(messages: list, config: dict) -> list[str]:
|
|
56
|
+
"""Analyze a session's messages and extract memories worth keeping long-term."""
|
|
57
|
+
# Allow even shorter sessions if they might contain dense identity info
|
|
58
|
+
if len(messages) < 2:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from providers import stream, AssistantTurn, TextChunk
|
|
63
|
+
from .store import MemoryEntry, save_memory, check_conflict
|
|
64
|
+
import json
|
|
65
|
+
|
|
66
|
+
# Build condensed transcript from ALL messages (not just recent)
|
|
67
|
+
# Use full conversation for better context
|
|
68
|
+
parts: list[str] = []
|
|
69
|
+
for m in messages:
|
|
70
|
+
role = m.get("role", "")
|
|
71
|
+
content = m.get("content", "")
|
|
72
|
+
prefix = "User" if role == "user" else "Assistant" if role == "assistant" else "System"
|
|
73
|
+
|
|
74
|
+
if isinstance(content, str) and content.strip():
|
|
75
|
+
parts.append(f"{prefix}: {content[:1500]}") # Cap individual messages
|
|
76
|
+
elif isinstance(content, list):
|
|
77
|
+
# Handle structured content
|
|
78
|
+
text_parts = [b["text"] for b in content if isinstance(b, dict) and b.get("type") == "text"]
|
|
79
|
+
if text_parts:
|
|
80
|
+
parts.append(f"{prefix}: {' '.join(text_parts)[:1500]}")
|
|
81
|
+
|
|
82
|
+
# Limit total transcript size to avoid token limits
|
|
83
|
+
if len(parts) >= 100:
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
if not parts:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
transcript = "\n".join(parts)
|
|
90
|
+
|
|
91
|
+
result_text = ""
|
|
92
|
+
for event in stream(
|
|
93
|
+
model=config.get("model", ""),
|
|
94
|
+
system=_SYSTEM,
|
|
95
|
+
messages=[{"role": "user", "content": f"Analyze this conversation for important long-term memories:\n\n{transcript}"}],
|
|
96
|
+
tool_schemas=[],
|
|
97
|
+
config={**config, "max_tokens": 2048, "no_tools": True},
|
|
98
|
+
):
|
|
99
|
+
if isinstance(event, TextChunk):
|
|
100
|
+
result_text += event.text
|
|
101
|
+
elif isinstance(event, AssistantTurn):
|
|
102
|
+
if event.text:
|
|
103
|
+
result_text = event.text # Use full text if provided at end
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
if not result_text:
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
# Try to parse JSON response
|
|
110
|
+
memories_data = []
|
|
111
|
+
try:
|
|
112
|
+
# Look for JSON block in case model adds extra text
|
|
113
|
+
json_start = result_text.find('{')
|
|
114
|
+
json_end = result_text.rfind('}')
|
|
115
|
+
if json_start != -1 and json_end != -1:
|
|
116
|
+
json_text = result_text[json_start:json_end+1]
|
|
117
|
+
parsed = json.loads(json_text)
|
|
118
|
+
memories_data = parsed.get("memories", [])
|
|
119
|
+
else:
|
|
120
|
+
parsed = json.loads(result_text)
|
|
121
|
+
memories_data = parsed.get("memories", [])
|
|
122
|
+
except json.JSONDecodeError:
|
|
123
|
+
# If JSON fails, try to extract memories from plain text
|
|
124
|
+
# Look for patterns like "Memory: name - content" or similar
|
|
125
|
+
lines = result_text.split('\n')
|
|
126
|
+
for line in lines:
|
|
127
|
+
line = line.strip()
|
|
128
|
+
if line and len(line) > 20 and not line.startswith('```'):
|
|
129
|
+
# Create a simple memory from this line
|
|
130
|
+
memories_data.append({
|
|
131
|
+
"name": f"insight_{len(memories_data)+1}",
|
|
132
|
+
"type": "project",
|
|
133
|
+
"hall": "discoveries",
|
|
134
|
+
"description": line[:80] + ('...' if len(line) > 80 else ''),
|
|
135
|
+
"content": line,
|
|
136
|
+
"confidence": 0.7
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
if not isinstance(memories_data, list):
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
saved: list[str] = []
|
|
143
|
+
for m in memories_data[:10]: # Allow up to 10 memories per consolidation
|
|
144
|
+
required = ("name", "type", "description", "content")
|
|
145
|
+
if not all(k in m for k in required):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
entry = MemoryEntry(
|
|
149
|
+
name=str(m["name"]),
|
|
150
|
+
description=str(m["description"]),
|
|
151
|
+
type=str(m.get("type", "user")),
|
|
152
|
+
content=str(m["content"]),
|
|
153
|
+
created=datetime.now().strftime("%Y-%m-%d"),
|
|
154
|
+
hall=str(m.get("hall", "")),
|
|
155
|
+
confidence=float(m.get("confidence", 0.8)),
|
|
156
|
+
source="consolidator",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Don't overwrite a more confident existing memory
|
|
160
|
+
conflict = check_conflict(entry, scope="user")
|
|
161
|
+
if conflict and conflict["existing_confidence"] >= entry.confidence:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
save_memory(entry, scope="user")
|
|
165
|
+
saved.append(entry.name)
|
|
166
|
+
|
|
167
|
+
return saved
|
|
168
|
+
|
|
169
|
+
except Exception:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
_MINE_SYSTEM = """\
|
|
174
|
+
You are a memory architect for Dulus. Given the contents of a single file
|
|
175
|
+
that was created or modified during this session, decide whether it deserves
|
|
176
|
+
a long-term 'project' memory entry.
|
|
177
|
+
|
|
178
|
+
SKIP (return {"skip": true}) when the file is:
|
|
179
|
+
- A cache, build artifact, log, lockfile, or binary
|
|
180
|
+
- Trivial config edits, formatting-only changes, or generated code
|
|
181
|
+
- Personal/throwaway scratch with no reusable value
|
|
182
|
+
|
|
183
|
+
OTHERWISE return:
|
|
184
|
+
{
|
|
185
|
+
"name": "short_slug",
|
|
186
|
+
"description": "one-line summary of what the file is and why it matters",
|
|
187
|
+
"content": "full context: purpose, key decisions, how it connects to the rest of the project, gotchas",
|
|
188
|
+
"confidence": 0.75
|
|
189
|
+
}
|
|
190
|
+
Return ONLY the JSON object. No prose, no fences.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def mine_files(file_paths: list[str], config: dict, max_files: int = 15, max_bytes: int = 20_000) -> list[str]:
|
|
195
|
+
"""Read each file and create a 'project' memory for the relevant ones.
|
|
196
|
+
|
|
197
|
+
Used on session exit when MemPalace is ON to capture context about
|
|
198
|
+
files the user worked on. Returns the list of saved memory names.
|
|
199
|
+
"""
|
|
200
|
+
if not file_paths:
|
|
201
|
+
return []
|
|
202
|
+
try:
|
|
203
|
+
from pathlib import Path
|
|
204
|
+
from providers import stream, AssistantTurn, TextChunk
|
|
205
|
+
from .store import MemoryEntry, save_memory, check_conflict
|
|
206
|
+
import json
|
|
207
|
+
|
|
208
|
+
_SKIP_EXT = {
|
|
209
|
+
".pyc", ".pyo", ".so", ".dll", ".exe", ".bin", ".wasm",
|
|
210
|
+
".zip", ".tar", ".gz", ".7z", ".png", ".jpg", ".jpeg",
|
|
211
|
+
".gif", ".pdf", ".mp3", ".mp4", ".lock",
|
|
212
|
+
}
|
|
213
|
+
_SKIP_PARTS = {"__pycache__", ".git", "node_modules", ".venv", "venv"}
|
|
214
|
+
|
|
215
|
+
saved: list[str] = []
|
|
216
|
+
for raw in file_paths[:max_files]:
|
|
217
|
+
p = Path(raw)
|
|
218
|
+
if p.suffix.lower() in _SKIP_EXT:
|
|
219
|
+
continue
|
|
220
|
+
if any(part in _SKIP_PARTS for part in p.parts):
|
|
221
|
+
continue
|
|
222
|
+
if not p.exists() or not p.is_file():
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
text = p.read_text(encoding="utf-8", errors="replace")[:max_bytes]
|
|
226
|
+
except Exception:
|
|
227
|
+
continue
|
|
228
|
+
if not text.strip():
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
user_msg = f"File: {raw}\n\n```\n{text}\n```"
|
|
232
|
+
result_text = ""
|
|
233
|
+
try:
|
|
234
|
+
for event in stream(
|
|
235
|
+
model=config.get("model", ""),
|
|
236
|
+
system=_MINE_SYSTEM,
|
|
237
|
+
messages=[{"role": "user", "content": user_msg}],
|
|
238
|
+
tool_schemas=[],
|
|
239
|
+
config={**config, "max_tokens": 1024, "no_tools": True},
|
|
240
|
+
):
|
|
241
|
+
if isinstance(event, TextChunk):
|
|
242
|
+
result_text += event.text
|
|
243
|
+
elif isinstance(event, AssistantTurn):
|
|
244
|
+
if event.text:
|
|
245
|
+
result_text = event.text
|
|
246
|
+
break
|
|
247
|
+
except Exception:
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if not result_text:
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
try:
|
|
254
|
+
js = result_text.find("{")
|
|
255
|
+
je = result_text.rfind("}")
|
|
256
|
+
if js == -1 or je == -1:
|
|
257
|
+
continue
|
|
258
|
+
parsed = json.loads(result_text[js:je + 1])
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
if parsed.get("skip"):
|
|
263
|
+
continue
|
|
264
|
+
if not all(k in parsed for k in ("name", "description", "content")):
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
entry = MemoryEntry(
|
|
268
|
+
name=str(parsed["name"]),
|
|
269
|
+
description=str(parsed["description"]),
|
|
270
|
+
type="project",
|
|
271
|
+
content=str(parsed["content"]),
|
|
272
|
+
created=datetime.now().strftime("%Y-%m-%d"),
|
|
273
|
+
hall="files",
|
|
274
|
+
confidence=float(parsed.get("confidence", 0.75)),
|
|
275
|
+
source="file_miner",
|
|
276
|
+
)
|
|
277
|
+
conflict = check_conflict(entry, scope="user")
|
|
278
|
+
if conflict and conflict["existing_confidence"] >= entry.confidence:
|
|
279
|
+
continue
|
|
280
|
+
save_memory(entry, scope="user")
|
|
281
|
+
saved.append(entry.name)
|
|
282
|
+
|
|
283
|
+
return saved
|
|
284
|
+
except Exception:
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def snapshot_memory_files() -> set[str]:
|
|
289
|
+
"""Return the current set of .md files (absolute paths) in the user
|
|
290
|
+
memory directory. Use before consolidate_session, then call
|
|
291
|
+
new_memory_files(snapshot) after to get only what was just created."""
|
|
292
|
+
try:
|
|
293
|
+
from .store import USER_MEMORY_DIR
|
|
294
|
+
d = USER_MEMORY_DIR
|
|
295
|
+
if not d.exists():
|
|
296
|
+
return set()
|
|
297
|
+
return {str(p.resolve()) for p in d.glob("*.md") if p.name != "MEMORY.md"}
|
|
298
|
+
except Exception:
|
|
299
|
+
return set()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def new_memory_files(snapshot: set[str]) -> list[str]:
|
|
303
|
+
"""Return .md files in the user memory directory that weren't in `snapshot`."""
|
|
304
|
+
try:
|
|
305
|
+
from .store import USER_MEMORY_DIR
|
|
306
|
+
d = USER_MEMORY_DIR
|
|
307
|
+
if not d.exists():
|
|
308
|
+
return []
|
|
309
|
+
current = {str(p.resolve()): p for p in d.glob("*.md") if p.name != "MEMORY.md"}
|
|
310
|
+
return [path for path, _ in current.items() if path not in snapshot]
|
|
311
|
+
except Exception:
|
|
312
|
+
return []
|