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.
Files changed (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. 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,5 @@
1
+ """Metrics package for DeepSeek Code audit daemon.
2
+
3
+ Stores the time-series JSONL log produced by AuditDaemon._append_metrics()
4
+ at ``luckyd_code/metrics/time_series.jsonl``.
5
+ """
@@ -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