livepilot 1.23.2 → 1.23.4

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 (46) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +108 -10
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +39 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/runtime/remote_commands.py +10 -0
  18. package/mcp_server/server.py +45 -24
  19. package/mcp_server/tools/agent_os.py +33 -9
  20. package/mcp_server/tools/analyzer.py +84 -23
  21. package/mcp_server/tools/browser.py +20 -1
  22. package/mcp_server/tools/devices.py +78 -11
  23. package/mcp_server/tools/perception.py +5 -1
  24. package/mcp_server/tools/tracks.py +39 -2
  25. package/mcp_server/user_corpus/__init__.py +48 -0
  26. package/mcp_server/user_corpus/manifest.py +142 -0
  27. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  28. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  29. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  30. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  31. package/mcp_server/user_corpus/runner.py +261 -0
  32. package/mcp_server/user_corpus/scanner.py +115 -0
  33. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  34. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  35. package/mcp_server/user_corpus/scanners/als.py +144 -0
  36. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  37. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  38. package/mcp_server/user_corpus/tools.py +904 -0
  39. package/mcp_server/user_corpus/wizard.py +224 -0
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/browser.py +7 -2
  43. package/remote_script/LivePilot/devices.py +9 -0
  44. package/remote_script/LivePilot/simpler_sample.py +98 -0
  45. package/requirements.txt +3 -3
  46. package/server.json +2 -2
@@ -0,0 +1,347 @@
1
+ """Phase 2.3 + 2.4 — Manual discovery + extraction.
2
+
3
+ Given a DetectedPlugin, glob the standard manual locations + extract text from
4
+ the most promising file. Supports .pdf (pypdf with pdfplumber fallback), .html
5
+ (bs4), .md / .txt / .rtf (raw read).
6
+
7
+ Section splitter recognizes common chapter heading patterns and produces a
8
+ sections.json mapping section title → text range.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ from dataclasses import asdict, dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .detector import DetectedPlugin
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ _MANUAL_EXTENSIONS = {".pdf", ".html", ".htm", ".md", ".txt", ".rtf"}
24
+ _MANUAL_NAME_HINTS = (
25
+ "manual", "guide", "reference", "documentation", "user", "handbook", "readme",
26
+ )
27
+ # Anti-hints: filenames containing these are NOT plugin manuals even if the
28
+ # extension matches. Filters out the log/cache/preset noise that lives in
29
+ # Application Support directories.
30
+ _MANUAL_NAME_ANTIHINTS = (
31
+ "log", "cache", "settings", "prefs", "preferences", "tmp", "temp",
32
+ "crash", "error", "debug", "trace", "history", "registry", "license",
33
+ "credits", "thirdparty", "third-party", "third_party", "uninstall",
34
+ "lockfile", "lock-file", ".lock",
35
+ )
36
+ # Non-extension names we also reject (case-insensitive substring on the stem)
37
+ _MANUAL_REJECT_STEMS = (
38
+ "package-info", "buildinfo", "version", "checksum",
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class ManualCandidate:
44
+ path: str
45
+ extension: str
46
+ size_kb: int
47
+ name_score: float # higher = filename more clearly a manual
48
+ location_score: int # higher = more authoritative location
49
+
50
+ def __lt__(self, other: "ManualCandidate") -> bool:
51
+ # sort: location first, then name score, then size descending
52
+ return (
53
+ self.location_score, self.name_score, self.size_kb,
54
+ ) > (
55
+ other.location_score, other.name_score, other.size_kb,
56
+ )
57
+
58
+
59
+ @dataclass
60
+ class ManualExtraction:
61
+ plugin_id: str
62
+ source_path: str
63
+ source_kind: str # pdf | html | md | txt | rtf
64
+ text: str
65
+ char_count: int
66
+ page_count: int | None = None
67
+ sections: list[dict] = field(default_factory=list) # [{title, start, end}]
68
+ truncated: bool = False
69
+ error: str | None = None
70
+
71
+
72
+ # ─── Discovery ──────────────────────────────────────────────────────────────
73
+
74
+
75
+ def discover_manuals_for_plugin(
76
+ plugin: DetectedPlugin,
77
+ extra_search_dirs: list[Path] | None = None,
78
+ max_candidates: int = 20,
79
+ ) -> list[ManualCandidate]:
80
+ """Glob the standard manual locations and return ranked candidates.
81
+
82
+ Search priority (high to low):
83
+ 1. Inside the bundle: <bundle>/Contents/Resources/
84
+ 2. /Applications/<vendor>*.app/Contents/Resources/
85
+ 3. /Library/Audio/Plug-Ins/<format>/<vendor>/Documentation/
86
+ 4. ~/Documents/<vendor>/, ~/Documents/<plugin>/
87
+ 5. /Library/Application Support/<vendor>/
88
+ """
89
+ candidates: list[ManualCandidate] = []
90
+ bundle = Path(plugin.bundle_path)
91
+ vendor = plugin.vendor or ""
92
+ name = plugin.name
93
+
94
+ search_locations = _enumerate_search_locations(bundle, vendor, name)
95
+ for extra in (extra_search_dirs or []):
96
+ search_locations.append((extra, 1)) # extras get baseline location score
97
+
98
+ for loc, loc_score in search_locations:
99
+ if not loc.exists():
100
+ continue
101
+ try:
102
+ for path in _walk_for_manuals(loc, max_depth=4):
103
+ cand = _score_candidate(path, loc_score)
104
+ if cand:
105
+ candidates.append(cand)
106
+ if len(candidates) >= max_candidates * 3:
107
+ break
108
+ except (PermissionError, OSError):
109
+ continue
110
+
111
+ # Dedupe by path; sort
112
+ seen: dict[str, ManualCandidate] = {}
113
+ for c in candidates:
114
+ if c.path not in seen or c < seen[c.path]:
115
+ seen[c.path] = c
116
+ ranked = sorted(seen.values())
117
+ return ranked[:max_candidates]
118
+
119
+
120
+ def _enumerate_search_locations(
121
+ bundle: Path, vendor: str, name: str,
122
+ ) -> list[tuple[Path, int]]:
123
+ """Build the (path, location_score) list to search. Higher score = more authoritative."""
124
+ home = Path.home()
125
+ out: list[tuple[Path, int]] = []
126
+ # Score 5: inside the bundle
127
+ out.append((bundle / "Contents" / "Resources", 5))
128
+ # Score 4: vendor app + format-specific docs folder
129
+ if vendor:
130
+ for slug in (vendor, vendor.replace(" ", ""), vendor.lower()):
131
+ apps = Path("/Applications").glob(f"*{slug}*.app")
132
+ for app in apps:
133
+ out.append((app / "Contents" / "Resources", 4))
134
+ out.append((Path(f"/Library/Audio/Plug-Ins/VST3/{slug}/Documentation"), 4))
135
+ out.append((Path(f"/Library/Audio/Plug-Ins/Components/{slug}/Documentation"), 4))
136
+ # Score 3: ~/Documents/<vendor>/, ~/Documents/<plugin>/
137
+ if vendor:
138
+ out.append((home / "Documents" / vendor, 3))
139
+ out.append((home / "Documents" / name, 3))
140
+ # Score 2: /Library/Application Support/<vendor>/
141
+ if vendor:
142
+ out.append((Path("/Library/Application Support") / vendor, 2))
143
+ out.append((home / "Library" / "Application Support" / vendor, 2))
144
+ return out
145
+
146
+
147
+ def _walk_for_manuals(root: Path, max_depth: int = 4):
148
+ """Yield manual-extension files within a directory tree (depth-bounded)."""
149
+ if not root.exists():
150
+ return
151
+ base_depth = len(root.parts)
152
+ try:
153
+ for path in root.rglob("*"):
154
+ try:
155
+ if not path.is_file():
156
+ continue
157
+ if (len(path.parts) - base_depth) > max_depth:
158
+ continue
159
+ if path.suffix.lower() in _MANUAL_EXTENSIONS:
160
+ yield path
161
+ except (PermissionError, OSError):
162
+ continue
163
+ except (PermissionError, OSError):
164
+ return
165
+
166
+
167
+ def _score_candidate(path: Path, location_score: int) -> ManualCandidate | None:
168
+ try:
169
+ size_kb = int(path.stat().st_size / 1024)
170
+ except OSError:
171
+ return None
172
+ if size_kb < 1:
173
+ return None # 0-byte files
174
+ name_lower = path.name.lower()
175
+ stem_lower = path.stem.lower()
176
+
177
+ # Reject if any anti-hint matches — these are logs/caches/settings, not manuals
178
+ for anti in _MANUAL_NAME_ANTIHINTS:
179
+ if anti in name_lower:
180
+ return None
181
+ for anti in _MANUAL_REJECT_STEMS:
182
+ if anti in stem_lower:
183
+ return None
184
+ # Reject any path whose immediate parent dir is named "logs", "cache", "tmp"
185
+ parent_lower = path.parent.name.lower()
186
+ if parent_lower in ("logs", "cache", "tmp", "temp", "crash", "trash"):
187
+ return None
188
+
189
+ name_score = 0.0
190
+ for hint in _MANUAL_NAME_HINTS:
191
+ if hint in name_lower:
192
+ name_score += 1.0
193
+ # Strong prior for "manual" specifically
194
+ if "manual" in name_lower:
195
+ name_score += 1.5
196
+ if path.suffix.lower() == ".pdf":
197
+ name_score += 0.5
198
+ # Require at least SOME positive signal: a name hint OR a PDF in a
199
+ # high-priority location. This blocks generic .txt files that aren't manuals.
200
+ if name_score == 0.0 and location_score < 4:
201
+ return None
202
+ return ManualCandidate(
203
+ path=str(path), extension=path.suffix.lower(),
204
+ size_kb=size_kb, name_score=name_score, location_score=location_score,
205
+ )
206
+
207
+
208
+ # ─── Extraction ─────────────────────────────────────────────────────────────
209
+
210
+
211
+ _MAX_CHAR_OUTPUT = 1_000_000 # cap so a 5000-page manual doesn't blow context
212
+
213
+
214
+ def extract_manual_text(
215
+ plugin: DetectedPlugin, candidate: ManualCandidate,
216
+ ) -> ManualExtraction:
217
+ """Extract text from a manual candidate. Never raises."""
218
+ path = Path(candidate.path)
219
+ ext = candidate.extension
220
+ try:
221
+ if ext == ".pdf":
222
+ text, pages = _extract_pdf(path)
223
+ kind = "pdf"
224
+ elif ext in (".html", ".htm"):
225
+ text = _extract_html(path)
226
+ pages = None
227
+ kind = "html"
228
+ elif ext == ".rtf":
229
+ text = _extract_rtf(path)
230
+ pages = None
231
+ kind = "rtf"
232
+ else:
233
+ text = path.read_text(encoding="utf-8", errors="ignore")
234
+ pages = None
235
+ kind = ext.lstrip(".")
236
+ except Exception as e: # noqa: BLE001
237
+ return ManualExtraction(
238
+ plugin_id=plugin.plugin_id, source_path=str(path),
239
+ source_kind=ext.lstrip("."), text="", char_count=0,
240
+ page_count=None, sections=[], truncated=False,
241
+ error=f"{type(e).__name__}: {e}",
242
+ )
243
+
244
+ truncated = False
245
+ if len(text) > _MAX_CHAR_OUTPUT:
246
+ text = text[:_MAX_CHAR_OUTPUT]
247
+ truncated = True
248
+
249
+ sections = _detect_sections(text)
250
+ return ManualExtraction(
251
+ plugin_id=plugin.plugin_id, source_path=str(path),
252
+ source_kind=kind, text=text, char_count=len(text),
253
+ page_count=pages, sections=sections, truncated=truncated,
254
+ )
255
+
256
+
257
+ def _extract_pdf(path: Path) -> tuple[str, int]:
258
+ """Extract text from a PDF. Try pypdf first, fall back to pdfplumber."""
259
+ pages = 0
260
+ try:
261
+ import pypdf
262
+ reader = pypdf.PdfReader(str(path))
263
+ chunks: list[str] = []
264
+ for page in reader.pages:
265
+ try:
266
+ chunks.append(page.extract_text() or "")
267
+ except Exception: # noqa: BLE001
268
+ continue
269
+ pages += 1
270
+ text = "\n\n".join(chunks)
271
+ if text.strip():
272
+ return text, pages
273
+ except Exception as e: # noqa: BLE001
274
+ logger.debug("pypdf failed on %s: %s; trying pdfplumber", path, e)
275
+ # Fallback
276
+ try:
277
+ import pdfplumber
278
+ chunks: list[str] = []
279
+ with pdfplumber.open(str(path)) as pdf:
280
+ for page in pdf.pages:
281
+ try:
282
+ chunks.append(page.extract_text() or "")
283
+ except Exception: # noqa: BLE001
284
+ continue
285
+ pages += 1
286
+ return "\n\n".join(chunks), pages
287
+ except Exception as e: # noqa: BLE001
288
+ logger.warning("pdfplumber also failed on %s: %s", path, e)
289
+ raise
290
+
291
+
292
+ def _extract_html(path: Path) -> str:
293
+ from bs4 import BeautifulSoup
294
+ raw = path.read_text(encoding="utf-8", errors="ignore")
295
+ soup = BeautifulSoup(raw, "html.parser")
296
+ for tag in soup(["script", "style", "nav", "footer"]):
297
+ tag.decompose()
298
+ return soup.get_text(separator="\n", strip=True)
299
+
300
+
301
+ def _extract_rtf(path: Path) -> str:
302
+ """Crude RTF stripper — drops control words + groups."""
303
+ raw = path.read_text(encoding="latin-1", errors="ignore")
304
+ # Strip control words like \word123 and groups
305
+ text = re.sub(r"\\[a-z]+-?\d* ?", "", raw)
306
+ text = re.sub(r"[{}]", "", text)
307
+ return text
308
+
309
+
310
+ # ─── Section detection ──────────────────────────────────────────────────────
311
+
312
+
313
+ _HEADING_PATTERNS = [
314
+ re.compile(r"^\s*(?:CHAPTER\s+\d+\s*[:.\s-]+)?([A-Z][A-Z0-9 \-&,'/]{3,60})\s*$",
315
+ re.MULTILINE),
316
+ re.compile(r"^\s*\d+(?:\.\d+)?\s+([A-Z][\w\s\-&,'/]{3,60})\s*$", re.MULTILINE),
317
+ re.compile(r"^#{1,3}\s+(.{3,80}?)\s*$", re.MULTILINE), # markdown
318
+ ]
319
+
320
+ _KNOWN_SECTION_KEYWORDS = (
321
+ "parameters", "controls", "modulation", "matrix", "envelopes", "lfo",
322
+ "filter", "filters", "oscillator", "oscillators", "effects", "fx",
323
+ "presets", "tutorials", "tutorial", "introduction", "overview",
324
+ "installation", "getting started", "performance", "automation",
325
+ "midi", "macros", "global", "interface", "browser", "library",
326
+ )
327
+
328
+
329
+ def _detect_sections(text: str) -> list[dict]:
330
+ """Best-effort section splitter. Returns at most 50 sections."""
331
+ headings: list[tuple[int, str]] = []
332
+ for pat in _HEADING_PATTERNS:
333
+ for m in pat.finditer(text):
334
+ title = m.group(1).strip()
335
+ start = m.start()
336
+ if 4 <= len(title) <= 60 and title not in (h[1] for h in headings):
337
+ # Bias toward known section keywords
338
+ low = title.lower()
339
+ score = sum(1 for kw in _KNOWN_SECTION_KEYWORDS if kw in low)
340
+ if score > 0 or len(headings) < 30:
341
+ headings.append((start, title))
342
+ headings.sort()
343
+ sections: list[dict] = []
344
+ for i, (start, title) in enumerate(headings[:50]):
345
+ end = headings[i + 1][0] if i + 1 < len(headings) else len(text)
346
+ sections.append({"title": title, "start": start, "end": end})
347
+ return sections
@@ -0,0 +1,247 @@
1
+ """Phase 3 + 4 — Research target builder + Synthesis brief builder.
2
+
3
+ These don't call WebSearch or Anthropic directly. They emit STRUCTURED TASK
4
+ PACKETS that the Claude agent (in Claude Code) fulfills via WebSearch +
5
+ WebFetch + Agent dispatch (sonnet subagents). This keeps the corpus engine
6
+ portable + lets Claude Code's permission model gate every external call.
7
+
8
+ See docs/PLUGIN_KNOWLEDGE_ENGINE.md §"Why the agent-driven split".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .detector import DetectedPlugin
17
+ from .manual import ManualExtraction
18
+
19
+
20
+ # ─── Phase 3: research target packet ────────────────────────────────────────
21
+
22
+
23
+ def build_research_targets(
24
+ plugin: DetectedPlugin,
25
+ local_manual: ManualExtraction | None = None,
26
+ output_root: Path | None = None,
27
+ ) -> dict:
28
+ """Emit a structured packet of research queries for the agent to fulfill.
29
+
30
+ The packet declares:
31
+ - what we already know (identity + local manual presence)
32
+ - what queries to run (manual_alt, technique_corpus, comparison)
33
+ - where to cache results
34
+ - what tool to call next (corpus_synthesize_plugin_identity)
35
+ """
36
+ name = plugin.name
37
+ vendor = plugin.vendor or "unknown vendor"
38
+ output_dir = (
39
+ (output_root or _default_output_root()) / "plugins" / plugin.plugin_id / "research"
40
+ )
41
+ has_local_manual = bool(local_manual and local_manual.text and not local_manual.error)
42
+
43
+ targets: list[dict] = []
44
+
45
+ # Target 1: alternate / verified manual (always emit; even with local manual,
46
+ # online may have a newer version)
47
+ targets.append({
48
+ "type": "manual_alt",
49
+ "rationale": (
50
+ "Verify local manual is current OR fetch one if no local manual exists"
51
+ if has_local_manual else
52
+ "No local manual found; locate the official PDF/web manual"
53
+ ),
54
+ "queries": _manual_queries(name, vendor),
55
+ "priority": 1,
56
+ "fetch_top_n": 1 if has_local_manual else 2,
57
+ })
58
+
59
+ # Target 2: technique corpus (recipes, sound design walkthroughs)
60
+ targets.append({
61
+ "type": "technique_corpus",
62
+ "rationale": "Build a producer-oriented technique library — concrete recipes, sweet spots, common patches",
63
+ "queries": _technique_queries(name, vendor),
64
+ "priority": 2,
65
+ "fetch_top_n": 5,
66
+ })
67
+
68
+ # Target 3: comparisons (when to reach for vs alternatives)
69
+ targets.append({
70
+ "type": "comparison",
71
+ "rationale": "Determine when to reach for this plugin vs comparable alternatives",
72
+ "queries": _comparison_queries(name, vendor),
73
+ "priority": 3,
74
+ "fetch_top_n": 3,
75
+ })
76
+
77
+ return {
78
+ "plugin_id": plugin.plugin_id,
79
+ "plugin_identity": {
80
+ "name": name, "vendor": vendor, "format": plugin.format,
81
+ "version": plugin.version, "unique_id": plugin.unique_id,
82
+ },
83
+ "local_manual_present": has_local_manual,
84
+ "local_manual_path": local_manual.source_path if has_local_manual else None,
85
+ "research_targets": targets,
86
+ "cache_root": str(output_dir),
87
+ "instructions": (
88
+ "Use WebSearch + WebFetch to fulfill each target. For each query, "
89
+ "cache the top result(s) under cache_root/<target_type>/<n>.txt + "
90
+ "<n>_url.txt (the source URL). Stamp each cached file with a "
91
+ "retrieval timestamp comment in the first line. Failures "
92
+ "(no results, paywall, 404) are logged to cache_root/search_log.json "
93
+ "and do not abort other targets."
94
+ ),
95
+ "next_step_tool": "corpus_emit_synthesis_briefs",
96
+ "schema_version": 1,
97
+ }
98
+
99
+
100
+ def _manual_queries(name: str, vendor: str) -> list[str]:
101
+ out = [
102
+ f'{vendor} {name} manual pdf',
103
+ f'{vendor} {name} user guide',
104
+ ]
105
+ if vendor and vendor != "unknown vendor":
106
+ out.append(f'site:{vendor.lower().split()[0]}.com {name} manual')
107
+ return out
108
+
109
+
110
+ def _technique_queries(name: str, vendor: str) -> list[str]:
111
+ return [
112
+ f'{name} sound design tutorial',
113
+ f'{name} bass patch',
114
+ f'{name} pad evolving',
115
+ f'{name} lead sound',
116
+ f'{name} preset walkthrough',
117
+ f'how to use {name}',
118
+ ]
119
+
120
+
121
+ def _comparison_queries(name: str, vendor: str) -> list[str]:
122
+ return [
123
+ f'{name} vs alternatives review',
124
+ f'{name} when to use',
125
+ f'{name} pros cons',
126
+ ]
127
+
128
+
129
+ # ─── Phase 4: synthesis brief ───────────────────────────────────────────────
130
+
131
+
132
+ def build_synthesis_brief(
133
+ plugin: DetectedPlugin,
134
+ local_manual: ManualExtraction | None = None,
135
+ research_cache_root: Path | None = None,
136
+ preset_examples: list[dict] | None = None,
137
+ output_root: Path | None = None,
138
+ ) -> dict:
139
+ """Emit a self-contained brief for a sonnet subagent to write identity.yaml.
140
+
141
+ The agent calls Anthropic-API (via Agent tool) with this brief. The brief
142
+ contains all source data the subagent needs. The subagent writes one YAML
143
+ at brief["output_path"].
144
+ """
145
+ out_root = output_root or _default_output_root()
146
+ plugin_dir = out_root / "plugins" / plugin.plugin_id
147
+ output_path = plugin_dir / "identity.yaml"
148
+
149
+ # Manual extract — either full text (capped) or a sectioned digest
150
+ manual_block: dict[str, Any] = {}
151
+ if local_manual and local_manual.text and not local_manual.error:
152
+ manual_block = {
153
+ "source_path": local_manual.source_path,
154
+ "source_kind": local_manual.source_kind,
155
+ "char_count": local_manual.char_count,
156
+ "page_count": local_manual.page_count,
157
+ "section_titles": [s["title"] for s in (local_manual.sections or [])][:30],
158
+ "text_preview": local_manual.text[:6000], # first ~1000 words
159
+ }
160
+
161
+ # Research cache — list whatever's there as URLs + previews
162
+ research_block: dict[str, Any] = {"available": False}
163
+ if research_cache_root and Path(research_cache_root).exists():
164
+ research_block = _summarize_research_cache(Path(research_cache_root))
165
+
166
+ return {
167
+ "plugin_id": plugin.plugin_id,
168
+ "synthesis_inputs": {
169
+ "identity": {
170
+ "name": plugin.name, "vendor": plugin.vendor,
171
+ "format": plugin.format, "version": plugin.version,
172
+ "unique_id": plugin.unique_id,
173
+ },
174
+ "local_manual": manual_block,
175
+ "research_cache": research_block,
176
+ "preset_examples": (preset_examples or [])[:30],
177
+ },
178
+ "synthesis_schema": {
179
+ # ── REQUIRED overlay fields (so the file is queryable via extension_atlas_search) ──
180
+ "entity_id": "EXACTLY equal to plugin_id — required for overlay indexing",
181
+ "entity_type": 'literal string "installed_plugin" — required for overlay indexing',
182
+ "name": "human-readable plugin name (same as plugin's 'name' field)",
183
+ "description": "ONE short paragraph (≤200 chars) used as the search-result summary; can be the first sentence of sonic_fingerprint",
184
+ "tags": (
185
+ "list of search tags — MUST include: 'installed-plugin', "
186
+ "'vendor:<vendor-slug>', and EXACTLY ONE 'format:<primary>' tag — "
187
+ "VST3 preferred when available, otherwise AU, otherwise AAX/VST2/CLAP/LV2. "
188
+ "Do NOT emit multiple format:* tags — the full format list goes in "
189
+ "'formats_available' for reference, but only the primary format is queryable. "
190
+ "This avoids token waste and duplicate hits when a plugin ships in 4+ formats. "
191
+ "Plus one 'genre:<slug>' per genre_affinity entry, one 'producer:<slug>' "
192
+ "per producer_anchors entry, and any signature descriptors "
193
+ "('shimmer-capable', 'self-sustaining', 'parallel-only', etc.)."
194
+ ),
195
+ "artists": "list of producer names from producer_anchors keys (overlay search ranks artist matches +50)",
196
+ # ── Plugin-specific knowledge ──
197
+ "sonic_fingerprint": "3-5 sentences describing what the plugin SOUNDS like + its signature character. Must reference concrete sonic qualities (warmth/grit/digital/analog/etc.), not just architecture.",
198
+ "reach_for": "list of bullet points: when/why a producer would pick this plugin",
199
+ "avoid": "list of bullet points: when this plugin is the wrong tool",
200
+ "key_techniques": "list of producer-oriented recipes — each ideally citing specific parameter ranges or modulation routings",
201
+ "parameter_glossary": "dict of dial → 1-line description, capped at 12 most important",
202
+ "comparable_plugins": "list of {name, when_better} entries",
203
+ "genre_affinity": "list of genre slugs (microhouse, dub_techno, hip_hop_boom_bap_lo_fi, etc.)",
204
+ "producer_anchors": "list of {producer_name: rationale} entries — only cite when the research clearly supports the mapping",
205
+ },
206
+ "agent_instructions": (
207
+ "Read synthesis_inputs in full. Write a YAML file at output_path "
208
+ "matching synthesis_schema. The first 6 fields (entity_id, entity_type, "
209
+ "name, description, tags, artists) are REQUIRED for the overlay loader "
210
+ "to index the result — without them the file is unqueryable. "
211
+ "Be concrete, not generic — every claim should be traceable to a "
212
+ "specific sentence in manual or research_cache. Do not invent producer "
213
+ "attributions; only cite anchors when the plugin's character clearly "
214
+ "maps to a producer's documented sound. Cap output at ~3000 tokens."
215
+ ),
216
+ "output_path": str(output_path),
217
+ "schema_version": 1,
218
+ }
219
+
220
+
221
+ def _summarize_research_cache(root: Path) -> dict:
222
+ """Walk a research/ cache dir and produce a manifest the brief can include."""
223
+ if not root.exists():
224
+ return {"available": False}
225
+ entries: list[dict] = []
226
+ for sub in sorted(root.iterdir()):
227
+ if not sub.is_dir():
228
+ continue
229
+ for f in sorted(sub.glob("*.txt")):
230
+ try:
231
+ preview = f.read_text(encoding="utf-8", errors="ignore")[:2000]
232
+ except OSError:
233
+ preview = ""
234
+ entries.append({
235
+ "target_type": sub.name,
236
+ "path": str(f),
237
+ "preview": preview,
238
+ })
239
+ return {
240
+ "available": bool(entries),
241
+ "entry_count": len(entries),
242
+ "entries": entries[:40], # cap so the brief stays reasonable size
243
+ }
244
+
245
+
246
+ def _default_output_root() -> Path:
247
+ return Path.home() / ".livepilot" / "atlas-overlays" / "user"