livepilot 1.9.13 → 1.9.15

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 (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,149 @@
1
+ """Reference profile builders — construct ReferenceProfile from various sources.
2
+
3
+ Pure functions, zero I/O.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .models import ReferenceProfile
9
+
10
+
11
+ # ── Audio Reference ────────────────────────────────────────────────
12
+
13
+
14
+ def build_audio_reference_profile(comparison_data: dict) -> ReferenceProfile:
15
+ """Build a ReferenceProfile from compare_to_reference output.
16
+
17
+ Args:
18
+ comparison_data: dict returned by perception engine's
19
+ compare_to_reference (keys: reference_lufs, centroid_delta_hz,
20
+ stereo_width_ref, band_deltas, suggestions, etc.)
21
+
22
+ Returns:
23
+ ReferenceProfile with source_type="audio".
24
+ """
25
+ band_deltas = comparison_data.get("band_deltas", {})
26
+
27
+ # Reconstruct approximate reference spectral contour from band deltas.
28
+ # The deltas are (mix - ref), so ref bands are conceptually the baseline.
29
+ spectral_contour: dict = {
30
+ "band_balance": band_deltas,
31
+ "centroid_delta_hz": comparison_data.get("centroid_delta_hz", 0.0),
32
+ }
33
+
34
+ width_depth: dict = {
35
+ "stereo_width": comparison_data.get("stereo_width_ref", 0.0),
36
+ }
37
+
38
+ # Extract loudness posture
39
+ loudness = comparison_data.get("reference_lufs", comparison_data.get("ref_lufs", 0.0))
40
+
41
+ return ReferenceProfile(
42
+ source_type="audio",
43
+ loudness_posture=float(loudness),
44
+ spectral_contour=spectral_contour,
45
+ width_depth=width_depth,
46
+ density_arc=[], # audio comparison doesn't provide density
47
+ section_pacing=[], # not available from offline comparison
48
+ harmonic_character="", # would need chroma analysis
49
+ transition_tendencies=[],
50
+ )
51
+
52
+
53
+ # ── Style Reference ───────────────────────────────────────────────
54
+
55
+
56
+ def build_style_reference_profile(style_tactics: list[dict]) -> ReferenceProfile:
57
+ """Build a ReferenceProfile from style tactic data.
58
+
59
+ Args:
60
+ style_tactics: list of StyleTactic.to_dict() entries from the
61
+ research engine's get_style_tactics.
62
+
63
+ Returns:
64
+ ReferenceProfile with source_type="style".
65
+ """
66
+ if not style_tactics:
67
+ return ReferenceProfile(source_type="style")
68
+
69
+ # Aggregate arrangement patterns into section_pacing
70
+ section_pacing: list[dict] = []
71
+ transition_tendencies: list[str] = []
72
+ device_names: list[str] = []
73
+
74
+ for tactic in style_tactics:
75
+ # Arrangement patterns -> section pacing
76
+ for pattern in tactic.get("arrangement_patterns", []):
77
+ section_pacing.append({
78
+ "label": pattern,
79
+ "source": tactic.get("artist_or_genre", "unknown"),
80
+ })
81
+
82
+ # Automation gestures -> transition tendencies
83
+ for gesture in tactic.get("automation_gestures", []):
84
+ if gesture not in transition_tendencies:
85
+ transition_tendencies.append(gesture)
86
+
87
+ # Collect device names for harmonic character hints
88
+ for dev in tactic.get("device_chain", []):
89
+ name = dev.get("name", "")
90
+ if name and name not in device_names:
91
+ device_names.append(name)
92
+
93
+ # Infer harmonic character from device chain
94
+ harmonic_character = _infer_harmonic_character(device_names)
95
+
96
+ # Estimate density from arrangement pattern count
97
+ density_arc = _estimate_density_from_patterns(style_tactics)
98
+
99
+ return ReferenceProfile(
100
+ source_type="style",
101
+ loudness_posture=0.0, # style doesn't specify loudness
102
+ spectral_contour={}, # style doesn't specify spectrum
103
+ width_depth={},
104
+ density_arc=density_arc,
105
+ section_pacing=section_pacing,
106
+ harmonic_character=harmonic_character,
107
+ transition_tendencies=transition_tendencies,
108
+ )
109
+
110
+
111
+ # ── Internal helpers ──────────────────────────────────────────────
112
+
113
+
114
+ def _infer_harmonic_character(device_names: list[str]) -> str:
115
+ """Heuristic: infer harmonic character from common device names."""
116
+ lower_names = [d.lower() for d in device_names]
117
+
118
+ if any("reverb" in n for n in lower_names):
119
+ if any("filter" in n for n in lower_names):
120
+ return "atmospheric_filtered"
121
+ return "spacious"
122
+ if any("saturator" in n or "overdrive" in n or "amp" in n for n in lower_names):
123
+ return "warm_harmonic"
124
+ if any("operator" in n or "wavetable" in n for n in lower_names):
125
+ return "synthetic"
126
+ return "neutral"
127
+
128
+
129
+ def _estimate_density_from_patterns(style_tactics: list[dict]) -> list[float]:
130
+ """Heuristic: estimate a density arc from arrangement patterns.
131
+
132
+ More patterns / longer structures suggest higher density.
133
+ """
134
+ if not style_tactics:
135
+ return []
136
+
137
+ densities: list[float] = []
138
+ for tactic in style_tactics:
139
+ patterns = tactic.get("arrangement_patterns", [])
140
+ # Simple heuristic: 1-2 patterns = sparse, 3+ = dense
141
+ n = len(patterns)
142
+ if n == 0:
143
+ densities.append(0.2)
144
+ elif n <= 2:
145
+ densities.append(0.4)
146
+ else:
147
+ densities.append(min(0.9, 0.3 + n * 0.15))
148
+
149
+ return densities
@@ -0,0 +1,117 @@
1
+ """Tactic router — map gaps to target engines and rank tactics.
2
+
3
+ Pure functions, zero I/O.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .models import GapEntry, GapReport, ReferencePlan
9
+
10
+
11
+ # ── Domain -> Engine mapping ───────────────────────────────────────
12
+
13
+ _DOMAIN_ENGINE_MAP: dict[str, str] = {
14
+ "spectral": "mix_engine",
15
+ "loudness": "mix_engine",
16
+ "width": "mix_engine",
17
+ "density": "composition",
18
+ "pacing": "composition",
19
+ "harmonic": "composition",
20
+ }
21
+
22
+
23
+ # ── Routing ────────────────────────────────────────────────────────
24
+
25
+
26
+ def route_to_engines(gap_report: GapReport) -> list[dict]:
27
+ """Map each relevant gap to a target engine with priority.
28
+
29
+ Returns:
30
+ list of {domain, engine, delta, tactic, priority} dicts,
31
+ sorted by priority (highest first).
32
+ """
33
+ routes: list[dict] = []
34
+
35
+ for gap in gap_report.relevant_gaps:
36
+ engine = _DOMAIN_ENGINE_MAP.get(gap.domain, "mix_engine")
37
+ priority = _compute_priority(gap)
38
+
39
+ routes.append({
40
+ "domain": gap.domain,
41
+ "engine": engine,
42
+ "delta": gap.delta,
43
+ "tactic": gap.suggested_tactic,
44
+ "priority": priority,
45
+ "identity_warning": gap.identity_warning,
46
+ })
47
+
48
+ # Sort by priority descending
49
+ routes.sort(key=lambda r: -r["priority"])
50
+ return routes
51
+
52
+
53
+ def build_reference_plan(gap_report: GapReport) -> ReferencePlan:
54
+ """Build a full ReferencePlan from a gap report.
55
+
56
+ Combines routing with ranked tactics and target engine list.
57
+ """
58
+ routes = route_to_engines(gap_report)
59
+
60
+ # Ranked tactics: each route is a tactic entry
61
+ ranked_tactics = [
62
+ {
63
+ "rank": i + 1,
64
+ "domain": r["domain"],
65
+ "tactic": r["tactic"],
66
+ "engine": r["engine"],
67
+ "priority": r["priority"],
68
+ "identity_warning": r["identity_warning"],
69
+ }
70
+ for i, r in enumerate(routes)
71
+ ]
72
+
73
+ # Unique target engines in priority order
74
+ seen: set[str] = set()
75
+ target_engines: list[str] = []
76
+ for r in routes:
77
+ if r["engine"] not in seen:
78
+ target_engines.append(r["engine"])
79
+ seen.add(r["engine"])
80
+
81
+ return ReferencePlan(
82
+ gap_report=gap_report,
83
+ ranked_tactics=ranked_tactics,
84
+ target_engines=target_engines,
85
+ )
86
+
87
+
88
+ # ── Priority scoring ──────────────────────────────────────────────
89
+
90
+
91
+ def _compute_priority(gap: GapEntry) -> float:
92
+ """Compute routing priority for a gap.
93
+
94
+ Higher = more urgent. Identity-warned gaps get deprioritized
95
+ to avoid flattening the project.
96
+ """
97
+ base = abs(gap.delta)
98
+
99
+ # Normalize different domains to comparable scales
100
+ scale = _DOMAIN_SCALE.get(gap.domain, 1.0)
101
+ normalized = min(base * scale, 1.0)
102
+
103
+ # Penalize identity-risky gaps
104
+ if gap.identity_warning:
105
+ normalized *= 0.5
106
+
107
+ return round(normalized, 3)
108
+
109
+
110
+ _DOMAIN_SCALE: dict[str, float] = {
111
+ "spectral": 10.0, # band deltas are small (0.001-0.1)
112
+ "loudness": 0.1, # LUFS deltas are large (1-10)
113
+ "width": 5.0, # width deltas moderate (0.01-0.3)
114
+ "density": 2.0, # 0-1 range
115
+ "pacing": 0.2, # section count deltas
116
+ "harmonic": 1.0, # binary (0 or 1)
117
+ }
@@ -0,0 +1,235 @@
1
+ """Reference Engine MCP tools — 3 tools for reference-aware production intelligence.
2
+
3
+ Each tool fetches data from Ableton/perception via the shared connection,
4
+ then delegates to pure-computation modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from mcp.server.fastmcp import Context
10
+
11
+ from ..server import mcp
12
+ from ..tools._research_engine import get_style_tactics
13
+ from .profile_builder import build_audio_reference_profile, build_style_reference_profile
14
+ from .gap_analyzer import analyze_gaps, classify_gap_relevance, detect_identity_warnings
15
+ from .tactic_router import build_reference_plan
16
+
17
+
18
+ # ── Helpers ────────────────────────────────────────────────────────
19
+
20
+
21
+ def _fetch_comparison_data(ctx: Context, mix_path: str, reference_path: str) -> dict:
22
+ """Run compare_to_reference via the perception engine."""
23
+ from ..tools._perception_engine import compare_to_reference
24
+
25
+ if not mix_path:
26
+ return {"error": "No mix_path provided — bounce your project first and pass the path", "code": "INVALID_PARAM"}
27
+
28
+ return compare_to_reference(mix_path, reference_path, normalize=True)
29
+
30
+
31
+ def _fetch_project_snapshot(ctx: Context) -> dict:
32
+ """Build a lightweight project snapshot for gap analysis."""
33
+ ableton = ctx.lifespan_context["ableton"]
34
+
35
+ snapshot: dict = {
36
+ "loudness": 0.0,
37
+ "spectral": {},
38
+ "width": 0.0,
39
+ "density": 0.0,
40
+ "pacing": [],
41
+ "harmonic_character": "",
42
+ }
43
+
44
+ # Try to get master RMS / loudness
45
+ try:
46
+ rms_result = ableton.send_command("get_master_rms", {})
47
+ rms = rms_result.get("rms", 0.0) if isinstance(rms_result, dict) else 0.0
48
+ # Approximate LUFS from RMS (rough heuristic)
49
+ if rms > 0:
50
+ import math
51
+ snapshot["loudness"] = round(20 * math.log10(max(rms, 1e-10)), 2)
52
+ except Exception:
53
+ pass
54
+
55
+ # Try to get spectrum data
56
+ try:
57
+ spectrum = ableton.send_command("get_master_spectrum", {})
58
+ if isinstance(spectrum, dict):
59
+ snapshot["spectral"] = spectrum
60
+ except Exception:
61
+ pass
62
+
63
+ # Try to get session info for pacing / density
64
+ try:
65
+ session_info = ableton.send_command("get_session_info", {})
66
+ track_count = session_info.get("track_count", 0)
67
+ scene_count = session_info.get("scene_count", 0)
68
+ # Rough density estimate
69
+ snapshot["density"] = min(1.0, track_count / 20.0)
70
+ snapshot["pacing"] = [{"label": f"scene_{i}", "bars": 8} for i in range(scene_count)]
71
+ except Exception:
72
+ pass
73
+
74
+ return snapshot
75
+
76
+
77
+ # ── MCP Tools ──────────────────────────────────────────────────────
78
+
79
+
80
+ @mcp.tool()
81
+ def build_reference_profile(
82
+ ctx: Context,
83
+ reference_path: str = "",
84
+ mix_path: str = "",
85
+ style: str = "",
86
+ ) -> dict:
87
+ """Build a reference profile from an audio file or style/genre name.
88
+
89
+ Provide either reference_path (for audio comparison) or style
90
+ (for style tactic lookup). If both are provided, audio takes priority.
91
+
92
+ Args:
93
+ reference_path: Absolute path to a reference audio file (.wav, .flac, .aiff).
94
+ mix_path: Absolute path to your bounced mix file (required for audio comparison).
95
+ style: Artist or genre name (e.g. "burial", "techno", "lo-fi").
96
+
97
+ Returns:
98
+ ReferenceProfile as dict with source_type, loudness_posture,
99
+ spectral_contour, width_depth, density_arc, section_pacing,
100
+ harmonic_character, transition_tendencies.
101
+ """
102
+ if reference_path:
103
+ comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
104
+ if "error" in comparison:
105
+ return comparison
106
+ profile = build_audio_reference_profile(comparison)
107
+ return profile.to_dict()
108
+
109
+ if style:
110
+ tactics = get_style_tactics(style)
111
+ if not tactics:
112
+ return {
113
+ "error": f"No style tactics found for '{style}'",
114
+ "code": "NOT_FOUND",
115
+ }
116
+ tactic_dicts = [t.to_dict() for t in tactics]
117
+ profile = build_style_reference_profile(tactic_dicts)
118
+ return profile.to_dict()
119
+
120
+ return {
121
+ "error": "Provide either reference_path or style",
122
+ "code": "INVALID_PARAM",
123
+ }
124
+
125
+
126
+ @mcp.tool()
127
+ def analyze_reference_gaps(
128
+ ctx: Context,
129
+ reference_path: str = "",
130
+ mix_path: str = "",
131
+ style: str = "",
132
+ goal_dimensions: str = "",
133
+ ) -> dict:
134
+ """Analyze gaps between your project and a reference.
135
+
136
+ Computes deltas across spectral, loudness, width, density, pacing,
137
+ and harmonic domains. Flags which gaps are relevant and which
138
+ would destroy your project's identity if closed.
139
+
140
+ Args:
141
+ reference_path: Absolute path to a reference audio file.
142
+ mix_path: Absolute path to your bounced mix file (required for audio comparison).
143
+ style: Artist or genre name for style-based comparison.
144
+ goal_dimensions: Comma-separated domains to focus on
145
+ (e.g. "spectral,width"). Empty = all domains.
146
+
147
+ Returns:
148
+ GapReport as dict with gaps, relevant_gaps, identity_warnings,
149
+ and overall_distance.
150
+ """
151
+ # Build reference profile
152
+ if reference_path:
153
+ comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
154
+ if "error" in comparison:
155
+ return comparison
156
+ profile = build_audio_reference_profile(comparison)
157
+ elif style:
158
+ tactics = get_style_tactics(style)
159
+ if not tactics:
160
+ return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
161
+ tactic_dicts = [t.to_dict() for t in tactics]
162
+ profile = build_style_reference_profile(tactic_dicts)
163
+ else:
164
+ return {"error": "Provide either reference_path or style", "code": "INVALID_PARAM"}
165
+
166
+ # Build project snapshot
167
+ snapshot = _fetch_project_snapshot(ctx)
168
+
169
+ # Analyze gaps
170
+ gap_report = analyze_gaps(snapshot, profile)
171
+
172
+ # Reclassify relevance if user specified goal dimensions
173
+ if goal_dimensions:
174
+ dims = [d.strip() for d in goal_dimensions.split(",") if d.strip()]
175
+ for gap in gap_report.gaps:
176
+ gap.relevant = classify_gap_relevance(gap, dims)
177
+
178
+ return gap_report.to_dict()
179
+
180
+
181
+ @mcp.tool()
182
+ def plan_reference_moves(
183
+ ctx: Context,
184
+ reference_path: str = "",
185
+ mix_path: str = "",
186
+ style: str = "",
187
+ goal_dimensions: str = "",
188
+ ) -> dict:
189
+ """Plan concrete moves to close reference gaps.
190
+
191
+ Builds a reference profile, analyzes gaps, then routes each gap
192
+ to the appropriate engine (mix_engine or composition) with
193
+ ranked tactics and identity warnings.
194
+
195
+ Args:
196
+ reference_path: Absolute path to a reference audio file.
197
+ mix_path: Absolute path to your bounced mix file (required for audio comparison).
198
+ style: Artist or genre name for style-based comparison.
199
+ goal_dimensions: Comma-separated domains to focus on.
200
+
201
+ Returns:
202
+ ReferencePlan as dict with gap_report, ranked_tactics,
203
+ and target_engines.
204
+ """
205
+ # Build reference profile
206
+ if reference_path:
207
+ comparison = _fetch_comparison_data(ctx, mix_path, reference_path)
208
+ if "error" in comparison:
209
+ return comparison
210
+ profile = build_audio_reference_profile(comparison)
211
+ elif style:
212
+ tactics = get_style_tactics(style)
213
+ if not tactics:
214
+ return {"error": f"No style tactics found for '{style}'", "code": "NOT_FOUND"}
215
+ tactic_dicts = [t.to_dict() for t in tactics]
216
+ profile = build_style_reference_profile(tactic_dicts)
217
+ else:
218
+ return {"error": "Provide either reference_path or style", "code": "INVALID_PARAM"}
219
+
220
+ # Build project snapshot
221
+ snapshot = _fetch_project_snapshot(ctx)
222
+
223
+ # Analyze gaps
224
+ gap_report = analyze_gaps(snapshot, profile)
225
+
226
+ # Reclassify relevance if user specified goal dimensions
227
+ if goal_dimensions:
228
+ dims = [d.strip() for d in goal_dimensions.split(",") if d.strip()]
229
+ for gap in gap_report.gaps:
230
+ gap.relevant = classify_gap_relevance(gap, dims)
231
+
232
+ # Build plan
233
+ plan = build_reference_plan(gap_report)
234
+
235
+ return plan.to_dict()
@@ -0,0 +1 @@
1
+ """Runtime subsystem — capability state, action ledger."""
@@ -0,0 +1,117 @@
1
+ """SessionLedger — in-memory store for semantic moves.
2
+
3
+ Pure Python, zero I/O. The ledger tracks every agent-initiated mutation
4
+ as a *semantic move* that can be debugged, undone, or promoted to memory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import OrderedDict
10
+ from typing import Optional
11
+
12
+ from .action_ledger_models import LedgerEntry, UndoGroup
13
+
14
+
15
+ class SessionLedger:
16
+ """Per-session record of semantic moves."""
17
+
18
+ def __init__(self) -> None:
19
+ self._entries: OrderedDict[str, LedgerEntry] = OrderedDict()
20
+
21
+ # ── lifecycle ──────────────────────────────────────────────────────
22
+
23
+ def start_move(
24
+ self,
25
+ engine: str,
26
+ move_class: str,
27
+ intent: str,
28
+ undo_scope: str = "micro",
29
+ ) -> str:
30
+ """Create a new LedgerEntry and return its id."""
31
+ entry = LedgerEntry(
32
+ engine=engine,
33
+ move_class=move_class,
34
+ intent=intent,
35
+ undo_scope=undo_scope,
36
+ )
37
+ self._entries[entry.id] = entry
38
+ return entry.id
39
+
40
+ def append_action(
41
+ self, entry_id: str, tool_name: str, summary: str
42
+ ) -> None:
43
+ """Add a tool-call record to an existing entry."""
44
+ entry = self._entries.get(entry_id)
45
+ if entry is None:
46
+ raise KeyError(f"No ledger entry with id {entry_id!r}")
47
+ entry.actions.append({"tool": tool_name, "summary": summary})
48
+
49
+ def set_before_refs(self, entry_id: str, refs: dict) -> None:
50
+ entry = self._entries.get(entry_id)
51
+ if entry is None:
52
+ raise KeyError(f"No ledger entry with id {entry_id!r}")
53
+ entry.before_refs = refs
54
+
55
+ def set_after_refs(self, entry_id: str, refs: dict) -> None:
56
+ entry = self._entries.get(entry_id)
57
+ if entry is None:
58
+ raise KeyError(f"No ledger entry with id {entry_id!r}")
59
+ entry.after_refs = refs
60
+
61
+ def finalize_move(
62
+ self,
63
+ entry_id: str,
64
+ kept: bool = True,
65
+ score: float = 0.0,
66
+ memory_candidate: bool = False,
67
+ ) -> None:
68
+ """Seal a move with its evaluation outcome."""
69
+ entry = self._entries.get(entry_id)
70
+ if entry is None:
71
+ raise KeyError(f"No ledger entry with id {entry_id!r}")
72
+ entry.kept = kept
73
+ entry.score = score
74
+ entry.memory_candidate = memory_candidate
75
+
76
+ # ── queries ────────────────────────────────────────────────────────
77
+
78
+ def get_entry(self, entry_id: str) -> Optional[LedgerEntry]:
79
+ return self._entries.get(entry_id)
80
+
81
+ def get_last_move(self) -> Optional[LedgerEntry]:
82
+ if not self._entries:
83
+ return None
84
+ # OrderedDict preserves insertion order — last item is newest
85
+ return next(reversed(self._entries.values()))
86
+
87
+ def get_recent_moves(
88
+ self,
89
+ limit: int = 10,
90
+ engine: Optional[str] = None,
91
+ kept: Optional[bool] = None,
92
+ ) -> list[LedgerEntry]:
93
+ """Return recent moves, newest first, with optional filters."""
94
+ results: list[LedgerEntry] = []
95
+ for entry in reversed(self._entries.values()):
96
+ if engine is not None and entry.engine != engine:
97
+ continue
98
+ if kept is not None and entry.kept != kept:
99
+ continue
100
+ results.append(entry)
101
+ if len(results) >= limit:
102
+ break
103
+ return results
104
+
105
+ def get_memory_candidates(self) -> list[LedgerEntry]:
106
+ """Return all entries flagged as memory promotion candidates."""
107
+ return [e for e in self._entries.values() if e.memory_candidate]
108
+
109
+ def get_undo_groups(self) -> list[UndoGroup]:
110
+ """Group entries by undo_scope."""
111
+ groups: dict[str, list[str]] = {}
112
+ for entry in self._entries.values():
113
+ groups.setdefault(entry.undo_scope, []).append(entry.id)
114
+ return [
115
+ UndoGroup(scope=scope, entry_ids=ids)
116
+ for scope, ids in groups.items()
117
+ ]