dulus 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
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 []