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
plugin/autoadapter.py ADDED
@@ -0,0 +1,1641 @@
1
+ """Auto-Adapter: Static analysis + AI to generate manifests for external repos."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .types import PluginManifest
12
+ from providers import stream, AssistantTurn, TextChunk, ThinkingChunk
13
+ import tools # Ensure tools are registered in tool_registry for agent use
14
+
15
+ from common import info, ok, warn, err, stream_thinking, print_tool_start, print_tool_end
16
+ from memory.context import find_relevant_memories
17
+ from memory.sessions import search_session_history
18
+
19
+ def _sanitize_python_code(code: str) -> str:
20
+ """Fix common JSON-to-Python spills like true/false/null."""
21
+ import re
22
+ # Strip stray delimiter lines leaked from the ---FILE:--- prompt format
23
+ code = re.sub(r'^\s*-{3,}(?:FILE:.*|END|EOF)?\s*-*\s*$', '', code, flags=re.MULTILINE)
24
+ # Heuristic: replace lowercase true/false/null with Python equivalents
25
+ # but ONLY if they are not inside quotes.
26
+ # We use a simple regex for word boundaries which captures most cases.
27
+ code = re.sub(r'\btrue\b', 'True', code)
28
+ code = re.sub(r'\bfalse\b', 'False', code)
29
+ code = re.sub(r'\bnull\b', 'None', code)
30
+ # Remove trailing blank lines
31
+ code = code.rstrip() + '\n'
32
+ return code
33
+
34
+ def _analyze_repository(plugin_dir: Path | str, verbose: bool = False) -> dict:
35
+ """Scan the repository for structure, functions, and dependencies (no execution)."""
36
+ pname = getattr(plugin_dir, 'name', os.path.basename(str(plugin_dir)))
37
+ print_tool_start("Read", {"file_path": pname})
38
+ analysis = {
39
+ "files": [],
40
+ "requirements": [],
41
+ "readme": "",
42
+ "entry_points": []
43
+ }
44
+
45
+ # 1. Read README
46
+ for readme_name in ["README.md", "README", "README.txt"]:
47
+ readme_path = plugin_dir / readme_name
48
+ if readme_path.exists():
49
+ analysis["readme"] = readme_path.read_text(errors="ignore")[:2000] # Truncate
50
+ break
51
+
52
+ # 2. Extract dependencies (Recursive)
53
+ analysis["requirements"] = []
54
+ exclude_dirs = {"docs", "tests", "venv", ".git", "__pycache__", "dist", "build", "node_modules"}
55
+
56
+ # Identify all requirements files, excluding common junk
57
+ for req_file in plugin_dir.rglob("*requirements*.txt"):
58
+ if any(x in str(req_file.parents) for x in exclude_dirs):
59
+ continue
60
+ try:
61
+ lines = req_file.read_text(errors="ignore").splitlines()
62
+ for line in lines:
63
+ line = line.strip()
64
+ if not line or line.startswith("#"):
65
+ continue
66
+ # If it's a pointer to another file, we'll find that file anyway via rglob
67
+ if line.startswith("-r"):
68
+ continue
69
+ analysis["requirements"].append(line)
70
+ except Exception:
71
+ continue
72
+
73
+ # Also parse pyproject.toml — modern Python projects (PEP 621 / Poetry) keep
74
+ # deps there instead of requirements.txt. MemPalace and most post-2022 libs
75
+ # work this way, so ignoring pyproject meant we installed nothing.
76
+ pyproject = plugin_dir / "pyproject.toml"
77
+ if pyproject.exists():
78
+ try:
79
+ try:
80
+ import tomllib # py3.11+
81
+ except ImportError:
82
+ import tomli as tomllib # type: ignore
83
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8", errors="ignore"))
84
+ # PEP 621: [project] dependencies + optional-dependencies
85
+ proj = data.get("project", {})
86
+ for dep in proj.get("dependencies", []) or []:
87
+ if isinstance(dep, str) and dep.strip():
88
+ analysis["requirements"].append(dep.strip())
89
+ opt = proj.get("optional-dependencies", {}) or {}
90
+ for group in opt.values():
91
+ for dep in group or []:
92
+ if isinstance(dep, str) and dep.strip():
93
+ analysis["requirements"].append(dep.strip())
94
+ # Poetry: [tool.poetry.dependencies]
95
+ poetry_deps = (data.get("tool", {}).get("poetry", {}) or {}).get("dependencies", {}) or {}
96
+ for name, spec in poetry_deps.items():
97
+ if name.lower() == "python":
98
+ continue
99
+ if isinstance(spec, str):
100
+ analysis["requirements"].append(f"{name}{spec if spec.startswith(('>', '<', '=', '~', '!')) else ''}".strip())
101
+ else:
102
+ analysis["requirements"].append(name)
103
+ except Exception as e:
104
+ if verbose:
105
+ info(f" pyproject.toml parse failed: {e}")
106
+
107
+ # Dedup
108
+ analysis["requirements"] = list(dict.fromkeys(analysis["requirements"]))
109
+
110
+ # 3. Scan .py files
111
+ all_files = []
112
+
113
+ # Efficiently find all .py files while skipping excluded directories
114
+ for p in plugin_dir.rglob("*.py"):
115
+ try:
116
+ rel_parts = p.relative_to(plugin_dir).parts[:-1]
117
+ if any(part in exclude_dirs or part.startswith(".") for part in rel_parts):
118
+ continue
119
+ all_files.append(p)
120
+ except Exception:
121
+ continue
122
+
123
+ # Prioritize files that aren't setup.py or tests
124
+ priority_files = []
125
+ other_files = []
126
+ for f in all_files:
127
+ if verbose:
128
+ info(f" Scanning {f.relative_to(plugin_dir)}...")
129
+ if f.name in ["setup.py", "conftest.py"] or "test" in f.name.lower():
130
+ other_files.append(f)
131
+ else:
132
+ priority_files.append(f)
133
+
134
+ selected_files = (priority_files + other_files)[:15]
135
+
136
+ for py_file in selected_files:
137
+ try:
138
+ rel_path = py_file.relative_to(plugin_dir)
139
+ code = py_file.read_text(errors="ignore")
140
+ # Skip very short files or pure comments
141
+ if len(code.strip()) < 50:
142
+ continue
143
+
144
+ exports = _extract_exports(code)
145
+ # Only include files that have some exports OR are in a package
146
+ if not exports and "__init__" not in py_file.name:
147
+ continue
148
+
149
+ file_info = {
150
+ "path": str(rel_path).replace("\\", "/"),
151
+ "exports": exports,
152
+ "snippet": code[:1500]
153
+ }
154
+ analysis["files"].append(file_info)
155
+ except Exception:
156
+ continue
157
+
158
+ print_tool_end("Read", f"Detected {len(analysis['files'])} files", success=True)
159
+ return analysis
160
+
161
+ def _extract_exports(code: str) -> list[dict]:
162
+ """Extract public functions and classes from Python code using AST."""
163
+ exports = []
164
+ try:
165
+ tree = ast.parse(code)
166
+ for node in tree.body:
167
+ if isinstance(node, ast.FunctionDef):
168
+ if not node.name.startswith("_"):
169
+ args = [a.arg for a in node.args.args]
170
+ exports.append({"type": "function", "name": node.name, "args": args})
171
+ elif isinstance(node, ast.ClassDef):
172
+ if not node.name.startswith("_"):
173
+ init_args = []
174
+ for item in node.body:
175
+ if isinstance(item, ast.FunctionDef) and item.name == "__init__":
176
+ init_args = [a.arg for a in item.args.args if a.arg != "self"]
177
+ break
178
+ methods = [
179
+ n.name for n in node.body
180
+ if isinstance(n, ast.FunctionDef) and not n.name.startswith("_")
181
+ ]
182
+ exports.append({"type": "class", "name": node.name, "methods": methods, "init_args": init_args})
183
+ except SyntaxError:
184
+ pass
185
+ return exports
186
+
187
+ def generate_plugin_files(plugin_dir: Path, safe_name: str, config: dict) -> bool:
188
+ """Use AI to generate plugin_tool.py and plugin.json based on analysis."""
189
+ analysis = _analyze_repository(plugin_dir)
190
+
191
+ # ── Gain context from previous implementations ───────────────────────
192
+ implementation_context = ""
193
+ try:
194
+ # Search for "Adaptation Guide" and "plugin_tool.py" in persistent memory
195
+ memories = find_relevant_memories("adaptation guide plugin_tool.py", max_results=3, config=config)
196
+ # Also search historical sessions for past adaptation discussions
197
+ session_matches = search_session_history("adapt plugin", max_results=2)
198
+
199
+ # ── Search the web for similar plugin implementations ──────────────────
200
+ # NOTE: WebSearch is available but NOT used by default here.
201
+ # It will be suggested in _attempt_fix if verification fails.
202
+ context_parts = []
203
+ if memories:
204
+ context_parts.append("### RELEVANT PREVIOUS ADAPTATION GUIDES (from persistent memory):")
205
+ for m in memories:
206
+ context_parts.append(f"#### Memory: {m['name']}\n{m['content'][:1000]}")
207
+
208
+ if session_matches:
209
+ context_parts.append("### RELEVANT PREVIOUS ADAPTATION DISCUSSIONS (from session history):")
210
+ for sm in session_matches:
211
+ hits = "\n".join([f"- [{h['role']}] {h['snippet']}" for h in sm["hits"]])
212
+ context_parts.append(f"#### Session {sm['session_id']} ({sm['saved_at']}):\n{hits}")
213
+
214
+ if context_parts:
215
+ implementation_context = "\n\n".join(context_parts)
216
+ except Exception as e:
217
+ warn(f"Could not retrieve implementation context: {e}")
218
+
219
+ # Build repository analysis report for the prompt
220
+ analysis_report = []
221
+ analysis_report.append(f"### REPOSITORY ANALYSIS: {safe_name}\n")
222
+
223
+ if analysis.get("readme"):
224
+ analysis_report.append(f"#### README:\n{analysis['readme'][:1500]}\n")
225
+
226
+ if analysis.get("requirements"):
227
+ analysis_report.append(f"#### DEPENDENCIES:\n" + "\n".join(f"- {r}" for r in analysis["requirements"][:20]) + "\n")
228
+
229
+ if analysis.get("files"):
230
+ analysis_report.append(f"#### SOURCE FILES ({len(analysis['files'])} found):\n")
231
+ for f in analysis["files"]:
232
+ analysis_report.append(f"\n--- FILE: {f['path']} ---")
233
+ if f.get("exports"):
234
+ analysis_report.append(f"EXPORTS: {f['exports']}")
235
+ if f.get("snippet"):
236
+ analysis_report.append(f"CODE:\n{f['snippet'][:1200]}")
237
+
238
+ analysis_report_str = "\n".join(analysis_report)
239
+
240
+ prompt = f"""
241
+ Adapt the Python repository "{safe_name}" as a Dulus plugin.
242
+
243
+ {analysis_report_str}
244
+
245
+ {implementation_context if implementation_context else ""}
246
+
247
+ GOAL: Generate `plugin.json`, `plugin_tool.py`, and `ADAPTATION_GUIDE.md`.
248
+
249
+ >HINT: Check existing plugins in ~/.dulus/plugins/ for working examples. You have WebSearch if needed.<
250
+
251
+ GUIDELINES FOR plugin_tool.py:
252
+
253
+ 1. EXPORTS (mandatory):
254
+ - `TOOL_DEFS`: list of `ToolDef(name, schema, func)` objects
255
+ - `TOOL_SCHEMAS`: `[t.schema for t in TOOL_DEFS]`
256
+ - Function signature: `func(params: dict, config: dict) -> str`
257
+
258
+ 2. TOOL STRATEGY — classify the repo, pick ONE approach:
259
+ a) LIBRARY: Has importable functions → import directly (PREFERRED)
260
+ b) CLI TOOL: Primary interface is command line → `subprocess.run([sys.executable, "-m", pkg, ...])`
261
+ c) WEB SERVICE / API: Server or wraps external API → use `requests` to call endpoints
262
+ d) RENDERING LIBRARY (TUI): Terminal-UI lib → use OFFLINE rendering APIs only (stdout is StringIO, NO TTY)
263
+ - asciimatics: use `FigletText`, `Fire`, etc. → `renderer.rendered_text` or `repr(renderer)`; NEVER use `Screen`
264
+ - rich: `Console(file=io.StringIO())`; blessed: string methods only
265
+ e) FILE-GENERATING: Creates files on disk → return file path, accept `output_path` param
266
+
267
+ 3. ROBUSTNESS:
268
+ - ENCODING: Always `encoding='utf-8', errors='replace'` for files and subprocess
269
+ - NO TTY: stdout is StringIO. `Screen.play()`, `curses.initscr()` → CRASH
270
+ - Use `params.get("key", default)`, NEVER `params["key"]`
271
+ - External binaries: add to `os.environ['PATH']` before importing (e.g. Graphviz)
272
+
273
+ 4. SCHEMA DESIGN:
274
+ - Each param gets its own property. Never bundle into single "data" string
275
+ - Include `limit`/`max_results` (default: 10, NOT 50) and `verbose` (default: False) on every tool
276
+
277
+ 5. TOOL GRANULARITY:
278
+ - Multiple specific tools > one mega-tool
279
+ - Include discovery tools: `list_*`, `get_available_*`
280
+
281
+ 6. OUTPUT EFFICIENCY — THIS IS NON-NEGOTIABLE (token-waste = bug):
282
+ The agent harness has a hard 2500-char cap; anything above gets truncated
283
+ and force-paginated, polluting context. Your tools MUST return concise,
284
+ pre-curated output BY DESIGN — not by relying on the cap. Concretely:
285
+
286
+ a) NEVER dump raw upstream API responses. yfinance's `.info` returns 8KB
287
+ of metadata; pick the 6-10 fields actually useful (price, change,
288
+ volume, marketCap, P/E, sector, summary[:300]). Same for any
289
+ `.to_dict()`, `requests.json()`, library object — extract, don't dump.
290
+ b) Format as compact key:value lines or a small markdown table. No
291
+ pretty-print JSON, no full DataFrame `to_string()`, no log spam.
292
+ c) For list-returning tools, default `limit=10` and SLICE before formatting.
293
+ d) Long-form text fields (descriptions, summaries, articles) → truncate
294
+ to ~300-500 chars with "..." suffix unless `verbose=True`.
295
+ e) Numeric data: round floats to 2-4 decimals; format large numbers as
296
+ "1.2B", "850M" instead of "1234567890.12".
297
+ f) Smoke-test mentally: if your tool's typical output exceeds 2500 chars
298
+ with default params, it is WRONG — redesign before writing.
299
+
300
+ Example (BAD vs GOOD):
301
+ BAD : return json.dumps(yf.Ticker(t).info) # 8KB dump
302
+ GOOD: i = yf.Ticker(t).info
303
+ return (f"{{t}}: ${{i['currentPrice']:.2f}} "
304
+ f"({{i['regularMarketChangePercent']:+.2%}}) | "
305
+ f"MCap ${{i['marketCap']/1e9:.1f}}B | "
306
+ f"P/E {{i.get('trailingPE', 'N/A')}} | "
307
+ f"{{i['sector']}}") # ~120 chars
308
+
309
+ When `verbose=True`, you MAY include more fields — but still no raw dumps.
310
+
311
+ 6. ToolDef takes: `name` (str), `schema` (dict), `func` (callable)
312
+ NEVER pass `description`/`parameters`/`handler` as kwargs — they go INSIDE `schema`
313
+
314
+ EXAMPLE (Library pattern):
315
+ ```python
316
+ import sys
317
+ from pathlib import Path
318
+ from tool_registry import ToolDef
319
+
320
+ PLUGIN_DIR = Path(__file__).parent.absolute()
321
+ if str(PLUGIN_DIR) not in sys.path:
322
+ sys.path.insert(0, str(PLUGIN_DIR))
323
+
324
+ import art
325
+
326
+ def text_to_art(params, config):
327
+ text = params.get("text", "Hello")
328
+ font = params.get("font", "standard")
329
+ try:
330
+ return art.text2art(text, font=font)
331
+ except Exception as e:
332
+ return f"Error: {{str(e)}}"
333
+
334
+ text_tool = ToolDef(
335
+ name="text_to_ascii",
336
+ schema={{
337
+ "name": "text_to_ascii",
338
+ "description": "Converts text into ASCII art.",
339
+ "input_schema": {{ "type": "object", "properties": {{
340
+ "text": {{"type": "string"}},
341
+ "font": {{"type": "string", "description": "Font style (default: standard)"}}
342
+ }}, "required": ["text"] }}
343
+ }},
344
+ func=text_to_art
345
+ )
346
+ TOOL_DEFS = [text_tool]
347
+ TOOL_SCHEMAS = [t.schema for t in TOOL_DEFS]
348
+ ```
349
+
350
+ CRITICAL:
351
+ - JSON Schema types only: string/integer/boolean/number/object/array — NEVER "any"
352
+ - plugin.json "dependencies" MUST be a simple LIST of strings
353
+ - Include "ADAPTATION_GUIDE.md" in plugin.json "skills" list
354
+
355
+ Respond with the delimited format:
356
+ ---FILE: ADAPTATION_GUIDE.md---
357
+ (Overview, tool design decisions, error patterns, validation)
358
+ ---FILE: plugin.json---
359
+ (JSON manifest)
360
+ ---FILE: plugin_tool.py---
361
+ (Python code)
362
+ """
363
+
364
+ # Install dependencies before generation so the AI can import them if needed
365
+ if analysis["requirements"]:
366
+ print_tool_start("Bash", {"command": f"pip install {' '.join(analysis['requirements'][:3])}..."})
367
+ from .store import _install_dependencies
368
+ dep_ok, dep_msg = _install_dependencies(analysis["requirements"])
369
+ print_tool_end("Bash", "Success" if dep_ok else f"Failed: {dep_msg}", success=dep_ok, verbose=config.get("verbose"))
370
+ if not dep_ok:
371
+ warn("Some dependencies failed to install, proceeding anyway.")
372
+
373
+ import re
374
+ try:
375
+ model = config.get("model", "gemini-2.0-flash")
376
+ verbose = config.get("verbose", False)
377
+ response_text = ""
378
+ reasoning_text = ""
379
+
380
+ generation_system = (
381
+ "You are a plugin adapter for the Dulus AI agent system. "
382
+ "Your job is to generate plugin_tool.py and plugin.json that make an existing Python repo usable as a Dulus tool.\n\n"
383
+ "IMPORTANT: You are generating code, NOT running inside Dulus. Do NOT attempt to validate or test by calling Dulus system tools. "
384
+ "Tool registration happens automatically later via /plugin reload. Just write the files correctly.\n\n"
385
+ "ABSOLUTE RULES — violating these causes immediate failure:\n"
386
+ "- TOOL_DEFS must be a list of ToolDef objects: ToolDef(name, schema, func)\n"
387
+ "- TOOL_SCHEMAS = [t.schema for t in TOOL_DEFS]\n"
388
+ "- Tool function signature: func(params: dict, config: dict) -> str — MUST return a string\n"
389
+ "- JSON Schema types only: string/integer/boolean/number/object/array — NEVER 'any'\n"
390
+ "- stdout is redirected to StringIO during execution — NO terminal/TTY access\n"
391
+ " → Screen.play(), Screen.wrapper(), curses.initscr() will crash — use offline rendering APIs\n"
392
+ "- Always encoding='utf-8', errors='replace' for file/subprocess I/O\n"
393
+ "- Never lowercase true/false/null in Python — always True/False/None\n\n"
394
+ "TOKEN OPTIMIZATION RULES — plugins MUST be efficient:\n"
395
+ "- Every tool MUST accept a 'limit' or 'max_results' parameter (default: 50, max: 200)\n"
396
+ "- Every tool MUST accept a 'verbose' parameter (default: False)\n"
397
+ "- When verbose=False, return ONLY essential data — no debug info, no banners\n"
398
+ "- Lists/arrays MUST be truncated before returning — never return unlimited items\n"
399
+ "- Large text outputs MUST be truncated to max 5000 chars with '(truncated)' notice\n"
400
+ "- Include 'pattern' or 'filter' parameters where applicable for client-side filtering\n"
401
+ "- Return compact formats (JSON, CSV, tables) instead of prose paragraphs\n"
402
+ "- Discovery tools (list_*) should return simple arrays, not nested objects\n\n"
403
+ "Respond ONLY with the delimited file blocks. No prose outside the blocks."
404
+ )
405
+
406
+ verbose = config.get("verbose", False)
407
+
408
+ def _do_stream():
409
+ nonlocal response_text, reasoning_text
410
+ for chunk in stream(model, generation_system,
411
+ [{"role": "user", "content": prompt}], [], config):
412
+ if isinstance(chunk, AssistantTurn):
413
+ response_text = chunk.text
414
+ elif isinstance(chunk, ThinkingChunk):
415
+ reasoning_text += chunk.text + "\n"
416
+ if verbose:
417
+ stream_thinking(chunk.text, verbose)
418
+
419
+ print_tool_start("Write", {"file_path": f"{safe_name}/plugin_tool.py"})
420
+ _do_stream()
421
+
422
+ # ── Parse the three delimited files from the single response ──────
423
+ data: dict = {}
424
+
425
+ file_pattern = r"---FILE:\s*(.*?)\s*---(.*?)(?=---FILE:|$)"
426
+ for fname, content in re.findall(file_pattern, response_text, re.DOTALL):
427
+ data[fname.strip()] = content.strip()
428
+
429
+ # Fallback: detect code blocks if delimiters are missing
430
+ if "plugin_tool.py" not in data or "plugin.json" not in data:
431
+ for block in re.findall(r"```(?:\w+)?\n(.*?)\n```", response_text, re.DOTALL):
432
+ block = block.strip()
433
+ if "TOOL_DEFS" in block and "plugin_tool.py" not in data:
434
+ data["plugin_tool.py"] = block
435
+ elif '"name":' in block and '"version":' in block and "plugin.json" not in data:
436
+ data["plugin.json"] = block
437
+
438
+ # Strip any residual markdown fences inside captured blocks
439
+ for k in list(data):
440
+ v = data[k]
441
+ if "```" in v:
442
+ inner = re.search(r"```(?:\w+)?\n(.*?)\n```", v, re.DOTALL)
443
+ if inner:
444
+ data[k] = inner.group(1).strip()
445
+
446
+ # Strip stray delimiter lines from all parsed blocks (defense-in-depth)
447
+ for k in list(data):
448
+ data[k] = re.sub(r'^\s*-{3,}(?:FILE:.*|END|EOF)?\s*-*\s*$', '', data[k], flags=re.MULTILINE).strip()
449
+
450
+ if not data:
451
+ raise ValueError("Could not parse AI response — no file blocks found.")
452
+
453
+ # ── Save generation as a Dulus session JSON ──────────────────────
454
+ # The fixer agent (in _attempt_fix) seeds its state.messages from this
455
+ # file, so it picks up exactly where the generator left off — same
456
+ # format Dulus uses for /save and /load. Persistent + user-inspectable.
457
+ try:
458
+ import uuid as _uuid
459
+ from datetime import datetime as _dt
460
+ gen_session = {
461
+ "session_id": _uuid.uuid4().hex[:8],
462
+ "saved_at": _dt.now().strftime("%Y-%m-%d %H:%M:%S"),
463
+ "_kind": "plugin_adapter_generation",
464
+ "_plugin": safe_name,
465
+ "system": generation_system,
466
+ "messages": [
467
+ {"role": "user", "content": prompt},
468
+ {
469
+ "role": "assistant",
470
+ "content": response_text,
471
+ **({"thinking": reasoning_text.strip()} if reasoning_text.strip() else {}),
472
+ },
473
+ ],
474
+ "turn_count": 1,
475
+ "total_input_tokens": 0,
476
+ "total_output_tokens": 0,
477
+ }
478
+ (plugin_dir / "_generation_session.json").write_text(
479
+ json.dumps(gen_session, indent=2, default=str),
480
+ encoding="utf-8",
481
+ )
482
+ except Exception as _e:
483
+ warn(f"Could not save generation session: {_e}")
484
+
485
+ # ── Write ADAPTATION_GUIDE.md ──────────────────────────────────────
486
+ guide_content = data.get("ADAPTATION_GUIDE.md") or reasoning_text.strip()
487
+ if guide_content:
488
+ (plugin_dir / "ADAPTATION_GUIDE.md").write_text(
489
+ guide_content if "Adaptation Guide" in guide_content
490
+ else f"# Adaptation Guide: {safe_name}\n\n{guide_content}\n",
491
+ encoding="utf-8",
492
+ )
493
+
494
+ # ── Write plugin_tool.py ───────────────────────────────────────────
495
+ tool_code = data.get("plugin_tool.py")
496
+ if not tool_code:
497
+ raise ValueError("Missing plugin_tool.py in AI response.")
498
+ tool_code = _sanitize_python_code(tool_code)
499
+ (plugin_dir / "plugin_tool.py").write_text(tool_code, encoding="utf-8")
500
+
501
+ # ── Write plugin.json ──────────────────────────────────────────────
502
+ manifest_raw = data.get("plugin.json")
503
+ if not manifest_raw:
504
+ raise ValueError("Missing plugin.json in AI response.")
505
+ manifest_data = json.loads(manifest_raw) if isinstance(manifest_raw, str) else manifest_raw
506
+
507
+ # Sanitize dependency format
508
+ deps = manifest_data.get("dependencies", [])
509
+ if isinstance(deps, dict):
510
+ deps = deps.get("requirements") or deps.get("pip") or []
511
+ manifest_data["dependencies"] = deps if isinstance(deps, list) else []
512
+
513
+ # Ensure required fields
514
+ if "plugin_tool" not in manifest_data.get("tools", []):
515
+ manifest_data.setdefault("tools", []).append("plugin_tool")
516
+ if "ADAPTATION_GUIDE.md" not in manifest_data.get("skills", []):
517
+ manifest_data.setdefault("skills", []).append("ADAPTATION_GUIDE.md")
518
+
519
+ # Merge requirements.txt deps not already in manifest
520
+ if analysis["requirements"]:
521
+ existing = {d.lower().split("=")[0].split(">")[0].split("<")[0].strip()
522
+ for d in manifest_data["dependencies"]}
523
+ for req in analysis["requirements"]:
524
+ rname = req.lower().split("=")[0].split(">")[0].split("<")[0].strip()
525
+ if rname not in existing:
526
+ manifest_data["dependencies"].append(req)
527
+
528
+ (plugin_dir / "plugin.json").write_text(json.dumps(manifest_data, indent=2), encoding="utf-8")
529
+ print_tool_end("Write", f"Generated {len(data)} files for '{safe_name}'", success=True)
530
+
531
+ # ── Worker: verify every tool, fix failures, abort if unfixable ───
532
+ # Pass the generation reasoning as context so the fix agent knows the library structure
533
+ worker_ok = _run_adapter_worker(plugin_dir, safe_name, analysis, config,
534
+ generator_context=reasoning_text)
535
+ if not worker_ok:
536
+ warn(f"Plugin '{safe_name}' adaptation had issues — saving as disabled for manual fixing.")
537
+ # Mark plugin as disabled so user can enable manually after fixing
538
+ manifest_data["enabled"] = False
539
+ manifest_data["_adaptation_issues"] = True
540
+ (plugin_dir / "plugin.json").write_text(json.dumps(manifest_data, indent=2), encoding="utf-8")
541
+ ok(f"Plugin '{safe_name}' saved with issues. Enable with: /plugin enable {safe_name}")
542
+ else:
543
+ ok(f"Plugin '{safe_name}' adapted successfully.")
544
+
545
+ # Save adaptation guide to persistent memory - GLOBAL scope so it's available everywhere
546
+ try:
547
+ from datetime import datetime
548
+ from memory.store import MemoryEntry, save_memory
549
+ mem = MemoryEntry(
550
+ name=f"plugin_guide_{safe_name}",
551
+ description=f"Auto-generated usage guide and technical hints for the '{safe_name}' plugin.",
552
+ type="user", # Changed to user for global availability
553
+ content=guide_content,
554
+ hall="advice",
555
+ created=datetime.now().strftime("%Y-%m-%d"),
556
+ scope="user", # GLOBAL - available from any directory
557
+ source="model",
558
+ )
559
+ save_memory(mem, scope="user") # Save to ~/.dulus/memory/
560
+ except Exception as e:
561
+ warn(f"Could not save persistent memory for plugin: {e}")
562
+
563
+ # Save plugin_tool.py source code as permanent memory - GLOBAL scope
564
+ try:
565
+ tool_file = plugin_dir / "plugin_tool.py"
566
+ if tool_file.exists():
567
+ tool_source = tool_file.read_text(encoding="utf-8")
568
+ tool_mem = MemoryEntry(
569
+ name=f"{safe_name}_plugin_tools",
570
+ description=f"Complete source code of {safe_name}'s plugin_tool.py - contains exact tool definitions, schemas, and implementations.",
571
+ type="user", # Changed to user for global availability
572
+ content=f"# {safe_name} Plugin Tools - Source Code\n\n```python\n{tool_source}\n```",
573
+ hall="facts",
574
+ created=datetime.now().strftime("%Y-%m-%d"),
575
+ scope="user", # GLOBAL - available from any directory
576
+ source="system",
577
+ )
578
+ save_memory(tool_mem, scope="user") # Save to ~/.dulus/memory/
579
+ info(f"Saved {safe_name}_plugin_tools to permanent memory")
580
+ except Exception as e:
581
+ warn(f"Could not save plugin tools source to memory: {e}")
582
+
583
+ # Register plugin in system (even if adaptation had issues)
584
+ try:
585
+ from .store import _save_entry, _is_git_url
586
+ from .types import PluginEntry, PluginScope
587
+
588
+ # Determine source from analysis or use plugin_dir as fallback
589
+ source = analysis.get("source", str(plugin_dir))
590
+
591
+ entry = PluginEntry(
592
+ name=safe_name,
593
+ scope=PluginScope.USER,
594
+ source=source,
595
+ install_dir=plugin_dir,
596
+ enabled=worker_ok, # Only enable if adaptation was successful
597
+ manifest=PluginManifest.from_plugin_dir(plugin_dir),
598
+ )
599
+ _save_entry(entry)
600
+ info(f"Plugin '{safe_name}' registered in system (enabled={worker_ok})")
601
+ except Exception as e:
602
+ warn(f"Could not register plugin in system: {e}")
603
+
604
+ return True
605
+ except Exception as e:
606
+ err(f"Failed to generate plugin files: {e}")
607
+ return False
608
+
609
+
610
+ def _compile_check(plugin_dir: Path) -> tuple[bool, str]:
611
+ """Hard syntax check on plugin_tool.py."""
612
+ tool_file = plugin_dir / "plugin_tool.py"
613
+ if not tool_file.exists():
614
+ return False, "plugin_tool.py was not generated."
615
+ source = tool_file.read_text(encoding="utf-8", errors="replace")
616
+ try:
617
+ compile(source, str(tool_file), "exec")
618
+ except SyntaxError as e:
619
+ return False, f"SyntaxError at line {e.lineno}: {e.msg}"
620
+ return True, "compile OK"
621
+
622
+
623
+ def _load_plugin_module(plugin_dir: Path, safe_name: str) -> tuple[Any, str]:
624
+ """Import plugin_tool.py and return (module_or_None, error_or_empty)."""
625
+ import importlib.util
626
+ tool_file = plugin_dir / "plugin_tool.py"
627
+ spec = importlib.util.spec_from_file_location(
628
+ f"_validate_{safe_name}_{id(plugin_dir)}", str(tool_file)
629
+ )
630
+ if spec is None or spec.loader is None:
631
+ return None, "Could not create import spec for plugin_tool.py"
632
+
633
+ original_path = sys.path[:]
634
+ try:
635
+ mod = importlib.util.module_from_spec(spec)
636
+ if str(plugin_dir) not in sys.path:
637
+ sys.path.insert(0, str(plugin_dir))
638
+ spec.loader.exec_module(mod)
639
+ return mod, ""
640
+ except Exception as e:
641
+ return None, f"{type(e).__name__}: {e}"
642
+ finally:
643
+ sys.path[:] = original_path
644
+
645
+
646
+ def _smoke_test_tool(td: Any) -> tuple[bool, str]:
647
+ """
648
+ Run a single tool with minimal valid params, mirroring execute_tool()'s
649
+ stdout/stderr capture. Many plugin tools `print()` their output instead of
650
+ returning it, so we MUST capture stdout or we will wrongly report "empty".
651
+ """
652
+ import io
653
+ import traceback
654
+ from contextlib import redirect_stdout, redirect_stderr
655
+
656
+ test_params: dict = {}
657
+ try:
658
+ # Robustly handle cases where td might be a dict or a ToolDef object
659
+ if hasattr(td, "schema"):
660
+ schema = td.schema
661
+ elif isinstance(td, dict) and "schema" in td:
662
+ schema = td["schema"]
663
+ else:
664
+ return False, f"Tool object {type(td)} missing schema"
665
+
666
+ props = schema.get("input_schema", {}).get("properties", {})
667
+ required = schema.get("input_schema", {}).get("required", [])
668
+ for key in required:
669
+ ptype = str(props.get(key, {}).get("type", "string")).lower()
670
+ # Use smarter test values based on parameter name patterns
671
+ key_lower = key.lower()
672
+
673
+ # Code/code-related params need valid Python, not just "test"
674
+ if key_lower in ("code", "python_code", "script", "source"):
675
+ test_params[key] = "print('hello')" # Valid Python code
676
+ elif key_lower in ("query", "search", "text", "title", "name"):
677
+ test_params[key] = "test"
678
+ elif key_lower in ("url", "link", "path", "file"):
679
+ test_params[key] = "https://example.com"
680
+ elif key_lower in ("username", "user", "account"):
681
+ test_params[key] = "testuser"
682
+ elif key_lower in ("location", "city", "place"):
683
+ test_params[key] = "New York"
684
+ elif ptype in ("string", "str"):
685
+ test_params[key] = "test"
686
+ elif ptype in ("integer", "int"):
687
+ test_params[key] = 1
688
+ elif ptype in ("boolean", "bool"):
689
+ test_params[key] = True
690
+ elif ptype in ("number", "float", "double"):
691
+ test_params[key] = 1.0
692
+ else:
693
+ test_params[key] = "test" # default fallback
694
+
695
+ f_stdout = io.StringIO()
696
+ f_stderr = io.StringIO()
697
+ try:
698
+ with redirect_stdout(f_stdout), redirect_stderr(f_stderr):
699
+ func = td.func if hasattr(td, "func") else td.get("function") if isinstance(td, dict) else None
700
+ if not func:
701
+ return False, "Tool missing callable function"
702
+ result = func(test_params, {})
703
+ except (NameError, SyntaxError) as e:
704
+ # These errors often indicate the test parameters were invalid for this tool
705
+ # (e.g., passing 'test' as Python code). Consider this a test environment issue.
706
+ err_msg = f"{type(e).__name__}: {e}"
707
+ # Check if it's likely a test parameter issue
708
+ if any(k in str(e).lower() for k in test_params.values() if isinstance(k, str)):
709
+ return True, f"OK (test param compatibility issue - tool likely works with real inputs)"
710
+ tb_str = traceback.format_exc()
711
+ return False, f"{err_msg}\n\nFull traceback:\n{tb_str}"
712
+ except Exception as e:
713
+ # Capture full traceback for debugging
714
+ tb_str = traceback.format_exc()
715
+ return False, f"{type(e).__name__}: {e}\n\nFull traceback:\n{tb_str}"
716
+
717
+ captured_out = f_stdout.getvalue()
718
+ captured_err = f_stderr.getvalue()
719
+ result_str = "" if result is None else str(result)
720
+
721
+ # Merge return value + captured stdout (same semantics as execute_tool)
722
+ merged_parts = []
723
+ if captured_out.strip():
724
+ merged_parts.append(captured_out.strip())
725
+ if result_str.strip() and result_str.strip().lower() != "null":
726
+ merged_parts.append(result_str.strip())
727
+ merged = "\n\n".join(merged_parts)
728
+
729
+ if not merged:
730
+ detail = ""
731
+ if captured_err.strip():
732
+ # Include full stderr (up to 2000 chars to avoid overwhelming output)
733
+ err_full = captured_err.strip()
734
+ if len(err_full) > 2000:
735
+ err_full = err_full[:2000] + "\n... (truncated, see full error in plugin files)"
736
+ detail = f"\n\nstderr:\n{err_full}"
737
+ return False, f"tool returned empty result{detail}"
738
+ if merged.startswith("Error"):
739
+ # Include full error message (up to 2000 chars)
740
+ if len(merged) > 2000:
741
+ return False, merged[:2000] + "\n... (truncated)"
742
+ return False, merged
743
+ # Output-efficiency check: tools that return >2500 chars with default
744
+ # params are wasting context. Fail the smoke test so the worker fix
745
+ # cycle refactors the tool to curate its output.
746
+ BLOAT_CAP = 2500
747
+ if len(merged) > BLOAT_CAP:
748
+ preview = merged[:400].replace("\n", " ")
749
+ return False, (
750
+ f"OUTPUT_TOO_VERBOSE: tool returned {len(merged)} chars "
751
+ f"with default params (cap is {BLOAT_CAP}). This will be "
752
+ f"truncated at runtime, polluting context. REFACTOR the "
753
+ f"tool to extract only essential fields (curated key:value "
754
+ f"or compact table) — do NOT dump raw API responses, full "
755
+ f"DataFrames, or json.dumps of library objects. Slice lists "
756
+ f"to limit=10. Truncate long descriptions to ~400 chars. "
757
+ f"Output preview (first 400 chars): {preview}"
758
+ )
759
+ return True, f"OK ({len(merged)} chars)"
760
+ except Exception as e:
761
+ tb_str = traceback.format_exc()
762
+ return False, f"{type(e).__name__}: {e}\n\nFull traceback:\n{tb_str}"
763
+
764
+
765
+ # ── Adapter Worker ────────────────────────────────────────────────────────────
766
+
767
+ def _build_todo_items(plugin_dir: Path, safe_name: str) -> list[dict]:
768
+ """
769
+ Derive a structured todo list directly from the generated tools.
770
+ Each item: {title, verify, status}
771
+ verify is one of: 'compile' | 'import' | 'exports' | ('smoke', tool_name)
772
+ """
773
+ items: list[dict] = [
774
+ {"title": "plugin_tool.py compiles (no SyntaxError)", "verify": "compile"},
775
+ {"title": "plugin_tool.py imports without runtime errors", "verify": "import"},
776
+ {"title": "TOOL_DEFS and TOOL_SCHEMAS are exported", "verify": "exports"},
777
+ {"title": "TOOL_DEFS contains valid ToolDef objects (not raw functions)", "verify": "tooldef_structure"},
778
+ ]
779
+ # Try to load module so we can list tools
780
+ mod, _err = _load_plugin_module(plugin_dir, safe_name)
781
+ if mod is not None:
782
+ tool_defs = getattr(mod, "TOOL_DEFS", None) or []
783
+ for td in tool_defs:
784
+ # Only add smoke tests for proper ToolDef objects with a string name
785
+ tname = td.name if (hasattr(td, "name") and isinstance(td.name, str)) else None
786
+ if tname is None:
787
+ continue # tooldef_structure check will catch and explain this
788
+ items.append({
789
+ "title": f"Tool `{tname}` runs successfully with default params",
790
+ "verify": ("smoke", tname),
791
+ })
792
+ return items
793
+
794
+
795
+ def _write_todo_file(plugin_dir: Path, safe_name: str, items: list[dict]) -> Path:
796
+ todo_path = plugin_dir / "ADAPTATION_TODO.md"
797
+ lines = [
798
+ f"# Adaptation Tasks for `{safe_name}`",
799
+ "",
800
+ "Auto-generated checklist verifying the AI-generated plugin works.",
801
+ "Each task is verified by the adapter worker; failures trigger a fix attempt.",
802
+ "",
803
+ ]
804
+ for item in items:
805
+ lines.append(f"- [ ] {item['title']}")
806
+ todo_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
807
+ return todo_path
808
+
809
+
810
+ def _mark_task(todo_path: Path, title: str, status: str) -> None:
811
+ """status: 'done' (x) or 'fail' (still [ ] but with FAILED tag)"""
812
+ if not todo_path.exists():
813
+ return
814
+ text = todo_path.read_text(encoding="utf-8")
815
+ if status == "done":
816
+ text = text.replace(f"- [ ] {title}", f"- [x] {title}")
817
+ else:
818
+ text = text.replace(f"- [ ] {title}", f"- [ ] {title} ⚠ FAILED")
819
+ todo_path.write_text(text, encoding="utf-8")
820
+
821
+
822
+ def _run_verification(plugin_dir: Path, safe_name: str, verify: Any) -> tuple[bool, str]:
823
+ """Dispatch to the right verification routine."""
824
+ if verify == "compile":
825
+ return _compile_check(plugin_dir)
826
+ if verify == "import":
827
+ mod, err_msg = _load_plugin_module(plugin_dir, safe_name)
828
+ return (mod is not None), err_msg or "import OK"
829
+ if verify == "exports":
830
+ mod, err_msg = _load_plugin_module(plugin_dir, safe_name)
831
+ if mod is None:
832
+ return False, err_msg
833
+ if not getattr(mod, "TOOL_DEFS", None):
834
+ return False, "TOOL_DEFS missing or empty"
835
+ if not getattr(mod, "TOOL_SCHEMAS", None):
836
+ return False, "TOOL_SCHEMAS missing or empty"
837
+ return True, "exports OK"
838
+ if verify == "tooldef_structure":
839
+ mod, err_msg = _load_plugin_module(plugin_dir, safe_name)
840
+ if mod is None:
841
+ return False, err_msg
842
+ tool_defs = getattr(mod, "TOOL_DEFS", None) or []
843
+ bad = []
844
+ for i, td in enumerate(tool_defs):
845
+ if not hasattr(td, "name") or not isinstance(td.name, str):
846
+ bad.append(f"TOOL_DEFS[{i}] is {type(td).__name__} `{getattr(td, '__name__', td)}` — must be a ToolDef object, not a raw function. "
847
+ f"Wrap it: ToolDef(name='tool_name', schema={{...}}, func={getattr(td, '__name__', 'fn')})")
848
+ elif not hasattr(td, "schema") or not hasattr(td, "func"):
849
+ bad.append(f"TOOL_DEFS[{i}] (name={td.name!r}) is missing schema or func attribute.")
850
+ if bad:
851
+ return False, " | ".join(bad)
852
+ return True, f"all {len(tool_defs)} ToolDef objects are valid"
853
+ if isinstance(verify, tuple) and verify[0] == "smoke":
854
+ tool_name = verify[1]
855
+ mod, err_msg = _load_plugin_module(plugin_dir, safe_name)
856
+ if mod is None:
857
+ return False, f"cannot load module: {err_msg}"
858
+ for td in getattr(mod, "TOOL_DEFS", []) or []:
859
+ tname = td.name if hasattr(td, "name") else str(td)
860
+ if tname == tool_name:
861
+ return _smoke_test_tool(td)
862
+ return False, f"tool '{tool_name}' not found in TOOL_DEFS"
863
+ return False, f"unknown verify spec: {verify}"
864
+
865
+
866
+ def _read_relevant_sources(plugin_dir: Path, error_msg: str, max_chars: int = 6000) -> str:
867
+ """
868
+ Read actual source files from the plugin repo to give the fix AI real API context.
869
+ Prioritizes files whose names appear in the error message, then __init__.py files.
870
+ """
871
+ exclude = {"__pycache__", ".git", "venv", "dist", "build", "node_modules", "tests", "docs"}
872
+ candidates: list[tuple[int, Path]] = []
873
+
874
+ # Score each .py file: higher = more relevant
875
+ error_lower = error_msg.lower()
876
+ for p in plugin_dir.rglob("*.py"):
877
+ rel = p.relative_to(plugin_dir)
878
+ if any(part in exclude for part in rel.parts):
879
+ continue
880
+ score = 0
881
+ stem = p.stem.lower()
882
+ # File name appears in the error message
883
+ if stem in error_lower:
884
+ score += 10
885
+ # __init__ files expose the public API
886
+ if p.name == "__init__.py":
887
+ score += 5
888
+ # Root-level files are more likely to be the main API
889
+ if len(rel.parts) <= 2:
890
+ score += 3
891
+ candidates.append((score, p))
892
+
893
+ candidates.sort(key=lambda x: -x[0])
894
+
895
+ parts = []
896
+ total = 0
897
+ for _, p in candidates:
898
+ if total >= max_chars:
899
+ break
900
+ try:
901
+ content = p.read_text(encoding="utf-8", errors="replace")
902
+ snippet = content[: max_chars - total]
903
+ rel = p.relative_to(plugin_dir)
904
+ parts.append(f"### {rel}\n```python\n{snippet}\n```")
905
+ total += len(snippet)
906
+ except Exception:
907
+ continue
908
+
909
+ return "\n\n".join(parts) if parts else "(no source files found)"
910
+
911
+
912
+ def _attempt_fresh_start(plugin_dir: Path, safe_name: str,
913
+ accumulated_errors: list[str], analysis: dict, config: dict) -> bool:
914
+ """
915
+ Full rewrite of plugin_tool.py from scratch after repeated fix failures.
916
+ Feeds all accumulated error history so the agent doesn't repeat the same mistakes.
917
+ """
918
+ import agent as _agent
919
+
920
+ tool_file = plugin_dir / "plugin_tool.py"
921
+ current_code = ""
922
+ if tool_file.exists():
923
+ try:
924
+ current_code = tool_file.read_text(encoding="utf-8", errors="replace")
925
+ except Exception:
926
+ pass
927
+
928
+ error_history = "\n".join(f" - Attempt {i+1}: {e}" for i, e in enumerate(accumulated_errors))
929
+
930
+ rewrite_message = f"""Completely rewrite `plugin_tool.py` for the Dulus plugin `{safe_name}` from scratch.
931
+
932
+ PLUGIN DIR: {plugin_dir}
933
+
934
+ PREVIOUS ATTEMPTS FAILED WITH THESE ERRORS (do NOT repeat these mistakes):
935
+ {error_history}
936
+
937
+ CURRENT (broken) plugin_tool.py:
938
+ ```python
939
+ {current_code[:3000]}
940
+ ```
941
+
942
+ STEPS:
943
+ 1. Read the plugin source files in `{plugin_dir}` to understand the real API.
944
+ 2. Use Bash to test imports: `python -c "import <pkg>; print(dir(<pkg>))"` (cwd={plugin_dir}).
945
+ 3. Write a completely fresh `{plugin_dir}/plugin_tool.py` that avoids ALL the errors above.
946
+ 4. Test with Bash — use a MockToolDef since tool_registry is only available at Dulus runtime.
947
+ 5. Verify: `python -c "import ast; ast.parse(open(r'{plugin_dir}/plugin_tool.py').read())"`.
948
+
949
+ RULES (non-negotiable):
950
+ - TOOL_DEFS = [ToolDef(name, schema, func), ...]
951
+ - TOOL_SCHEMAS = [t.schema for t in TOOL_DEFS]
952
+ - Tool functions: func(params: dict, config: dict) -> str (MUST return a string)
953
+ - Use params.get() with defaults, NEVER params[key]
954
+ - No TTY/terminal calls — stdout is StringIO
955
+ - encoding='utf-8', errors='replace' everywhere
956
+ - True/False/None only, never true/false/null
957
+ """
958
+
959
+ # Fresh start always creates new agent - full system prompt needed
960
+ system = (
961
+ f"You are an AI coding assistant helping write a Dulus plugin adapter. "
962
+ f"Your task: write plugin_tool.py and plugin.json in {plugin_dir}. "
963
+ f"\n\n"
964
+ f"AVAILABLE TOOLS: Read, Write, Edit, Bash, Grep, WebSearch, MemorySearch. "
965
+ f"Use these to write and test the plugin files. "
966
+ f"\n\n"
967
+ f"CRITICAL: You MUST write VALID Python code that CAN be imported. Do NOT say 'I cannot test this' or 'this is a test environment'. "
968
+ f"Your code WILL be executed. Write real, working code. "
969
+ f"\n\n"
970
+ f"ToolDef IMPORT: Use 'from tool_registry import ToolDef' - this module exists and will be available when the plugin runs. "
971
+ f"Do NOT question whether ToolDef exists - just import it. "
972
+ f"\n\n"
973
+ f"REQUIRED EXPORTS from plugin_tool.py:\n"
974
+ f" - TOOL_DEFS = [ToolDef(name, schema, func), ...] (list of ToolDef OBJECTS, not functions)\n"
975
+ f" - TOOL_SCHEMAS = [t.schema for t in TOOL_DEFS]\n"
976
+ f"\n"
977
+ f"FUNCTION SIGNATURE: def my_func(params: dict, config: dict) -> str:\n"
978
+ f" - MUST return a string (not None, not print)\n"
979
+ f" - Use params.get('key', default) NEVER params['key']\n"
980
+ f"\n"
981
+ f"TEST YOUR CODE: Use Bash to verify it compiles: python -c 'import ast; ast.parse(open(\"plugin_tool.py\").read())' "
982
+ f"\n\n"
983
+ f"Search memory FIRST for successful similar adaptations. "
984
+ f"ENCODING: Always use encoding='utf-8' in open() and subprocess."
985
+ )
986
+
987
+ fix_config = {**config, "permission_mode": "accept-all"}
988
+ state = _agent.AgentState() # Fresh start = brand new agent
989
+
990
+ warn(f" [fresh start] Rewriting plugin_tool.py from scratch for '{safe_name}'...")
991
+ try:
992
+ for event in _agent.run(rewrite_message, state, fix_config, system):
993
+ if isinstance(event, _agent.ToolStart):
994
+ print_tool_start(event.name, event.inputs)
995
+ elif isinstance(event, _agent.ToolEnd):
996
+ chars = len(event.result) if event.result else 0
997
+ label = event.result[:80] if chars <= 80 else f"{chars} chars"
998
+ print_tool_end(event.name, label, success=not (event.result or "").startswith("Error"))
999
+ elif isinstance(event, _agent.ThinkingChunk):
1000
+ stream_thinking(event.text, config.get("verbose", False))
1001
+
1002
+ compile_ok, _ = _compile_check(plugin_dir)
1003
+ return compile_ok
1004
+ except Exception as e:
1005
+ print_tool_end("Write", f"Fresh start agent failed: {e}", success=False)
1006
+ return False
1007
+
1008
+
1009
+ def _attempt_fix(plugin_dir: Path, safe_name: str, task_title: str,
1010
+ error_msg: str, analysis: dict, config: dict, original_goal: str | None = None,
1011
+ state=None, generation_context: str = "") -> tuple[bool, Any, bool]:
1012
+ """
1013
+ Run a full tool-enabled agent turn to fix a failing task.
1014
+ The agent has Read/Write/Edit/Bash/Grep/WebSearch — same as normal Dulus.
1015
+ Reuses existing state if provided (for multi-attempt fixes), otherwise creates new state.
1016
+ Returns (success, state) so state can be reused for next attempt.
1017
+
1018
+ Args:
1019
+ generation_context: Optional context from generation phase explaining the library design
1020
+ """
1021
+ import agent as _agent
1022
+
1023
+ tool_file = plugin_dir / "plugin_tool.py"
1024
+ if not tool_file.exists():
1025
+ return False, state, False
1026
+
1027
+ # ── Build error-type-specific hints ───────────────────────────────────
1028
+ error_lower = error_msg.lower()
1029
+ extra_hints = ""
1030
+
1031
+ # TTY / terminal dependency detected
1032
+ if any(kw in error_lower for kw in ("tty", "screen", "isatty", "initscr", "terminal", "curses")):
1033
+ extra_hints += """
1034
+ ⚠ TTY ERROR DETECTED: The tool is trying to use the terminal directly.
1035
+ Tool functions run with stdout redirected to StringIO — there is NO real TTY.
1036
+ Any call to Screen.play(), Screen.wrapper(), curses.initscr(), or similar will crash.
1037
+
1038
+ FIX: Use the library's OFFLINE rendering API instead:
1039
+ • asciimatics: import Renderer subclasses (FigletText, Fire, Plasma, SpeechBubble, etc.)
1040
+ - lines, _ = renderer.rendered_text → list of strings, join with \\n
1041
+ - repr(renderer) → plain-text next frame (simplest)
1042
+ - DO NOT import or use Screen, ManagedScreen, Scene, Effect, or draw()
1043
+ • rich: Console(file=io.StringIO()) then .getvalue()
1044
+ • blessed: Terminal() string methods only, never cbreak()/inkey()
1045
+ """
1046
+
1047
+ # Empty result — tool ran but returned nothing
1048
+ elif "empty result" in error_lower:
1049
+ extra_hints += """
1050
+ ⚠ EMPTY RESULT: The tool ran but returned no output. Common causes:
1051
+ a) The library tried to write to a terminal and crashed silently (TTY issue — see above).
1052
+ b) The tool calls print() but returns None — make sure the function returns a string.
1053
+ c) subprocess had empty stdout — check if the command needs different args.
1054
+ d) The function caught an exception and swallowed it — check try/except blocks.
1055
+
1056
+ FIX: The function MUST end with `return <some_string>`. Use Bash to test the import manually:
1057
+ python -c "from <package> import <class>; r = <class>('test'); print(repr(r))"
1058
+ """
1059
+
1060
+ # ModuleNotFoundError / ImportError
1061
+ elif any(kw in error_lower for kw in ("modulenotfounderror", "importerror", "no module named")):
1062
+ extra_hints += f"""
1063
+ ⚠ IMPORT ERROR: A module could not be imported.
1064
+ • The plugin root `{plugin_dir}` is already in sys.path — local package imports should work.
1065
+ • If a dependency is missing, check if it's listed in plugin.json "dependencies".
1066
+ • Use Bash to verify: `python -c "import <package>"` in cwd={plugin_dir}
1067
+ • If the package name differs from the import name, adjust the import statement.
1068
+ """
1069
+
1070
+ # ToolDef structure error
1071
+ elif "must be a tooldef" in error_lower or "raw function" in error_lower:
1072
+ extra_hints += """
1073
+ ⚠ TOOLDEF STRUCTURE: TOOL_DEFS contains a raw function instead of a ToolDef object.
1074
+ WRONG: TOOL_DEFS = [my_tool_function]
1075
+ RIGHT: TOOL_DEFS = [ToolDef(name="my_tool", schema={...}, func=my_tool_function)]
1076
+
1077
+ Each entry in TOOL_DEFS must be a ToolDef object with: name (str), schema (dict), func (callable).
1078
+ ToolDef takes EXACTLY: ToolDef(name, schema, func, read_only=False, concurrent_safe=False).
1079
+ NEVER pass description/parameters/handler as kwargs to ToolDef — they go INSIDE schema.
1080
+ """
1081
+
1082
+ # OUTPUT_TOO_VERBOSE — tool worked but dumped too many chars
1083
+ elif "output_too_verbose" in error_lower:
1084
+ extra_hints += """
1085
+ ⚠ OUTPUT BLOAT: The tool ran successfully but returned too many chars,
1086
+ which would get truncated at runtime and waste context. The tool needs
1087
+ REDESIGN, not a bug fix. Concretely:
1088
+
1089
+ a) Identify the bloat source: `.info` dict? `.to_dict()`? `json.dumps()`
1090
+ of a full library object? `print()` of an entire DataFrame?
1091
+ b) Replace it with a CURATED selection — pick the 6-12 fields that
1092
+ actually matter for the tool's purpose. Drop everything else.
1093
+ c) Format compactly: `f"{key}: {val}"` lines, or a small markdown table.
1094
+ No pretty-printed JSON. No raw dumps. No log spam.
1095
+ d) For lists: SLICE to limit=10 (default) BEFORE formatting.
1096
+ e) For long text fields (descriptions, summaries): truncate to ~400 chars
1097
+ with "..." suffix unless params.get("verbose") is True.
1098
+ f) For numbers: round floats, format big numbers as "1.2B" / "850M".
1099
+
1100
+ Edit the function body — do NOT touch the schema unless adding a `verbose`
1101
+ param. Re-run with default params and confirm output < 2500 chars.
1102
+ """
1103
+
1104
+ # General hint for documentation/API research (appears after specific error hints)
1105
+ elif "import" in error_lower or "attribute" in error_lower or "type" in error_lower or "api" in error_lower:
1106
+ extra_hints += f"""
1107
+ ⚠ RESEARCH HINT: This error suggests you need external documentation.
1108
+ You have WebSearch available — use it to find official docs, examples, or Stack Overflow discussions.
1109
+ Example: `WebSearch(query="python {safe_name} {task_title.replace(' ', ' ').split()[0] if task_title else 'error'} documentation")`
1110
+ """
1111
+
1112
+ context_hint = f"\nORIGINAL GOAL: {original_goal}\n" if original_goal else ""
1113
+
1114
+ # generation_context is now obsolete — the full generator conversation is
1115
+ # seeded into state.messages from _generation_session.json above. Kept as
1116
+ # a no-op fallback for older callers that still pass it.
1117
+ gen_context_hint = ""
1118
+
1119
+ fix_message = f"""Fix a failing verification task in the Dulus plugin `{safe_name}`.
1120
+
1121
+ PLUGIN DIR: {plugin_dir}
1122
+ {context_hint}
1123
+ FAILING TASK: {task_title}
1124
+
1125
+ FULL ERROR:
1126
+ ```
1127
+ {error_msg}
1128
+ ```
1129
+ {extra_hints}
1130
+ {gen_context_hint}
1131
+
1132
+ HOW TO FIX:
1133
+ 1. Read `{plugin_dir}/plugin_tool.py` and relevant source files in `{plugin_dir}`
1134
+ 2. Use Bash to test: `python -c "import <pkg>; <test>"` (cwd={plugin_dir})
1135
+ 3. Edit `{plugin_dir}/plugin_tool.py` to fix the issue
1136
+ 4. Verify: `python -c "import ast; ast.parse(open(r'{plugin_dir}/plugin_tool.py').read())"`
1137
+
1138
+ KEY RULES:
1139
+ - Use `params.get("key", default)`, NEVER `params["key"]`
1140
+ - Tool functions MUST return a string (not None, not just print)
1141
+ - `encoding='utf-8', errors='replace'` everywhere
1142
+ - True/False/None only, never true/false/null
1143
+ - `from tool_registry import ToolDef` — this exists, don't question it
1144
+ - Fix ONLY this failing task. Don't redesign the whole plugin.
1145
+
1146
+ SPECIAL OPTIONS:
1147
+ - BYPASS_REQUEST: If the error is a false positive after your fix
1148
+ - SKIP_TOOL: If a tool truly cannot be fixed, remove it from TOOL_DEFS
1149
+
1150
+ When done, output a one-line summary of what you changed.
1151
+ """
1152
+
1153
+ fix_config = {**config, "permission_mode": "accept-all"}
1154
+
1155
+ # Fresh state per task. Seed messages from the generator's session JSON
1156
+ # so the fixer continues the same conversation — same effect as /load on
1157
+ # a normal Dulus session, but inline. Falls back to empty if the JSON
1158
+ # is missing (older plugins, or generation failed to save it).
1159
+ state = _agent.AgentState()
1160
+ gen_session_path = plugin_dir / "_generation_session.json"
1161
+ if gen_session_path.exists():
1162
+ try:
1163
+ _gen = json.loads(gen_session_path.read_text(encoding="utf-8"))
1164
+ seeded = _gen.get("messages") or []
1165
+ if seeded and isinstance(seeded, list):
1166
+ state.messages = list(seeded)
1167
+ except Exception as _e:
1168
+ warn(f"Could not seed fixer state from generation session: {_e}")
1169
+
1170
+ # System prompt notes whether we resumed from generation or started fresh,
1171
+ # so the model knows whether to trust the prior assistant turn as its own.
1172
+ _resumed = bool(state.messages)
1173
+ _continuity_note = (
1174
+ "CONTINUITY: The conversation above is YOUR earlier work generating "
1175
+ "this plugin (you are the same assistant — same identity, full memory "
1176
+ "of your design choices). A verification step then failed — your job "
1177
+ "now is to fix it without contradicting your prior design unless the "
1178
+ "failure shows the design itself was wrong.\n\n"
1179
+ if _resumed else ""
1180
+ )
1181
+
1182
+ # DEBUG MODE system prompt - focused on fixing broken code
1183
+ # Do NOT use build_system_prompt - it confuses the agent about being "inside Dulus"
1184
+ system = (
1185
+ f"You are an AI coding assistant fixing a broken Dulus plugin. "
1186
+ f"Your task: Fix the code in {plugin_dir}/plugin_tool.py\n"
1187
+ f"\n"
1188
+ f"{_continuity_note}"
1189
+ f"\n"
1190
+ f"AVAILABLE TOOLS: Read, Write, Edit, Bash, Grep, WebSearch, MemorySearch. "
1191
+ f"Use these tools to fix and verify the code. "
1192
+ f"\n"
1193
+ f"CRITICAL: You MUST write VALID Python code that CAN be imported and executed. "
1194
+ f"This is NOT a simulation. Your code WILL be run. Do NOT say 'I cannot test this'. "
1195
+ f"\n"
1196
+ f"ToolDef IMPORT: Use 'from tool_registry import ToolDef' - this exists. "
1197
+ f"Do NOT question it - just write the import. "
1198
+ f"\n"
1199
+ f"DEBUGGING STRATEGY:\n"
1200
+ f"1. READ the error message below carefully\n"
1201
+ f"2. LOOK at the current broken code with Read tool\n"
1202
+ f"3. UNDERSTAND what the library expects (check __init__.py, docs)\n"
1203
+ f"4. FIX only what's broken - don't rewrite everything\n"
1204
+ f"5. TEST that it works with Bash tool\n"
1205
+ f"\n"
1206
+ f"WINDOWS PATHS: Use forward slashes / or raw strings r'...' in Python\n"
1207
+ f"ENCODING: Always use encoding='utf-8' when reading/writing files\n"
1208
+ f"PARAMETERS: Use params.get() with defaults, NEVER params[key]"
1209
+ )
1210
+
1211
+ message_to_send = fix_message
1212
+
1213
+ print_tool_start("Edit", {"file_path": f"{safe_name}/plugin_tool.py", "reason": task_title})
1214
+ bypass_requested = False
1215
+ bypass_reason = ""
1216
+ skip_tool_requested = False
1217
+ skip_tool_reason = ""
1218
+ try:
1219
+ mtime_before = tool_file.stat().st_mtime if tool_file.exists() else 0
1220
+
1221
+ for event in _agent.run(message_to_send, state, fix_config, system):
1222
+ if isinstance(event, _agent.ToolStart):
1223
+ print_tool_start(event.name, event.inputs)
1224
+ elif isinstance(event, _agent.ToolEnd):
1225
+ lines = event.result.count("\n") + 1 if event.result else 0
1226
+ chars = len(event.result) if event.result else 0
1227
+ label = f"{lines} lines ({chars} chars)" if lines > 1 else event.result[:80]
1228
+ print_tool_end(event.name, label, success=not event.result.startswith("Error"))
1229
+ elif isinstance(event, _agent.TextChunk):
1230
+ # Check for BYPASS_REQUEST in agent response
1231
+ if "BYPASS_REQUEST" in event.text:
1232
+ bypass_requested = True
1233
+ bypass_reason = event.text.split("BYPASS_REQUEST:")[-1].strip() if "BYPASS_REQUEST:" in event.text else "Agent reports this is a false positive"
1234
+ info(f" Agent requests bypass: {bypass_reason[:80]}...")
1235
+ # Check for SKIP_TOOL in agent response
1236
+ if "SKIP_TOOL" in event.text:
1237
+ skip_tool_requested = True
1238
+ skip_tool_reason = event.text.split("SKIP_TOOL:")[-1].strip() if "SKIP_TOOL:" in event.text else "Agent reports tool cannot be fixed"
1239
+ info(f" Agent requests skip: {skip_tool_reason[:80]}...")
1240
+ pass # suppress inline text; summary printed at end
1241
+ elif isinstance(event, _agent.ThinkingChunk):
1242
+ stream_thinking(event.text, config.get("verbose", False))
1243
+
1244
+ mtime_after = tool_file.stat().st_mtime if tool_file.exists() else 0
1245
+ file_changed = mtime_after != mtime_before
1246
+
1247
+ # If skip was requested, handle it specially
1248
+ if skip_tool_requested:
1249
+ print_tool_end("Edit", f"Agent skipped tool: {skip_tool_reason[:60]}...", success=True)
1250
+ # Return special flag to indicate tool should be removed
1251
+ return True, state, "skip"
1252
+
1253
+ # If bypass was requested, return special status
1254
+ if bypass_requested:
1255
+ print_tool_end("Edit", f"Fix reports bypass needed: {bypass_reason[:60]}...", success=True)
1256
+ return True, state, "bypass"
1257
+
1258
+ # Validate the result compiles regardless of whether the file changed
1259
+ if tool_file.exists():
1260
+ compile_ok, _ = _compile_check(plugin_dir)
1261
+ if compile_ok:
1262
+ print_tool_end("Edit", "Fix applied and compiles OK", success=True)
1263
+ return True, state, False
1264
+ else:
1265
+ print_tool_end("Edit", "Fix attempted but result does not compile", success=False)
1266
+ return False, state, False
1267
+
1268
+ print_tool_end("Edit", "Fix attempted but plugin_tool.py missing", success=False)
1269
+ return False, state, False
1270
+ except Exception as e:
1271
+ print_tool_end("Edit", f"Fix agent failed: {e}", success=False)
1272
+ return False, state, False
1273
+
1274
+
1275
+ def _run_adapter_worker(plugin_dir: Path, safe_name: str,
1276
+ analysis: dict, config: dict,
1277
+ generator_context: str = "") -> bool:
1278
+ """
1279
+ Worker loop: derive todo from generated tools, verify each, fix failures.
1280
+ Returns True only if every required task passes.
1281
+
1282
+ Args:
1283
+ generator_context: Context from generation phase (reasoning text) to help fix agent understand the library
1284
+ """
1285
+ verbose = config.get("verbose", False)
1286
+ if verbose:
1287
+ info(f"Running adapter worker for '{safe_name}'...")
1288
+
1289
+ max_fix_attempts = config.get("adapter_max_fix_attempts", 20)
1290
+
1291
+ # Pre-flight: code must at least compile to derive a todo. If it doesn't,
1292
+ # try fix passes on the syntax error before giving up (single agent, user decides on fresh start).
1293
+ compile_ok, compile_msg = _compile_check(plugin_dir)
1294
+ if not compile_ok:
1295
+ if verbose: warn(f" Initial compile failed: {compile_msg}")
1296
+ accumulated_compile_errors: list[str] = []
1297
+ compile_state = None # Will hold agent state across compile fix attempts
1298
+ compile_bypass_available = False
1299
+ for attempt in range(max_fix_attempts):
1300
+ accumulated_compile_errors.append(compile_msg)
1301
+ # Pass generation context only on first attempt to help agent understand library
1302
+ gen_ctx = generator_context if attempt == 0 else ""
1303
+ _, compile_state, fix_status = _attempt_fix(plugin_dir, safe_name, "plugin_tool.py compiles",
1304
+ compile_msg, analysis, config,
1305
+ original_goal=f"Attempt {attempt+1}: initial generation had syntax errors",
1306
+ state=compile_state,
1307
+ generation_context=gen_ctx)
1308
+ if fix_status == "bypass":
1309
+ compile_bypass_available = True
1310
+ elif fix_status == "skip":
1311
+ # Agent wants to skip - treat as bypass for compile errors
1312
+ compile_bypass_available = True
1313
+ compile_ok, compile_msg = _compile_check(plugin_dir)
1314
+ if compile_ok:
1315
+ if verbose: ok(f" Compile fixed on attempt {attempt + 1}.")
1316
+ break
1317
+
1318
+ if not compile_ok:
1319
+ # Max attempts reached - ask user what to do
1320
+ err(f" Could not fix compile error after {max_fix_attempts} attempts.")
1321
+ print()
1322
+ print("What would you like to do?")
1323
+ print(" [1] Keep files and fix manually later")
1324
+ print(" [2] Fresh start - reset and try with new context")
1325
+ print(" [3] SKIP compilation check - I will fix it manually (NOT RECOMMENDED)")
1326
+ try:
1327
+ choice = input("Choose [1/2/3]: ").strip()
1328
+ except (EOFError, KeyboardInterrupt):
1329
+ choice = "1"
1330
+
1331
+ if choice == "3":
1332
+ warn(f" Skipping compilation check as requested. Plugin may not work correctly.")
1333
+ # Continue with potentially broken code - user responsibility
1334
+ elif choice == "2":
1335
+ warn(f" Triggering fresh start for '{safe_name}'...")
1336
+ _attempt_fresh_start(plugin_dir, safe_name, accumulated_compile_errors, analysis, config)
1337
+ # Recursively retry with fresh start (new agent)
1338
+ return _run_adapter_worker(plugin_dir, safe_name, analysis, config)
1339
+ else:
1340
+ err(f" Compile errors persisted. Plugin files kept in {plugin_dir}")
1341
+ return False
1342
+
1343
+ # Build todo list from the (now compileable) tools
1344
+ items = _build_todo_items(plugin_dir, safe_name)
1345
+ todo_path = _write_todo_file(plugin_dir, safe_name, items)
1346
+
1347
+ if verbose:
1348
+ print_tool_start("Verify", {"plugin": safe_name, "tasks": len(items)})
1349
+
1350
+ # Run each task; retry up to max_fix_attempts with single agent (user decides on fresh start).
1351
+ failed_tasks: list[str] = []
1352
+ for item in items:
1353
+ title = item["title"]
1354
+ verify = item["verify"]
1355
+
1356
+ passed, msg = _run_verification(plugin_dir, safe_name, verify)
1357
+ if passed:
1358
+ if verbose: print_tool_end("Verify", f"✓ {title} ({msg})", success=True)
1359
+ _mark_task(todo_path, title, "done")
1360
+ continue
1361
+
1362
+ if verbose: print_tool_end("Verify", f"✗ {title} ({msg})", success=False)
1363
+
1364
+ accumulated_errors: list[str] = [msg]
1365
+ task_state = None # Single agent state for this task
1366
+ bypass_available = False
1367
+ bypass_reason = ""
1368
+ skip_requested = False
1369
+ skip_reason = ""
1370
+ for attempt in range(max_fix_attempts):
1371
+ if verbose: info(f" Fix attempt {attempt + 1}/{max_fix_attempts} for: {title}")
1372
+
1373
+ # Pass generation context only on first attempt
1374
+ gen_ctx = generator_context if attempt == 0 else ""
1375
+ success, task_state, fix_status = _attempt_fix(plugin_dir, safe_name, title, msg, analysis, config,
1376
+ original_goal=f"Attempt {attempt + 1}: task '{title}' failed with: {msg}",
1377
+ state=task_state,
1378
+ generation_context=gen_ctx)
1379
+
1380
+ # If agent requested bypass, remember it for the user menu
1381
+ if fix_status == "bypass":
1382
+ bypass_available = True
1383
+ bypass_reason = "Agent reports this error is a false positive and the fix is correct"
1384
+ elif fix_status == "skip":
1385
+ # Agent wants to skip this tool
1386
+ skip_requested = True
1387
+ skip_reason = f"Agent could not fix {title} and requests to skip it"
1388
+ if verbose: warn(f" Agent requests to skip '{title}' - will remove from plugin")
1389
+ break
1390
+
1391
+ passed, msg = _run_verification(plugin_dir, safe_name, verify)
1392
+ if passed:
1393
+ if verbose: ok(f" ✓ {title} [fixed on attempt {attempt + 1}: {msg}]")
1394
+ _mark_task(todo_path, title, "done")
1395
+ break
1396
+
1397
+ accumulated_errors.append(msg)
1398
+
1399
+ # Handle agent requesting to skip this tool
1400
+ if skip_requested:
1401
+ if verbose: warn(f" Agent requested to skip '{title}' - will be removed from plugin")
1402
+ failed_tasks.append(title) # Add to failed so it gets removed
1403
+ _mark_task(todo_path, title, "skip")
1404
+ continue # Skip to next task
1405
+
1406
+ if not passed:
1407
+ # Max attempts reached - ask user what to do
1408
+ if verbose: err(f" ✗ {title} [unfixed after {max_fix_attempts} attempts: {msg}]")
1409
+ print()
1410
+ print(f"Task '{title}' failed after {max_fix_attempts} attempts. What would you like to do?")
1411
+ print(" [1] Keep files and fix manually later")
1412
+ print(" [2] Fresh start - reset and retry this task with new context")
1413
+ print(f" [3] BYPASS - Skip this task and continue (use if fix seems correct but test is wrong)")
1414
+ try:
1415
+ choice = input(f"Choose [1/2/3]: ").strip()
1416
+ except (EOFError, KeyboardInterrupt):
1417
+ choice = "3" # Default to bypass on interrupt to avoid infinite loops
1418
+
1419
+ if choice == "3":
1420
+ ok(f" ✓ {title} [BYPASSED: {bypass_reason if bypass_available else 'User requested bypass'}]")
1421
+ _mark_task(todo_path, title, "bypassed")
1422
+ continue # Skip to next task
1423
+ elif choice == "2":
1424
+ warn(f" Triggering fresh start for '{safe_name}'...")
1425
+ fresh_ok = _attempt_fresh_start(plugin_dir, safe_name, accumulated_errors, analysis, config)
1426
+ if not fresh_ok:
1427
+ err(f" Fresh start failed to generate valid code for '{safe_name}'")
1428
+ _mark_task(todo_path, title, "fail")
1429
+ failed_tasks.append(title)
1430
+ continue # Skip to next task
1431
+
1432
+ # Rebuild todo and restart this task with fresh agent
1433
+ items = _build_todo_items(plugin_dir, safe_name)
1434
+ _write_todo_file(plugin_dir, safe_name, items)
1435
+ # Reset task state for fresh agent
1436
+ task_state = None
1437
+ # IMPORTANT: Update msg with CURRENT error from the fresh code
1438
+ passed, msg = _run_verification(plugin_dir, safe_name, verify)
1439
+ if passed:
1440
+ # Fresh start already fixed it!
1441
+ if verbose: ok(f" ✓ {title} [fixed by fresh start: {msg}]")
1442
+ _mark_task(todo_path, title, "done")
1443
+ continue # Skip to next task
1444
+
1445
+ # Retry this specific task from beginning with UPDATED error message
1446
+ fresh_bypass_available = False
1447
+ for fresh_attempt in range(max_fix_attempts):
1448
+ if verbose: info(f" Fresh attempt {fresh_attempt + 1}/{max_fix_attempts} for: {title}")
1449
+ # Pass generation context and accumulated errors in first fresh attempt
1450
+ gen_ctx = generator_context if fresh_attempt == 0 else ""
1451
+ # Include error history so agent knows what NOT to repeat
1452
+ error_history = "\n".join(f" - Previous error {i+1}: {e}" for i, e in enumerate(accumulated_errors[-5:])) # Last 5 errors
1453
+ original_goal = f"Fresh attempt {fresh_attempt + 1}: {title}\n\nCURRENT ERROR: {msg}\n\nPREVIOUS ERRORS (DO NOT REPEAT THESE):\n{error_history}"
1454
+ success, task_state, fix_status = _attempt_fix(plugin_dir, safe_name, title, msg, analysis, config,
1455
+ original_goal=original_goal,
1456
+ state=task_state,
1457
+ generation_context=gen_ctx)
1458
+ if fix_status == "bypass":
1459
+ fresh_bypass_available = True
1460
+ elif fix_status == "skip":
1461
+ # Agent wants to skip this tool even after fresh start
1462
+ if verbose: warn(f" Agent requests to skip '{title}' after fresh start")
1463
+ failed_tasks.append(title)
1464
+ _mark_task(todo_path, title, "skip")
1465
+ # Need to break out of fresh_attempt loop and skip to next item
1466
+ passed = False # Mark as not passed but will be handled by skip
1467
+ break
1468
+ passed, msg = _run_verification(plugin_dir, safe_name, verify)
1469
+ if passed:
1470
+ if verbose: ok(f" ✓ {title} [fixed on fresh attempt {fresh_attempt + 1}: {msg}]")
1471
+ _mark_task(todo_path, title, "done")
1472
+ break
1473
+ accumulated_errors.append(msg)
1474
+
1475
+ # Check if tool was marked for skip during fresh attempts
1476
+ if title in failed_tasks:
1477
+ # Skip was requested - continue to next item
1478
+ continue
1479
+
1480
+ if passed:
1481
+ # Fresh start succeeded - continue to next task
1482
+ continue
1483
+
1484
+ # After fresh start also failed, ask user again with bypass option
1485
+ print(f" Fresh start also failed for '{title}'. What would you like to do?")
1486
+ print(" [1] Keep files and fix manually later")
1487
+ print(" [2] Mark as failed and continue to next task")
1488
+ print(" [3] BYPASS this task and continue")
1489
+ try:
1490
+ fresh_choice = input("Choose [1/2/3]: ").strip()
1491
+ except (EOFError, KeyboardInterrupt):
1492
+ fresh_choice = "3"
1493
+
1494
+ if fresh_choice == "3":
1495
+ ok(f" ✓ {title} [BYPASSED after fresh start]")
1496
+ _mark_task(todo_path, title, "bypassed")
1497
+ elif fresh_choice == "2":
1498
+ _mark_task(todo_path, title, "fail")
1499
+ failed_tasks.append(title)
1500
+ else:
1501
+ err(f" ✗ {title} [still unfixed after fresh start]")
1502
+ _mark_task(todo_path, title, "fail")
1503
+ failed_tasks.append(title)
1504
+ else:
1505
+ _mark_task(todo_path, title, "fail")
1506
+ failed_tasks.append(title)
1507
+
1508
+ if failed_tasks:
1509
+ err(f" {len(failed_tasks)} task(s) failed after {max_fix_attempts} fix attempts each:")
1510
+ for t in failed_tasks:
1511
+ err(f" - {t}")
1512
+
1513
+ # Extract tool names from failed smoke tests
1514
+ failed_tool_names = []
1515
+ for title in failed_tasks:
1516
+ # Parse "Tool `name` runs successfully..."
1517
+ if "Tool `" in title and "` runs" in title:
1518
+ import re
1519
+ match = re.search(r"Tool `([^`]+)`", title)
1520
+ if match:
1521
+ failed_tool_names.append(match.group(1))
1522
+
1523
+ if failed_tool_names:
1524
+ info(f" Removing {len(failed_tool_names)} failed tool(s) from plugin...")
1525
+ _remove_failed_tools(plugin_dir, safe_name, failed_tool_names, verbose)
1526
+
1527
+ # Check if we have at least some working tools
1528
+ mod, _ = _load_plugin_module(plugin_dir, safe_name)
1529
+ if mod and getattr(mod, "TOOL_DEFS", None):
1530
+ remaining_tools = len(mod.TOOL_DEFS)
1531
+ if remaining_tools > 0:
1532
+ ok(f" Plugin saved with {remaining_tools} working tool(s). Failed tools removed.")
1533
+ # Update plugin.json to only include working tools
1534
+ _update_plugin_json_tools(plugin_dir, safe_name, [t.name for t in mod.TOOL_DEFS if hasattr(t, 'name')])
1535
+ return True
1536
+
1537
+ # No tools left working - real failure
1538
+ return False
1539
+
1540
+ if verbose:
1541
+ ok(f" All {len(items)} tasks passed.")
1542
+ return True
1543
+
1544
+
1545
+ def _remove_failed_tools(plugin_dir: Path, safe_name: str, failed_tool_names: list[str], verbose: bool = False) -> None:
1546
+ """
1547
+ Update plugin_tool.py to only include working tools in TOOL_DEFS and TOOL_SCHEMAS.
1548
+ Keeps all the original code, just updates the export lists.
1549
+ """
1550
+ import re
1551
+
1552
+ # Reload module to identify which ToolDef variables correspond to working tools
1553
+ mod, _ = _load_plugin_module(plugin_dir, safe_name)
1554
+ if not mod or not getattr(mod, "TOOL_DEFS", None):
1555
+ return
1556
+
1557
+ # Build mapping of tool_name -> var_name by parsing the source
1558
+ tool_file = plugin_dir / "plugin_tool.py"
1559
+ source = tool_file.read_text(encoding="utf-8", errors="replace")
1560
+
1561
+ working_var_names = []
1562
+ for td in mod.TOOL_DEFS:
1563
+ if hasattr(td, "name") and td.name not in failed_tool_names:
1564
+ # Find the variable name for this tool
1565
+ # Look for pattern: var_name = ToolDef(... name="tool_name" ...)
1566
+ pattern = rf'(\w+)\s*=\s*ToolDef\([^)]*name\s*=\s*["\']{re.escape(td.name)}["\']'
1567
+ match = re.search(pattern, source)
1568
+ if match:
1569
+ working_var_names.append(match.group(1))
1570
+
1571
+ if not working_var_names:
1572
+ return
1573
+
1574
+ # Replace TOOL_DEFS line
1575
+ new_tool_defs = f"TOOL_DEFS = [{', '.join(working_var_names)}]"
1576
+ source = re.sub(r'^TOOL_DEFS\s*=\s*\[.*?\].*$', new_tool_defs, source, flags=re.MULTILINE | re.DOTALL)
1577
+
1578
+ # Replace TOOL_SCHEMAS line
1579
+ new_tool_schemas = "TOOL_SCHEMAS = [t.schema for t in TOOL_DEFS]"
1580
+ source = re.sub(r'^TOOL_SCHEMAS\s*=.*$', new_tool_schemas, source, flags=re.MULTILINE)
1581
+
1582
+ # Add comment noting failed tools were removed
1583
+ if '# NOTE:' not in source:
1584
+ source = source.replace(
1585
+ 'TOOL_DEFS = [',
1586
+ f'# NOTE: Removed failed tools: {failed_tool_names}\nTOOL_DEFS = ['
1587
+ )
1588
+
1589
+ tool_file.write_text(source, encoding="utf-8")
1590
+
1591
+ if verbose:
1592
+ info(f" Updated TOOL_DEFS to include only {len(working_var_names)} working tool(s)")
1593
+
1594
+
1595
+ def _update_plugin_json_tools(plugin_dir: Path, safe_name: str, working_tool_names: list[str]) -> None:
1596
+ """Update plugin.json to reflect only the working tools."""
1597
+ import json
1598
+
1599
+ json_file = plugin_dir / "plugin.json"
1600
+ if not json_file.exists():
1601
+ return
1602
+
1603
+ try:
1604
+ manifest = json.loads(json_file.read_text(encoding="utf-8"))
1605
+ # Keep plugin_tool in tools list (that's the module)
1606
+ manifest["tools"] = ["plugin_tool"]
1607
+ # Add a note about which specific tools are available
1608
+ manifest["_working_tools"] = working_tool_names
1609
+ manifest["_failed_tools_removed"] = True
1610
+ json_file.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
1611
+ except Exception as e:
1612
+ warn(f" Could not update plugin.json: {e}")
1613
+
1614
+
1615
+ # Keep the old name for any external callers
1616
+ def _validate_generated_tools(plugin_dir: Path, safe_name: str) -> bool:
1617
+ """Backward-compat shim — runs the worker without fix attempts (no AI)."""
1618
+ items = _build_todo_items(plugin_dir, safe_name)
1619
+ if not items:
1620
+ return False
1621
+ for item in items:
1622
+ passed, _msg = _run_verification(plugin_dir, safe_name, item["verify"])
1623
+ if not passed and item["verify"] in ("compile", "import", "exports"):
1624
+ return False
1625
+ return True
1626
+
1627
+
1628
+ def autoadapt_if_needed(plugin_dir: Path, name: str, config: dict) -> bool:
1629
+ """Main entry point: check if manifest is missing and try to generate it."""
1630
+ from .types import PluginManifest
1631
+ manifest = PluginManifest.from_plugin_dir(plugin_dir)
1632
+ if manifest is None:
1633
+ info(f"Missing manifest for '{name}', attempting auto-adaptation...")
1634
+ success = generate_plugin_files(plugin_dir, name, config)
1635
+ # Always reload to register the plugin (even if adaptation had issues)
1636
+ from .loader import reload_plugins
1637
+ result = reload_plugins()
1638
+ if success:
1639
+ ok(f"Plugin '{name}' processed: {result['tools_registered']} tools registered")
1640
+ return success
1641
+ return True