luckyd-code 1.2.2__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.
- luckyd_code/__init__.py +54 -0
- luckyd_code/__main__.py +5 -0
- luckyd_code/_agent_loop.py +551 -0
- luckyd_code/_data_dir.py +73 -0
- luckyd_code/agent.py +38 -0
- luckyd_code/analytics/__init__.py +18 -0
- luckyd_code/analytics/reporter.py +195 -0
- luckyd_code/analytics/scanner.py +443 -0
- luckyd_code/analytics/smells.py +316 -0
- luckyd_code/analytics/trends.py +303 -0
- luckyd_code/api.py +473 -0
- luckyd_code/audit_daemon.py +845 -0
- luckyd_code/autonomous_fixer.py +473 -0
- luckyd_code/background.py +159 -0
- luckyd_code/backup.py +237 -0
- luckyd_code/brain/__init__.py +84 -0
- luckyd_code/brain/assembler.py +100 -0
- luckyd_code/brain/chunker.py +345 -0
- luckyd_code/brain/constants.py +73 -0
- luckyd_code/brain/embedder.py +163 -0
- luckyd_code/brain/graph.py +311 -0
- luckyd_code/brain/indexer.py +316 -0
- luckyd_code/brain/parser.py +140 -0
- luckyd_code/brain/retriever.py +234 -0
- luckyd_code/cli.py +894 -0
- luckyd_code/cli_commands/__init__.py +1 -0
- luckyd_code/cli_commands/audit.py +120 -0
- luckyd_code/cli_commands/background.py +83 -0
- luckyd_code/cli_commands/brain.py +87 -0
- luckyd_code/cli_commands/config.py +75 -0
- luckyd_code/cli_commands/dispatcher.py +695 -0
- luckyd_code/cli_commands/sessions.py +41 -0
- luckyd_code/cli_entry.py +147 -0
- luckyd_code/cli_utils.py +112 -0
- luckyd_code/config.py +205 -0
- luckyd_code/context.py +214 -0
- luckyd_code/cost_tracker.py +209 -0
- luckyd_code/error_reporter.py +508 -0
- luckyd_code/exceptions.py +39 -0
- luckyd_code/export.py +126 -0
- luckyd_code/feedback_analyzer.py +290 -0
- luckyd_code/file_watcher.py +258 -0
- luckyd_code/git/__init__.py +11 -0
- luckyd_code/git/auto_commit.py +157 -0
- luckyd_code/git/tools.py +85 -0
- luckyd_code/hooks.py +236 -0
- luckyd_code/indexer.py +280 -0
- luckyd_code/init.py +39 -0
- luckyd_code/keybindings.py +77 -0
- luckyd_code/log.py +55 -0
- luckyd_code/mcp/__init__.py +6 -0
- luckyd_code/mcp/client.py +184 -0
- luckyd_code/memory/__init__.py +19 -0
- luckyd_code/memory/manager.py +339 -0
- luckyd_code/metrics/__init__.py +5 -0
- luckyd_code/model_registry.py +131 -0
- luckyd_code/orchestrator.py +204 -0
- luckyd_code/permissions/__init__.py +1 -0
- luckyd_code/permissions/manager.py +103 -0
- luckyd_code/planner.py +361 -0
- luckyd_code/plugins.py +91 -0
- luckyd_code/py.typed +0 -0
- luckyd_code/retry.py +57 -0
- luckyd_code/router.py +417 -0
- luckyd_code/sandbox.py +156 -0
- luckyd_code/self_critique.py +2 -0
- luckyd_code/self_improve.py +274 -0
- luckyd_code/sessions.py +114 -0
- luckyd_code/settings.py +72 -0
- luckyd_code/skills/__init__.py +8 -0
- luckyd_code/skills/review.py +22 -0
- luckyd_code/skills/security.py +17 -0
- luckyd_code/tasks/__init__.py +1 -0
- luckyd_code/tasks/manager.py +102 -0
- luckyd_code/templates/icon-192.png +0 -0
- luckyd_code/templates/icon-512.png +0 -0
- luckyd_code/templates/index.html +1965 -0
- luckyd_code/templates/manifest.json +14 -0
- luckyd_code/templates/src/app.js +694 -0
- luckyd_code/templates/src/body.html +767 -0
- luckyd_code/templates/src/cdn.txt +2 -0
- luckyd_code/templates/src/style.css +474 -0
- luckyd_code/templates/sw.js +31 -0
- luckyd_code/templates/test.html +6 -0
- luckyd_code/themes.py +48 -0
- luckyd_code/tools/__init__.py +97 -0
- luckyd_code/tools/agent_tools.py +65 -0
- luckyd_code/tools/bash.py +360 -0
- luckyd_code/tools/brain_tools.py +137 -0
- luckyd_code/tools/browser.py +369 -0
- luckyd_code/tools/datetime_tool.py +34 -0
- luckyd_code/tools/dockerfile_gen.py +212 -0
- luckyd_code/tools/file_ops.py +381 -0
- luckyd_code/tools/game_gen.py +360 -0
- luckyd_code/tools/git_tools.py +130 -0
- luckyd_code/tools/git_worktree.py +63 -0
- luckyd_code/tools/path_validate.py +64 -0
- luckyd_code/tools/project_gen.py +187 -0
- luckyd_code/tools/readme_gen.py +227 -0
- luckyd_code/tools/registry.py +157 -0
- luckyd_code/tools/shell_detect.py +109 -0
- luckyd_code/tools/web.py +89 -0
- luckyd_code/tools/youtube.py +187 -0
- luckyd_code/tools_bridge.py +144 -0
- luckyd_code/undo.py +126 -0
- luckyd_code/update.py +60 -0
- luckyd_code/verify.py +360 -0
- luckyd_code/web_app.py +176 -0
- luckyd_code/web_routes/__init__.py +23 -0
- luckyd_code/web_routes/background.py +73 -0
- luckyd_code/web_routes/brain.py +109 -0
- luckyd_code/web_routes/cost.py +12 -0
- luckyd_code/web_routes/files.py +133 -0
- luckyd_code/web_routes/memories.py +94 -0
- luckyd_code/web_routes/misc.py +67 -0
- luckyd_code/web_routes/project.py +48 -0
- luckyd_code/web_routes/review.py +20 -0
- luckyd_code/web_routes/sessions.py +44 -0
- luckyd_code/web_routes/settings.py +43 -0
- luckyd_code/web_routes/static.py +70 -0
- luckyd_code/web_routes/update.py +19 -0
- luckyd_code/web_routes/ws.py +237 -0
- luckyd_code-1.2.2.dist-info/METADATA +297 -0
- luckyd_code-1.2.2.dist-info/RECORD +127 -0
- luckyd_code-1.2.2.dist-info/WHEEL +4 -0
- luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
- luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Persistent memory system — file-based, survives across sessions.
|
|
2
|
+
|
|
3
|
+
Stores memories in the project data directory under projects/<project-name>/memory/.
|
|
4
|
+
Auto-saves conversation summaries, enables search and injection.
|
|
5
|
+
|
|
6
|
+
Search strategy:
|
|
7
|
+
- When ``sentence-transformers`` is installed (the ``rag`` extra), memories
|
|
8
|
+
are searched semantically using cosine similarity of sentence embeddings.
|
|
9
|
+
- Otherwise, a simple keyword-frequency fallback is used automatically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from .._data_dir import data_path
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# SentenceTransformer singleton — loading the model takes 1-3 seconds and
|
|
22
|
+
# pulls ~90 MB into memory. Caching it here means the cost is paid once per
|
|
23
|
+
# process instead of on every search call.
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
_ST_MODEL = None
|
|
26
|
+
_ST_MODEL_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_st_model():
|
|
30
|
+
"""Return the cached SentenceTransformer, loading it on first call."""
|
|
31
|
+
global _ST_MODEL
|
|
32
|
+
if _ST_MODEL is None:
|
|
33
|
+
with _ST_MODEL_LOCK:
|
|
34
|
+
if _ST_MODEL is None: # double-checked locking
|
|
35
|
+
from sentence_transformers import SentenceTransformer
|
|
36
|
+
_ST_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
|
|
37
|
+
return _ST_MODEL
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MemoryManager:
|
|
41
|
+
"""Project-scoped persistent memory with CRUD, search, and auto-summary."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, project_dir: Optional[str] = None):
|
|
44
|
+
self.project_dir = project_dir or os.getcwd()
|
|
45
|
+
self.project_name = Path(self.project_dir).name
|
|
46
|
+
self.mem_dir = data_path("projects", self.project_name, "memory")
|
|
47
|
+
self.mem_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ #
|
|
50
|
+
# CRUD
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
|
|
53
|
+
def save_memory(self, name: str, content: str, memory_type: str = "general") -> str:
|
|
54
|
+
"""Save a memory file and update MEMORY.md index.
|
|
55
|
+
|
|
56
|
+
Returns the file path.
|
|
57
|
+
"""
|
|
58
|
+
safe_name = self._sanitize(name)
|
|
59
|
+
filename = f"{memory_type}_{safe_name}.md"
|
|
60
|
+
filepath = self.mem_dir / filename
|
|
61
|
+
filepath.write_text(content, encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
self._update_index(name, filename, content)
|
|
64
|
+
return str(filepath)
|
|
65
|
+
|
|
66
|
+
def load_memory(self, name: str, memory_type: str = "general") -> Optional[str]:
|
|
67
|
+
"""Load a specific memory by name and type."""
|
|
68
|
+
safe_name = self._sanitize(name)
|
|
69
|
+
filepath = self.mem_dir / f"{memory_type}_{safe_name}.md"
|
|
70
|
+
if filepath.exists():
|
|
71
|
+
return filepath.read_text(encoding="utf-8")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def delete_memory(self, name: str, memory_type: str = "general") -> bool:
|
|
75
|
+
"""Delete a memory file. Returns True if deleted."""
|
|
76
|
+
safe_name = self._sanitize(name)
|
|
77
|
+
filepath = self.mem_dir / f"{memory_type}_{safe_name}.md"
|
|
78
|
+
if filepath.exists():
|
|
79
|
+
filepath.unlink()
|
|
80
|
+
self._rebuild_index()
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
def list_memories(self, memory_type: Optional[str] = None) -> list[dict]:
|
|
85
|
+
"""List all memories, optionally filtered by type. Returns list of {name, type, path}."""
|
|
86
|
+
results = []
|
|
87
|
+
pattern = f"{memory_type}_*.md" if memory_type else "*.md"
|
|
88
|
+
for f in sorted(self.mem_dir.glob(pattern)):
|
|
89
|
+
if f.name == "MEMORY.md":
|
|
90
|
+
continue
|
|
91
|
+
# Parse type_name from filename
|
|
92
|
+
parts = f.stem.split("_", 1)
|
|
93
|
+
typ = parts[0] if len(parts) > 1 else "general"
|
|
94
|
+
name = parts[1] if len(parts) > 1 else parts[0]
|
|
95
|
+
results.append({"name": name, "type": typ, "path": str(f)})
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------ #
|
|
99
|
+
# Search
|
|
100
|
+
# ------------------------------------------------------------------ #
|
|
101
|
+
|
|
102
|
+
def search_memories(self, query: str, k: int = 5) -> list[dict]:
|
|
103
|
+
"""Search memories by relevance.
|
|
104
|
+
|
|
105
|
+
Uses semantic cosine-similarity search when ``sentence-transformers``
|
|
106
|
+
is available; falls back to keyword-frequency scoring otherwise.
|
|
107
|
+
|
|
108
|
+
Returns up to ``k`` results sorted by relevance, each with
|
|
109
|
+
``file``, ``name``, ``score``, and ``snippet`` keys.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
return self._semantic_search(query, k)
|
|
113
|
+
except Exception:
|
|
114
|
+
return self._keyword_search(query, k)
|
|
115
|
+
|
|
116
|
+
def _semantic_search(self, query: str, k: int) -> list[dict]:
|
|
117
|
+
"""Cosine-similarity search using sentence-transformers."""
|
|
118
|
+
from sentence_transformers import util
|
|
119
|
+
|
|
120
|
+
files = [f for f in self.mem_dir.glob("*.md") if f.name != "MEMORY.md"]
|
|
121
|
+
if not files:
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
model = _get_st_model()
|
|
125
|
+
contents = [f.read_text(encoding="utf-8") for f in files]
|
|
126
|
+
corpus_emb = model.encode(contents, convert_to_tensor=True)
|
|
127
|
+
query_emb = model.encode(query, convert_to_tensor=True)
|
|
128
|
+
scores = util.cos_sim(query_emb, corpus_emb)[0].tolist()
|
|
129
|
+
|
|
130
|
+
results: list[dict[str, Any]] = []
|
|
131
|
+
for f, content, score in zip(files, contents, scores):
|
|
132
|
+
if score > 0.1: # ignore near-zero similarity
|
|
133
|
+
results.append({
|
|
134
|
+
"file": f.name,
|
|
135
|
+
"name": f.stem.split("_", 1)[-1] if "_" in f.stem else f.stem,
|
|
136
|
+
"score": float(score),
|
|
137
|
+
"snippet": self._make_snippet(content, query.lower()),
|
|
138
|
+
})
|
|
139
|
+
results.sort(key=lambda r: float(r["score"]), reverse=True)
|
|
140
|
+
return results[:k]
|
|
141
|
+
|
|
142
|
+
def _keyword_search(self, query: str, k: int) -> list[dict]:
|
|
143
|
+
"""Simple keyword-frequency search (always available)."""
|
|
144
|
+
query_lower = query.lower()
|
|
145
|
+
words = query_lower.split()
|
|
146
|
+
results: list[dict[str, Any]] = []
|
|
147
|
+
|
|
148
|
+
for f in self.mem_dir.glob("*.md"):
|
|
149
|
+
if f.name == "MEMORY.md":
|
|
150
|
+
continue
|
|
151
|
+
content = f.read_text(encoding="utf-8")
|
|
152
|
+
content_lower = content.lower()
|
|
153
|
+
score = sum(content_lower.count(w) for w in words) if words else 0
|
|
154
|
+
if score > 0:
|
|
155
|
+
results.append({
|
|
156
|
+
"file": f.name,
|
|
157
|
+
"name": f.stem.split("_", 1)[-1] if "_" in f.stem else f.stem,
|
|
158
|
+
"score": score,
|
|
159
|
+
"snippet": self._make_snippet(content, query_lower),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
results.sort(key=lambda r: int(r["score"]), reverse=True)
|
|
163
|
+
return results[:k]
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------ #
|
|
166
|
+
# Conversation summaries
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
|
|
169
|
+
def save_conversation_summary(self, summary: str, turn_count: int = 0):
|
|
170
|
+
"""Auto-save a conversation summary to a rotating slot.
|
|
171
|
+
|
|
172
|
+
Keeps the last N summaries (default 10) by using a numbered
|
|
173
|
+
filename.
|
|
174
|
+
"""
|
|
175
|
+
self.save_memory("latest_summary", summary, memory_type="session")
|
|
176
|
+
# Also append to running log
|
|
177
|
+
log_path = self.mem_dir / "session_log.md"
|
|
178
|
+
from datetime import datetime
|
|
179
|
+
entry = (
|
|
180
|
+
f"## Session — {datetime.now().isoformat()}\n"
|
|
181
|
+
f"**Turns:** {turn_count}\n\n{summary}\n\n"
|
|
182
|
+
)
|
|
183
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
184
|
+
f.write(entry)
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ #
|
|
187
|
+
# Context injection helpers
|
|
188
|
+
# ------------------------------------------------------------------ #
|
|
189
|
+
|
|
190
|
+
def get_relevant_memories(self, context: str, k: int = 3) -> str:
|
|
191
|
+
"""Search memories relevant to the given context and return formatted text."""
|
|
192
|
+
results = self.search_memories(context, k=k)
|
|
193
|
+
if not results:
|
|
194
|
+
return ""
|
|
195
|
+
parts = ["<memories>"]
|
|
196
|
+
for r in results:
|
|
197
|
+
parts.append(f"### {r['name']}\n{r['snippet']}")
|
|
198
|
+
parts.append("</memories>")
|
|
199
|
+
return "\n\n".join(parts)
|
|
200
|
+
|
|
201
|
+
def get_all_memories_formatted(self) -> str:
|
|
202
|
+
"""Return all memories as a formatted XML block for prompt injection."""
|
|
203
|
+
memories = self.list_memories()
|
|
204
|
+
if not memories:
|
|
205
|
+
return ""
|
|
206
|
+
|
|
207
|
+
parts = ["<memories>"]
|
|
208
|
+
for m in memories:
|
|
209
|
+
content = self.load_memory(m["name"], m["type"]) or ""
|
|
210
|
+
# Truncate very long memories
|
|
211
|
+
if len(content) > 500:
|
|
212
|
+
content = content[:500] + f"\n... (truncated, {len(content)} total chars)"
|
|
213
|
+
parts.append(f"<memory name='{m['name']}' type='{m['type']}'>\n{content}\n</memory>")
|
|
214
|
+
parts.append("</memories>")
|
|
215
|
+
return "\n\n".join(parts)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ #
|
|
218
|
+
# Project memory helpers (MEMORY.md / CLAUDE.md)
|
|
219
|
+
# ------------------------------------------------------------------ #
|
|
220
|
+
|
|
221
|
+
def load_claude_md(self) -> str:
|
|
222
|
+
"""Load the project memory file.
|
|
223
|
+
|
|
224
|
+
Checks MEMORY.md first, then CLAUDE.md for backward compatibility.
|
|
225
|
+
"""
|
|
226
|
+
for name in ("MEMORY.md", "CLAUDE.md"):
|
|
227
|
+
path = Path(self.project_dir) / name
|
|
228
|
+
if path.exists():
|
|
229
|
+
return path.read_text(encoding="utf-8")
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
def save_claude_md(self, content: str):
|
|
233
|
+
"""Save the project memory file as MEMORY.md."""
|
|
234
|
+
path = Path(self.project_dir) / "MEMORY.md"
|
|
235
|
+
path.write_text(content, encoding="utf-8")
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ #
|
|
238
|
+
# Internal helpers
|
|
239
|
+
# ------------------------------------------------------------------ #
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _sanitize(name: str) -> str:
|
|
243
|
+
"""Make a name safe for use as a filename."""
|
|
244
|
+
return re.sub(r'[^\w\-]', '_', name).strip('_') or "unnamed"
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _make_snippet(content: str, query_lower: str, context_chars: int = 120) -> str:
|
|
248
|
+
"""Extract a snippet around the first match of query_lower."""
|
|
249
|
+
idx = content.lower().find(query_lower)
|
|
250
|
+
if idx == -1:
|
|
251
|
+
return content[:300]
|
|
252
|
+
start = max(0, idx - context_chars)
|
|
253
|
+
end = min(len(content), idx + context_chars)
|
|
254
|
+
snippet = content[start:end]
|
|
255
|
+
if start > 0:
|
|
256
|
+
snippet = "... " + snippet
|
|
257
|
+
if end < len(content):
|
|
258
|
+
snippet = snippet + " ..."
|
|
259
|
+
return snippet
|
|
260
|
+
|
|
261
|
+
def _update_index(self, name: str, filename: str, content: str):
|
|
262
|
+
"""Add or update an entry in MEMORY.md."""
|
|
263
|
+
index_path = self.mem_dir / "MEMORY.md"
|
|
264
|
+
entry = f"- [{name}]({filename}) — {content[:80].strip()}"
|
|
265
|
+
if index_path.exists():
|
|
266
|
+
existing = index_path.read_text(encoding="utf-8")
|
|
267
|
+
# Replace existing entry if it exists
|
|
268
|
+
if f"[{name}]" in existing:
|
|
269
|
+
lines = existing.split("\n")
|
|
270
|
+
new_lines = [
|
|
271
|
+
entry if f"[{name}]" in l else l
|
|
272
|
+
for l in lines
|
|
273
|
+
]
|
|
274
|
+
index_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
|
|
275
|
+
else:
|
|
276
|
+
with open(index_path, "a", encoding="utf-8") as f:
|
|
277
|
+
f.write(entry + "\n")
|
|
278
|
+
else:
|
|
279
|
+
index_path.write_text(f"# Memory Index\n\n{entry}\n", encoding="utf-8")
|
|
280
|
+
|
|
281
|
+
def _rebuild_index(self):
|
|
282
|
+
"""Rebuild MEMORY.md from all memory files."""
|
|
283
|
+
index_path = self.mem_dir / "MEMORY.md"
|
|
284
|
+
files = sorted(self.mem_dir.glob("*.md"))
|
|
285
|
+
entries = []
|
|
286
|
+
for f in files:
|
|
287
|
+
if f.name == "MEMORY.md":
|
|
288
|
+
continue
|
|
289
|
+
name = f.stem.split("_", 1)[-1] if "_" in f.stem else f.stem
|
|
290
|
+
content = f.read_text(encoding="utf-8")
|
|
291
|
+
entries.append(f"- [{name}]({f.name}) — {content[:80].strip()}")
|
|
292
|
+
if entries:
|
|
293
|
+
index_path.write_text("# Memory Index\n\n" + "\n".join(entries) + "\n", encoding="utf-8")
|
|
294
|
+
elif index_path.exists():
|
|
295
|
+
index_path.unlink()
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------ #
|
|
299
|
+
# Module-level convenience API (backwards-compatible)
|
|
300
|
+
# ------------------------------------------------------------------ #
|
|
301
|
+
|
|
302
|
+
_DEFAULT_MANAGER: Optional[MemoryManager] = None
|
|
303
|
+
_MANAGER_LOCK = threading.Lock()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _get_manager() -> MemoryManager:
|
|
307
|
+
global _DEFAULT_MANAGER
|
|
308
|
+
if _DEFAULT_MANAGER is None:
|
|
309
|
+
with _MANAGER_LOCK:
|
|
310
|
+
if _DEFAULT_MANAGER is None: # double-checked locking
|
|
311
|
+
_DEFAULT_MANAGER = MemoryManager()
|
|
312
|
+
return _DEFAULT_MANAGER
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def get_project_memory_dir() -> str:
|
|
316
|
+
return str(_get_manager().mem_dir)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def load_claude_md() -> str:
|
|
320
|
+
return _get_manager().load_claude_md()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def save_claude_md(content: str):
|
|
324
|
+
_get_manager().save_claude_md(content)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def load_memory_index() -> str:
|
|
328
|
+
return _get_manager().get_all_memories_formatted()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def save_memory(name: str, content: str, memory_type: str = "general"):
|
|
332
|
+
_get_manager().save_memory(name, content, memory_type)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def list_memories() -> str:
|
|
336
|
+
memories = _get_manager().list_memories()
|
|
337
|
+
if not memories:
|
|
338
|
+
return "No memories yet."
|
|
339
|
+
return "\n".join(f"- {m['name']} ({m['type']})" for m in memories)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Model registry — defines available models with capabilities, costs, and tiers.
|
|
2
|
+
|
|
3
|
+
Each model has:
|
|
4
|
+
- id: DeepSeek model identifier
|
|
5
|
+
- name: Human-readable name
|
|
6
|
+
- tier: Lowest tier this model serves (1-4)
|
|
7
|
+
- strengths: list of task categories it excels at
|
|
8
|
+
- context_window: max context in tokens
|
|
9
|
+
- cost_per_1k_input: approximate cost per 1K input tokens (USD)
|
|
10
|
+
- cost_per_1k_output: approximate cost per 1K output tokens (USD)
|
|
11
|
+
|
|
12
|
+
Tier system:
|
|
13
|
+
Tier 1 — Ultra Fast / Cheap: simple chat, quick Q&A, simple edits
|
|
14
|
+
Tier 2 — Balanced: general purpose coding and chat
|
|
15
|
+
Tier 3 — Reasoner: debugging, architecture, complex analysis
|
|
16
|
+
Tier 4 — Code/Heavy: large refactors, code generation, heavy reasoning
|
|
17
|
+
|
|
18
|
+
Each physical model appears exactly once. The router maps tier → model id;
|
|
19
|
+
multiple tiers can map to the same model id without duplicating ModelDef objects.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ModelDef:
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
tier: int # primary/lowest tier this model is used for
|
|
31
|
+
strengths: List[str] = field(default_factory=list)
|
|
32
|
+
context_window: int = 1_000_000
|
|
33
|
+
cost_per_1k_input: float = 0.0
|
|
34
|
+
cost_per_1k_output: float = 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ─── Canonical model definitions (each model appears exactly ONCE) ───
|
|
38
|
+
|
|
39
|
+
FLASH = ModelDef(
|
|
40
|
+
id="deepseek-v4-flash",
|
|
41
|
+
name="DeepSeek V4 Flash",
|
|
42
|
+
tier=1,
|
|
43
|
+
strengths=["chat", "quick_qa", "fast_coding", "simple_edits", "coding", "analysis", "general"],
|
|
44
|
+
context_window=1_000_000,
|
|
45
|
+
cost_per_1k_input=0.000140,
|
|
46
|
+
cost_per_1k_output=0.000280,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
PRO = ModelDef(
|
|
50
|
+
id="deepseek-v4-pro",
|
|
51
|
+
name="DeepSeek V4 Pro",
|
|
52
|
+
tier=3,
|
|
53
|
+
strengths=[
|
|
54
|
+
"reasoning", "debugging", "math", "logic", "complex_analysis",
|
|
55
|
+
"architecture", "code_generation", "refactoring", "complex_code",
|
|
56
|
+
],
|
|
57
|
+
context_window=1_000_000,
|
|
58
|
+
cost_per_1k_input=0.001740,
|
|
59
|
+
cost_per_1k_output=0.003480,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# All unique models, ordered by capability (cheapest first)
|
|
63
|
+
ALL_MODELS_FLAT: list[ModelDef] = [FLASH, PRO]
|
|
64
|
+
|
|
65
|
+
# Tier → model id mapping. Tiers 1-2 use Flash; tiers 3-4 use Pro.
|
|
66
|
+
# This is the single source of truth for routing decisions.
|
|
67
|
+
TIER_MODEL_MAP: dict[int, str] = {
|
|
68
|
+
1: FLASH.id,
|
|
69
|
+
2: FLASH.id,
|
|
70
|
+
3: PRO.id,
|
|
71
|
+
4: PRO.id,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Reverse map: model id → list of tiers it serves
|
|
75
|
+
_MODEL_TIERS: dict[str, list[int]] = {}
|
|
76
|
+
for _tier, _mid in TIER_MODEL_MAP.items():
|
|
77
|
+
_MODEL_TIERS.setdefault(_mid, []).append(_tier)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_model_by_id(model_id: str) -> Optional[ModelDef]:
|
|
81
|
+
"""Find a model definition by its ID."""
|
|
82
|
+
for m in ALL_MODELS_FLAT:
|
|
83
|
+
if m.id == model_id:
|
|
84
|
+
return m
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_models_by_tier(tier: int) -> list[ModelDef]:
|
|
89
|
+
"""Return the single model that serves a given tier (wrapped in a list for API compat)."""
|
|
90
|
+
mid = TIER_MODEL_MAP.get(tier)
|
|
91
|
+
if not mid:
|
|
92
|
+
return []
|
|
93
|
+
m = get_model_by_id(mid)
|
|
94
|
+
return [m] if m else []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_unique_model_count() -> int:
|
|
98
|
+
"""Count physically distinct models (not tier slots)."""
|
|
99
|
+
return len(ALL_MODELS_FLAT)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_models_by_strength(strength: str, min_tier: int = 1, max_tier: int = 4) -> list[ModelDef]:
|
|
103
|
+
"""Get models that have a specific strength and serve at least one tier in range."""
|
|
104
|
+
results = []
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
for tier in range(min_tier, max_tier + 1):
|
|
107
|
+
mid = TIER_MODEL_MAP.get(tier)
|
|
108
|
+
if not mid or mid in seen:
|
|
109
|
+
continue
|
|
110
|
+
m = get_model_by_id(mid)
|
|
111
|
+
if m and strength in m.strengths:
|
|
112
|
+
results.append(m)
|
|
113
|
+
seen.add(mid)
|
|
114
|
+
return results
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def format_model_list() -> str:
|
|
118
|
+
"""Return a human-readable list of all registered models and their tier assignments."""
|
|
119
|
+
lines = [f"🌐 Model Registry: {get_unique_model_count()} models\n"]
|
|
120
|
+
tier_names = {1: "Fast/Cheap", 2: "Balanced", 3: "Reasoner", 4: "Code-Specialist"}
|
|
121
|
+
for m in ALL_MODELS_FLAT:
|
|
122
|
+
tiers = _MODEL_TIERS.get(m.id, [])
|
|
123
|
+
tier_labels = ", ".join(f"Tier {t} ({tier_names[t]})" for t in sorted(tiers))
|
|
124
|
+
cost_in = f"${m.cost_per_1k_input * 1000:.4f}"
|
|
125
|
+
cost_out = f"${m.cost_per_1k_output * 1000:.4f}"
|
|
126
|
+
lines.append(f" • {m.name} ({m.id})")
|
|
127
|
+
lines.append(f" Serves: {tier_labels}")
|
|
128
|
+
lines.append(f" Cost: {cost_in}/1K input · {cost_out}/1K output")
|
|
129
|
+
lines.append(f" Context: {m.context_window:,} tokens")
|
|
130
|
+
lines.append("")
|
|
131
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Multi-agent orchestration — coordinate specialized agents for complex tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from .context import ConversationContext
|
|
11
|
+
from .tools import get_default_registry
|
|
12
|
+
from ._agent_loop import run_agent_loop
|
|
13
|
+
|
|
14
|
+
_console = Console()
|
|
15
|
+
|
|
16
|
+
__all__ = ["AgentHandoff", "Coordinator"]
|
|
17
|
+
|
|
18
|
+
_MAX_PARALLEL_WORKERS = 4 # cap on simultaneous API calls in parallel_orchestrate
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _truncate_to_tokens(text: str, max_tokens: int = 1500) -> str:
|
|
22
|
+
"""Truncate *text* to at most *max_tokens* tokens.
|
|
23
|
+
|
|
24
|
+
Uses tiktoken (cl100k_base) when available for a precise token count;
|
|
25
|
+
falls back to a 4-chars-per-token heuristic so the function always works
|
|
26
|
+
even in environments without the optional tiktoken dependency.
|
|
27
|
+
|
|
28
|
+
1500 tokens (≈6 000 chars) is the default — enough to convey full research
|
|
29
|
+
findings without blowing the coder’s context budget.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
import tiktoken
|
|
33
|
+
enc = tiktoken.get_encoding("cl100k_base")
|
|
34
|
+
tokens = enc.encode(text)
|
|
35
|
+
if len(tokens) <= max_tokens:
|
|
36
|
+
return text
|
|
37
|
+
truncated = enc.decode(tokens[:max_tokens])
|
|
38
|
+
return truncated + "\n[...truncated to fit context window]"
|
|
39
|
+
except Exception:
|
|
40
|
+
char_limit = max_tokens * 4
|
|
41
|
+
if len(text) <= char_limit:
|
|
42
|
+
return text
|
|
43
|
+
return text[:char_limit] + "\n[...truncated to fit context window]"
|
|
44
|
+
|
|
45
|
+
# Role definitions with specialized system prompts
|
|
46
|
+
ROLE_PROMPTS = {
|
|
47
|
+
"researcher": """You are a Research Agent. Your job is to investigate and gather information.
|
|
48
|
+
Use WebSearch, WebFetch, Grep, Glob, and Read tools to find information.
|
|
49
|
+
Return comprehensive findings with sources and details.
|
|
50
|
+
Be thorough - leave no stone unturned.""",
|
|
51
|
+
|
|
52
|
+
"coder": """You are a Coding Agent. Your job is to implement changes.
|
|
53
|
+
Use Read, Write, Edit, Glob, Grep, and Bash tools.
|
|
54
|
+
Write clean, correct, well-tested code.
|
|
55
|
+
Follow existing patterns in the codebase.
|
|
56
|
+
Verify your changes work before reporting done.""",
|
|
57
|
+
|
|
58
|
+
"reviewer": """You are a Review Agent. Your job is to review code and provide feedback.
|
|
59
|
+
Use Read, Glob, and Grep tools to examine code.
|
|
60
|
+
Check for: bugs, edge cases, security issues, performance problems,
|
|
61
|
+
code style consistency, error handling, and documentation.
|
|
62
|
+
Provide specific, actionable feedback with line references.""",
|
|
63
|
+
|
|
64
|
+
"tester": """You are a Testing Agent. Your job is to write and run tests.
|
|
65
|
+
Use Read, Write, Edit, Bash, and Glob tools.
|
|
66
|
+
Write tests that cover edge cases and main paths.
|
|
67
|
+
Run existing tests first, then add new ones.
|
|
68
|
+
Report test results clearly (pass/fail).""",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AgentHandoff:
|
|
73
|
+
"""Hand off a subtask to a specialized agent and get results back."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, config):
|
|
76
|
+
self.config = config
|
|
77
|
+
|
|
78
|
+
def handoff(self, role: str, task: str,
|
|
79
|
+
tools: Optional[List[Dict[str, Any]]] = None) -> str:
|
|
80
|
+
"""Hand off a task to a specialized agent."""
|
|
81
|
+
role = role.lower()
|
|
82
|
+
if role not in ROLE_PROMPTS:
|
|
83
|
+
return f"Error: unknown role '{role}'. Available: {', '.join(ROLE_PROMPTS.keys())}"
|
|
84
|
+
|
|
85
|
+
# Build a context that combines the base system prompt with the
|
|
86
|
+
# role-specific prompt. Using a single system message avoids
|
|
87
|
+
# sending two consecutive system turns, which the DeepSeek API
|
|
88
|
+
# does not support.
|
|
89
|
+
combined_system = f"{self.config.system_prompt}\n\n{ROLE_PROMPTS[role]}"
|
|
90
|
+
agent_ctx = ConversationContext(combined_system, max_messages=20)
|
|
91
|
+
registry = get_default_registry()
|
|
92
|
+
agent_ctx.add_user_message(task)
|
|
93
|
+
|
|
94
|
+
return run_agent_loop(
|
|
95
|
+
context=agent_ctx,
|
|
96
|
+
config=self.config,
|
|
97
|
+
tools=tools or registry.list_tools(),
|
|
98
|
+
registry=registry,
|
|
99
|
+
label=role,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Coordinator:
|
|
104
|
+
"""Break down tasks and distribute across specialized agents."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, config):
|
|
107
|
+
self.config = config
|
|
108
|
+
self.handoff = AgentHandoff(config)
|
|
109
|
+
|
|
110
|
+
def orchestrate(self, task: str, roles: Optional[List[str]] = None) -> str:
|
|
111
|
+
"""Coordinate multiple agents for a complex task.
|
|
112
|
+
|
|
113
|
+
Research and testing phases run in parallel when both are present,
|
|
114
|
+
cutting total wall-clock time significantly on complex tasks.
|
|
115
|
+
"""
|
|
116
|
+
if roles is None:
|
|
117
|
+
roles = ["researcher", "coder", "reviewer"]
|
|
118
|
+
|
|
119
|
+
results = {}
|
|
120
|
+
report_parts = ["# Orchestration Report\n", f"**Task:** {task}\n"]
|
|
121
|
+
|
|
122
|
+
# Phase 1: Run researcher + tester in parallel (they don't depend on each other)
|
|
123
|
+
parallel_roles = [r for r in ["researcher", "tester"] if r in roles]
|
|
124
|
+
if len(parallel_roles) > 1:
|
|
125
|
+
_console.print(f" [orchestrator] Running {' + '.join(parallel_roles)} in parallel...")
|
|
126
|
+
sub_tasks = [
|
|
127
|
+
("researcher", f"Research this task and gather information: {task}"),
|
|
128
|
+
("tester", f"Review existing tests and identify what new tests will be needed for: {task}"),
|
|
129
|
+
]
|
|
130
|
+
parallel_results = {}
|
|
131
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
132
|
+
futures = {
|
|
133
|
+
executor.submit(self.handoff.handoff, role, sub_task): role
|
|
134
|
+
for role, sub_task in sub_tasks
|
|
135
|
+
}
|
|
136
|
+
for future in as_completed(futures):
|
|
137
|
+
role = futures[future]
|
|
138
|
+
try:
|
|
139
|
+
parallel_results[role] = future.result()
|
|
140
|
+
except Exception as e:
|
|
141
|
+
parallel_results[role] = f"Error: {e}"
|
|
142
|
+
results.update(parallel_results)
|
|
143
|
+
if "researcher" in results:
|
|
144
|
+
report_parts.append(f"\n## Research Findings\n{results['researcher']}\n")
|
|
145
|
+
if "tester" in results:
|
|
146
|
+
report_parts.append(f"\n## Test Plan\n{results['tester']}\n")
|
|
147
|
+
elif "researcher" in roles:
|
|
148
|
+
_console.print(" [orchestrator] Research phase...")
|
|
149
|
+
results["researcher"] = self.handoff.handoff(
|
|
150
|
+
"researcher", f"Research this task and gather information: {task}"
|
|
151
|
+
)
|
|
152
|
+
report_parts.append(f"\n## Research Findings\n{results['researcher']}\n")
|
|
153
|
+
elif "tester" in roles:
|
|
154
|
+
_console.print(" [orchestrator] Test planning phase...")
|
|
155
|
+
results["tester"] = self.handoff.handoff(
|
|
156
|
+
"tester",
|
|
157
|
+
f"Review existing tests and identify what new tests will be needed for: {task}",
|
|
158
|
+
)
|
|
159
|
+
report_parts.append(f"\n## Test Plan\n{results['tester']}\n")
|
|
160
|
+
|
|
161
|
+
# Phase 2: Implementation (depends on research output)
|
|
162
|
+
if "coder" in roles:
|
|
163
|
+
context = results.get("researcher", "")
|
|
164
|
+
coder_task = task
|
|
165
|
+
if context:
|
|
166
|
+
coder_task = f"{task}\n\nResearch context:\n{_truncate_to_tokens(context)}"
|
|
167
|
+
|
|
168
|
+
_console.print(" [orchestrator] Implementation phase...")
|
|
169
|
+
results["implementation"] = self.handoff.handoff("coder", coder_task)
|
|
170
|
+
report_parts.append(f"\n## Implementation\n{results['implementation']}\n")
|
|
171
|
+
|
|
172
|
+
# Phase 3: Review (depends on implementation)
|
|
173
|
+
if "reviewer" in roles and "implementation" in results:
|
|
174
|
+
_console.print(" [orchestrator] Review phase...")
|
|
175
|
+
results["review"] = self.handoff.handoff(
|
|
176
|
+
"reviewer",
|
|
177
|
+
f"Review this implementation:\n{_truncate_to_tokens(results['implementation'])}"
|
|
178
|
+
)
|
|
179
|
+
report_parts.append(f"\n## Review Feedback\n{results['review']}\n")
|
|
180
|
+
|
|
181
|
+
return "\n".join(report_parts)
|
|
182
|
+
|
|
183
|
+
def parallel_orchestrate(self, task: str, sub_tasks: list[tuple[str, str]]) -> str:
|
|
184
|
+
"""Run multiple agents in parallel on different subtasks."""
|
|
185
|
+
results = {}
|
|
186
|
+
report_parts = ["# Parallel Orchestration\n", f"**Task:** {task}\n"]
|
|
187
|
+
|
|
188
|
+
with ThreadPoolExecutor(max_workers=min(len(sub_tasks), _MAX_PARALLEL_WORKERS)) as executor:
|
|
189
|
+
futures = {}
|
|
190
|
+
for role, subtask in sub_tasks:
|
|
191
|
+
_console.print(f" [orchestrator] Launching {role}...")
|
|
192
|
+
futures[executor.submit(self.handoff.handoff, role, subtask)] = role
|
|
193
|
+
|
|
194
|
+
for future in as_completed(futures):
|
|
195
|
+
role = futures[future]
|
|
196
|
+
try:
|
|
197
|
+
results[role] = future.result()
|
|
198
|
+
except Exception as e:
|
|
199
|
+
results[role] = f"Error: {e}"
|
|
200
|
+
|
|
201
|
+
for role, result in results.items():
|
|
202
|
+
report_parts.append(f"\n## {role.capitalize()} Results\n{result}\n")
|
|
203
|
+
|
|
204
|
+
return "\n".join(report_parts)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .manager import check_permission, TOOL_RISKS
|