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.
- package/CHANGELOG.md +124 -0
- package/README.md +108 -10
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +39 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/cross_pack_chain.py +658 -0
- package/mcp_server/atlas/demo_story.py +700 -0
- package/mcp_server/atlas/extract_chain.py +786 -0
- package/mcp_server/atlas/macro_fingerprint.py +554 -0
- package/mcp_server/atlas/overlays.py +95 -3
- package/mcp_server/atlas/pack_aware_compose.py +1255 -0
- package/mcp_server/atlas/preset_resolver.py +238 -0
- package/mcp_server/atlas/tools.py +1001 -31
- package/mcp_server/atlas/transplant.py +1177 -0
- package/mcp_server/mix_engine/state_builder.py +44 -1
- package/mcp_server/runtime/capability_state.py +34 -3
- package/mcp_server/runtime/remote_commands.py +10 -0
- package/mcp_server/server.py +45 -24
- package/mcp_server/tools/agent_os.py +33 -9
- package/mcp_server/tools/analyzer.py +84 -23
- package/mcp_server/tools/browser.py +20 -1
- package/mcp_server/tools/devices.py +78 -11
- package/mcp_server/tools/perception.py +5 -1
- package/mcp_server/tools/tracks.py +39 -2
- package/mcp_server/user_corpus/__init__.py +48 -0
- package/mcp_server/user_corpus/manifest.py +142 -0
- package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
- package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
- package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
- package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
- package/mcp_server/user_corpus/runner.py +261 -0
- package/mcp_server/user_corpus/scanner.py +115 -0
- package/mcp_server/user_corpus/scanners/__init__.py +18 -0
- package/mcp_server/user_corpus/scanners/adg.py +79 -0
- package/mcp_server/user_corpus/scanners/als.py +144 -0
- package/mcp_server/user_corpus/scanners/amxd.py +374 -0
- package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
- package/mcp_server/user_corpus/tools.py +904 -0
- package/mcp_server/user_corpus/wizard.py +224 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +7 -2
- package/remote_script/LivePilot/devices.py +9 -0
- package/remote_script/LivePilot/simpler_sample.py +98 -0
- package/requirements.txt +3 -3
- 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"
|