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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +2 -1
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +214 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +162 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +80 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +235 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +64 -0
- package/mcp_server/server.py +23 -1
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +18 -3
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/requirements.txt +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Data models for the Action Ledger — semantic move tracking.
|
|
2
|
+
|
|
3
|
+
Classes:
|
|
4
|
+
LedgerEntry — one semantic move with intent, scope, actions, evaluation
|
|
5
|
+
UndoGroup — group of entries sharing an undo scope
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Module-level counter for auto-generating IDs.
|
|
16
|
+
_counter: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _next_id() -> str:
|
|
20
|
+
global _counter
|
|
21
|
+
_counter += 1
|
|
22
|
+
return f"move_{_counter:04d}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class LedgerEntry:
|
|
27
|
+
"""One semantic move in the session ledger."""
|
|
28
|
+
|
|
29
|
+
engine: str
|
|
30
|
+
move_class: str
|
|
31
|
+
intent: str
|
|
32
|
+
undo_scope: str = "micro"
|
|
33
|
+
|
|
34
|
+
# Auto-generated
|
|
35
|
+
id: str = field(default_factory=_next_id)
|
|
36
|
+
timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
37
|
+
|
|
38
|
+
# Scope — which tracks / clips / devices are affected
|
|
39
|
+
scope: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
# Low-level tool calls within this move
|
|
42
|
+
actions: list[dict] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
# Snapshot references
|
|
45
|
+
before_refs: dict = field(default_factory=dict)
|
|
46
|
+
after_refs: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
# Evaluation
|
|
49
|
+
evaluation: dict = field(default_factory=dict)
|
|
50
|
+
kept: Optional[bool] = None
|
|
51
|
+
score: float = 0.0
|
|
52
|
+
memory_candidate: bool = False
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
return {
|
|
56
|
+
"id": self.id,
|
|
57
|
+
"timestamp_ms": self.timestamp_ms,
|
|
58
|
+
"engine": self.engine,
|
|
59
|
+
"move_class": self.move_class,
|
|
60
|
+
"intent": self.intent,
|
|
61
|
+
"scope": self.scope,
|
|
62
|
+
"actions": list(self.actions),
|
|
63
|
+
"before_refs": dict(self.before_refs),
|
|
64
|
+
"after_refs": dict(self.after_refs),
|
|
65
|
+
"evaluation": dict(self.evaluation),
|
|
66
|
+
"kept": self.kept,
|
|
67
|
+
"score": self.score,
|
|
68
|
+
"undo_scope": self.undo_scope,
|
|
69
|
+
"memory_candidate": self.memory_candidate,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class UndoGroup:
|
|
75
|
+
"""A group of ledger entries sharing an undo scope."""
|
|
76
|
+
|
|
77
|
+
scope: str
|
|
78
|
+
entry_ids: list[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
def to_dict(self) -> dict:
|
|
81
|
+
return {
|
|
82
|
+
"scope": self.scope,
|
|
83
|
+
"entry_ids": list(self.entry_ids),
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MCP tool wrappers for the Action Ledger.
|
|
2
|
+
|
|
3
|
+
Tools:
|
|
4
|
+
get_action_ledger_summary — recent moves, counts, memory candidates
|
|
5
|
+
get_last_move — most recent semantic move
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastmcp import Context
|
|
11
|
+
|
|
12
|
+
from ..server import mcp
|
|
13
|
+
from .action_ledger import SessionLedger
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_ledger(ctx: Context) -> SessionLedger:
|
|
17
|
+
"""Return the session-scoped ledger singleton."""
|
|
18
|
+
return ctx.lifespan_context.setdefault(
|
|
19
|
+
"action_ledger", SessionLedger()
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
def get_action_ledger_summary(
|
|
25
|
+
ctx: Context, limit: int = 10, engine: str = ""
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Return a summary of recent semantic moves from the action ledger.
|
|
28
|
+
|
|
29
|
+
Includes move count, last move, recent moves (newest first),
|
|
30
|
+
and number of memory promotion candidates.
|
|
31
|
+
"""
|
|
32
|
+
ledger = _get_ledger(ctx)
|
|
33
|
+
eng = engine if engine else None
|
|
34
|
+
recent = ledger.get_recent_moves(limit=limit, engine=eng)
|
|
35
|
+
last = ledger.get_last_move()
|
|
36
|
+
candidates = ledger.get_memory_candidates()
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"total_moves": len(ledger._entries),
|
|
40
|
+
"memory_candidate_count": len(candidates),
|
|
41
|
+
"last_move": last.to_dict() if last else None,
|
|
42
|
+
"recent_moves": [e.to_dict() for e in recent],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
def get_last_move(ctx: Context) -> dict:
|
|
48
|
+
"""Return the most recent semantic move from the action ledger.
|
|
49
|
+
|
|
50
|
+
Returns the full ledger entry including intent, scope, actions,
|
|
51
|
+
evaluation, and undo scope. Returns an empty dict if no moves exist.
|
|
52
|
+
"""
|
|
53
|
+
ledger = _get_ledger(ctx)
|
|
54
|
+
entry = ledger.get_last_move()
|
|
55
|
+
if entry is None:
|
|
56
|
+
return {}
|
|
57
|
+
return entry.to_dict()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Capability State v1 — unified runtime capability model.
|
|
2
|
+
|
|
3
|
+
Defines the shared data model that tells engines what can and can't be
|
|
4
|
+
trusted right now. Pure Python, zero I/O — all probing happens in the
|
|
5
|
+
MCP tool wrapper (runtime/tools.py).
|
|
6
|
+
|
|
7
|
+
Design: docs/specs/v2-engine-specs/CAPABILITY_STATE_V1.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Domain Model ────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CapabilityDomain:
|
|
21
|
+
"""A single capability domain's runtime status."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
available: bool
|
|
25
|
+
confidence: float # 0.0–1.0
|
|
26
|
+
freshness_ms: Optional[int] = None
|
|
27
|
+
mode: str = "unavailable"
|
|
28
|
+
reasons: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
if not 0.0 <= self.confidence <= 1.0:
|
|
32
|
+
raise ValueError(f"confidence must be 0.0–1.0, got {self.confidence}")
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Capability State ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CapabilityState:
|
|
42
|
+
"""Snapshot of all capability domains at a point in time."""
|
|
43
|
+
|
|
44
|
+
generated_at_ms: int
|
|
45
|
+
overall_mode: str # normal | measured_degraded | judgment_only | read_only
|
|
46
|
+
domains: dict[str, CapabilityDomain] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
# ── Query helpers ───────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def can_use_measured_evaluation(self) -> bool:
|
|
51
|
+
"""True when analyzer data is available and fresh enough to trust."""
|
|
52
|
+
analyzer = self.domains.get("analyzer")
|
|
53
|
+
if analyzer is None:
|
|
54
|
+
return False
|
|
55
|
+
return analyzer.available and analyzer.confidence >= 0.5
|
|
56
|
+
|
|
57
|
+
def can_run_research(self, mode: str = "targeted") -> bool:
|
|
58
|
+
"""Check if the requested research mode is available.
|
|
59
|
+
|
|
60
|
+
- 'targeted' — always true if session or memory is up
|
|
61
|
+
- 'deep' — requires web access
|
|
62
|
+
"""
|
|
63
|
+
if mode == "targeted":
|
|
64
|
+
session = self.domains.get("session_access")
|
|
65
|
+
memory = self.domains.get("memory")
|
|
66
|
+
if session and session.available:
|
|
67
|
+
return True
|
|
68
|
+
if memory and memory.available:
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
if mode == "deep":
|
|
73
|
+
web = self.domains.get("web")
|
|
74
|
+
return web is not None and web.available
|
|
75
|
+
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def to_dict(self) -> dict:
|
|
79
|
+
return {
|
|
80
|
+
"capability_state": {
|
|
81
|
+
"generated_at_ms": self.generated_at_ms,
|
|
82
|
+
"overall_mode": self.overall_mode,
|
|
83
|
+
"domains": {
|
|
84
|
+
name: domain.to_dict()
|
|
85
|
+
for name, domain in self.domains.items()
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Builder ─────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def build_capability_state(
|
|
94
|
+
*,
|
|
95
|
+
session_ok: bool = False,
|
|
96
|
+
analyzer_ok: bool = False,
|
|
97
|
+
analyzer_fresh: bool = False,
|
|
98
|
+
memory_ok: bool = False,
|
|
99
|
+
web_ok: bool = False,
|
|
100
|
+
flucoma_ok: bool = False,
|
|
101
|
+
) -> CapabilityState:
|
|
102
|
+
"""Build a CapabilityState from simple boolean probes.
|
|
103
|
+
|
|
104
|
+
Pure function — no I/O. The caller is responsible for probing
|
|
105
|
+
Ableton, the analyzer bridge, memory store, etc.
|
|
106
|
+
"""
|
|
107
|
+
domains: dict[str, CapabilityDomain] = {}
|
|
108
|
+
|
|
109
|
+
# ── session_access ──────────────────────────────────────────────
|
|
110
|
+
session_reasons: list[str] = []
|
|
111
|
+
if not session_ok:
|
|
112
|
+
session_reasons.append("session_unreachable")
|
|
113
|
+
domains["session_access"] = CapabilityDomain(
|
|
114
|
+
name="session_access",
|
|
115
|
+
available=session_ok,
|
|
116
|
+
confidence=1.0 if session_ok else 0.0,
|
|
117
|
+
mode="healthy" if session_ok else "unavailable",
|
|
118
|
+
reasons=session_reasons,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ── analyzer ────────────────────────────────────────────────────
|
|
122
|
+
analyzer_reasons: list[str] = []
|
|
123
|
+
if not analyzer_ok:
|
|
124
|
+
analyzer_reasons.append("analyzer_offline")
|
|
125
|
+
elif not analyzer_fresh:
|
|
126
|
+
analyzer_reasons.append("analyzer_stale")
|
|
127
|
+
analyzer_available = analyzer_ok and analyzer_fresh
|
|
128
|
+
if analyzer_available:
|
|
129
|
+
analyzer_conf = 0.9
|
|
130
|
+
analyzer_mode = "measured"
|
|
131
|
+
elif analyzer_ok:
|
|
132
|
+
analyzer_conf = 0.4
|
|
133
|
+
analyzer_mode = "stale"
|
|
134
|
+
else:
|
|
135
|
+
analyzer_conf = 0.0
|
|
136
|
+
analyzer_mode = "unavailable"
|
|
137
|
+
domains["analyzer"] = CapabilityDomain(
|
|
138
|
+
name="analyzer",
|
|
139
|
+
available=analyzer_available,
|
|
140
|
+
confidence=analyzer_conf,
|
|
141
|
+
mode=analyzer_mode,
|
|
142
|
+
reasons=analyzer_reasons,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# ── memory ──────────────────────────────────────────────────────
|
|
146
|
+
memory_reasons: list[str] = []
|
|
147
|
+
if not memory_ok:
|
|
148
|
+
memory_reasons.append("memory_unavailable")
|
|
149
|
+
domains["memory"] = CapabilityDomain(
|
|
150
|
+
name="memory",
|
|
151
|
+
available=memory_ok,
|
|
152
|
+
confidence=1.0 if memory_ok else 0.0,
|
|
153
|
+
mode="available" if memory_ok else "unavailable",
|
|
154
|
+
reasons=memory_reasons,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# ── web ──────────────────────────────────────────────────────────
|
|
158
|
+
web_reasons: list[str] = []
|
|
159
|
+
if not web_ok:
|
|
160
|
+
web_reasons.append("web_unavailable")
|
|
161
|
+
domains["web"] = CapabilityDomain(
|
|
162
|
+
name="web",
|
|
163
|
+
available=web_ok,
|
|
164
|
+
confidence=0.7 if web_ok else 0.0,
|
|
165
|
+
mode="available" if web_ok else "unavailable",
|
|
166
|
+
reasons=web_reasons,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ── research (composite) ────────────────────────────────────────
|
|
170
|
+
research_reasons: list[str] = []
|
|
171
|
+
research_sources = 0
|
|
172
|
+
if session_ok:
|
|
173
|
+
research_sources += 1
|
|
174
|
+
else:
|
|
175
|
+
research_reasons.append("session_unavailable")
|
|
176
|
+
if memory_ok:
|
|
177
|
+
research_sources += 1
|
|
178
|
+
else:
|
|
179
|
+
research_reasons.append("memory_unavailable")
|
|
180
|
+
if web_ok:
|
|
181
|
+
research_sources += 1
|
|
182
|
+
else:
|
|
183
|
+
research_reasons.append("web_unavailable")
|
|
184
|
+
|
|
185
|
+
if research_sources >= 3:
|
|
186
|
+
research_mode = "full"
|
|
187
|
+
research_conf = 1.0
|
|
188
|
+
elif research_sources >= 1:
|
|
189
|
+
research_mode = "targeted_only"
|
|
190
|
+
research_conf = 0.5 + 0.2 * research_sources
|
|
191
|
+
else:
|
|
192
|
+
research_mode = "unavailable"
|
|
193
|
+
research_conf = 0.0
|
|
194
|
+
|
|
195
|
+
domains["research"] = CapabilityDomain(
|
|
196
|
+
name="research",
|
|
197
|
+
available=research_sources >= 1,
|
|
198
|
+
confidence=round(research_conf, 2),
|
|
199
|
+
mode=research_mode,
|
|
200
|
+
reasons=research_reasons,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# ── Overall mode ────────────────────────────────────────────────
|
|
204
|
+
if session_ok and analyzer_ok and analyzer_fresh:
|
|
205
|
+
overall_mode = "normal"
|
|
206
|
+
elif session_ok and not analyzer_ok:
|
|
207
|
+
overall_mode = "measured_degraded"
|
|
208
|
+
elif session_ok:
|
|
209
|
+
# session_ok, analyzer_ok but not fresh
|
|
210
|
+
overall_mode = "judgment_only"
|
|
211
|
+
else:
|
|
212
|
+
overall_mode = "read_only"
|
|
213
|
+
|
|
214
|
+
return CapabilityState(
|
|
215
|
+
generated_at_ms=int(time.time() * 1000),
|
|
216
|
+
overall_mode=overall_mode,
|
|
217
|
+
domains=domains,
|
|
218
|
+
)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Safety Kernel — policy enforcement layer for LivePilot.
|
|
2
|
+
|
|
3
|
+
Pure Python, zero I/O. Validates proposed actions against policies
|
|
4
|
+
before they execute. MCP tool wrappers call ``check_action_safety``
|
|
5
|
+
before executing mutations.
|
|
6
|
+
|
|
7
|
+
Policies
|
|
8
|
+
--------
|
|
9
|
+
* **Blocked** — bulk-destructive operations that are never safe to run
|
|
10
|
+
automatically (delete_all_tracks, clear_all_automation, …).
|
|
11
|
+
* **Confirm required** — single-item destructive ops where the user
|
|
12
|
+
should be prompted before proceeding.
|
|
13
|
+
* **Scope check** — mutations affecting more than 5 tracks at once
|
|
14
|
+
are flagged as *caution*.
|
|
15
|
+
* **Capability gating** — when the runtime is in *read_only* mode,
|
|
16
|
+
all mutations are blocked.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, asdict
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Data types ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SafetyCheck:
|
|
29
|
+
"""Result of evaluating a proposed action against safety policies."""
|
|
30
|
+
|
|
31
|
+
action: str # what was proposed
|
|
32
|
+
allowed: bool # can it proceed?
|
|
33
|
+
risk_level: str # "safe", "caution", "blocked"
|
|
34
|
+
reason: str # why blocked / cautioned
|
|
35
|
+
requires_confirmation: bool # should we ask the user first?
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return asdict(self)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Policy sets ────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
BLOCKED_ACTIONS: set[str] = {
|
|
44
|
+
"delete_all_tracks",
|
|
45
|
+
"delete_all_clips",
|
|
46
|
+
"delete_all_scenes",
|
|
47
|
+
"clear_all_automation",
|
|
48
|
+
"reset_all_devices",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
CONFIRM_REQUIRED_ACTIONS: set[str] = {
|
|
52
|
+
"delete_track",
|
|
53
|
+
"delete_clip",
|
|
54
|
+
"delete_scene",
|
|
55
|
+
"flatten_track",
|
|
56
|
+
"replace_simpler_sample",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Read-only prefixes — any action starting with one of these is a read.
|
|
60
|
+
_READ_ONLY_PREFIXES = (
|
|
61
|
+
"get_",
|
|
62
|
+
"analyze_",
|
|
63
|
+
"identify_",
|
|
64
|
+
"detect_",
|
|
65
|
+
"check_",
|
|
66
|
+
"search_",
|
|
67
|
+
"compare_",
|
|
68
|
+
"read_",
|
|
69
|
+
"classify_",
|
|
70
|
+
"find_",
|
|
71
|
+
"walk_",
|
|
72
|
+
"list_",
|
|
73
|
+
"export_",
|
|
74
|
+
"extract_",
|
|
75
|
+
"build_world_model",
|
|
76
|
+
"compile_goal_vector",
|
|
77
|
+
"evaluate_",
|
|
78
|
+
"memory_list",
|
|
79
|
+
"memory_get",
|
|
80
|
+
"memory_recall",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Explicit safe actions (full list for fast-path lookup).
|
|
84
|
+
SAFE_ACTIONS: set[str] = {
|
|
85
|
+
"get_session_info",
|
|
86
|
+
"get_track_info",
|
|
87
|
+
"get_notes",
|
|
88
|
+
"get_device_parameters",
|
|
89
|
+
"get_master_spectrum",
|
|
90
|
+
"get_master_rms",
|
|
91
|
+
"get_detected_key",
|
|
92
|
+
"get_clip_info",
|
|
93
|
+
"get_scenes_info",
|
|
94
|
+
"get_arrangement_clips",
|
|
95
|
+
"get_arrangement_notes",
|
|
96
|
+
"get_clip_automation",
|
|
97
|
+
"get_automation_recipes",
|
|
98
|
+
"get_browser_tree",
|
|
99
|
+
"get_browser_items",
|
|
100
|
+
"get_return_tracks",
|
|
101
|
+
"get_master_track",
|
|
102
|
+
"get_mix_snapshot",
|
|
103
|
+
"get_track_routing",
|
|
104
|
+
"get_track_meters",
|
|
105
|
+
"get_master_meters",
|
|
106
|
+
"get_device_info",
|
|
107
|
+
"get_device_presets",
|
|
108
|
+
"get_rack_chains",
|
|
109
|
+
"get_freeze_status",
|
|
110
|
+
"get_scene_matrix",
|
|
111
|
+
"get_playing_clips",
|
|
112
|
+
"get_cue_points",
|
|
113
|
+
"get_hidden_parameters",
|
|
114
|
+
"get_automation_state",
|
|
115
|
+
"get_display_values",
|
|
116
|
+
"get_warp_markers",
|
|
117
|
+
"get_clip_file_path",
|
|
118
|
+
"get_simpler_slices",
|
|
119
|
+
"get_spectral_shape",
|
|
120
|
+
"get_mel_spectrum",
|
|
121
|
+
"get_chroma",
|
|
122
|
+
"get_onsets",
|
|
123
|
+
"get_novelty",
|
|
124
|
+
"get_momentary_loudness",
|
|
125
|
+
"get_recent_actions",
|
|
126
|
+
"get_session_diagnostics",
|
|
127
|
+
"get_plugin_parameters",
|
|
128
|
+
"get_plugin_presets",
|
|
129
|
+
"analyze_harmony",
|
|
130
|
+
"analyze_composition",
|
|
131
|
+
"analyze_loudness",
|
|
132
|
+
"analyze_spectrum_offline",
|
|
133
|
+
"analyze_midi_file",
|
|
134
|
+
"analyze_for_automation",
|
|
135
|
+
"analyze_outcomes",
|
|
136
|
+
"identify_scale",
|
|
137
|
+
"detect_theory_issues",
|
|
138
|
+
"compare_to_reference",
|
|
139
|
+
"read_audio_metadata",
|
|
140
|
+
"check_flucoma",
|
|
141
|
+
"search_browser",
|
|
142
|
+
"classify_progression",
|
|
143
|
+
"find_voice_leading_path",
|
|
144
|
+
"walk_device_tree",
|
|
145
|
+
"build_world_model",
|
|
146
|
+
"compile_goal_vector",
|
|
147
|
+
"evaluate_move",
|
|
148
|
+
"evaluate_composition_move",
|
|
149
|
+
"memory_list",
|
|
150
|
+
"memory_get",
|
|
151
|
+
"memory_recall",
|
|
152
|
+
"extract_piano_roll",
|
|
153
|
+
"export_clip_midi",
|
|
154
|
+
"get_section_graph",
|
|
155
|
+
"get_phrase_grid",
|
|
156
|
+
"get_harmony_field",
|
|
157
|
+
"get_transition_analysis",
|
|
158
|
+
"get_section_outcomes",
|
|
159
|
+
"get_motif_graph",
|
|
160
|
+
"get_technique_card",
|
|
161
|
+
"get_emotional_arc",
|
|
162
|
+
"get_style_tactics",
|
|
163
|
+
"research_technique",
|
|
164
|
+
"get_capability_state",
|
|
165
|
+
"get_action_ledger_summary",
|
|
166
|
+
"get_last_move",
|
|
167
|
+
"get_taste_profile",
|
|
168
|
+
"evaluate_with_fabric",
|
|
169
|
+
"get_anti_preferences",
|
|
170
|
+
"get_promotion_candidates",
|
|
171
|
+
"analyze_mix",
|
|
172
|
+
"get_mix_issues",
|
|
173
|
+
"get_mix_summary",
|
|
174
|
+
"get_masking_report",
|
|
175
|
+
"analyze_sound_design",
|
|
176
|
+
"get_sound_design_issues",
|
|
177
|
+
"get_patch_model",
|
|
178
|
+
"analyze_transition",
|
|
179
|
+
"score_transition",
|
|
180
|
+
"build_reference_profile",
|
|
181
|
+
"analyze_reference_gaps",
|
|
182
|
+
"check_translation",
|
|
183
|
+
"get_translation_issues",
|
|
184
|
+
"get_performance_state",
|
|
185
|
+
"get_performance_safe_moves",
|
|
186
|
+
"get_project_brain_summary",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Scope limits per capability mode ──────────────────────────────
|
|
191
|
+
|
|
192
|
+
_MAX_SCOPE: dict[str, dict] = {
|
|
193
|
+
"normal": {"max_tracks": 0}, # 0 = unlimited
|
|
194
|
+
"measured_degraded": {"max_tracks": 3},
|
|
195
|
+
"judgment_only": {"max_tracks": 1},
|
|
196
|
+
"read_only": {"max_tracks": 0}, # mutations blocked entirely
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_WIDE_SCOPE_THRESHOLD = 5 # tracks
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Public API ─────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
def is_read_only_action(action: str) -> bool:
|
|
205
|
+
"""Return True if *action* is a non-mutating read/query."""
|
|
206
|
+
if action in SAFE_ACTIONS:
|
|
207
|
+
return True
|
|
208
|
+
return action.startswith(_READ_ONLY_PREFIXES)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_max_safe_scope(capability_mode: str) -> dict:
|
|
212
|
+
"""Return the maximum allowed scope for *capability_mode*.
|
|
213
|
+
|
|
214
|
+
Keys:
|
|
215
|
+
max_tracks — 0 means unlimited.
|
|
216
|
+
"""
|
|
217
|
+
return dict(_MAX_SCOPE.get(capability_mode, _MAX_SCOPE["normal"]))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def check_action_safety(
|
|
221
|
+
action: str,
|
|
222
|
+
scope: Optional[dict] = None,
|
|
223
|
+
capability_state: Optional[dict] = None,
|
|
224
|
+
) -> SafetyCheck:
|
|
225
|
+
"""Validate a proposed action against safety policies.
|
|
226
|
+
|
|
227
|
+
Parameters
|
|
228
|
+
----------
|
|
229
|
+
action:
|
|
230
|
+
Tool / command name (e.g. ``"delete_track"``).
|
|
231
|
+
scope:
|
|
232
|
+
Optional dict describing what the action will affect.
|
|
233
|
+
Recognised key: ``track_count`` (int).
|
|
234
|
+
capability_state:
|
|
235
|
+
Optional dict with at least a ``"mode"`` key
|
|
236
|
+
(``"normal"``, ``"read_only"``, …).
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
SafetyCheck
|
|
241
|
+
"""
|
|
242
|
+
scope = scope or {}
|
|
243
|
+
capability_state = capability_state or {}
|
|
244
|
+
mode = capability_state.get("mode", "normal")
|
|
245
|
+
|
|
246
|
+
# 1. Blocked actions — always refused.
|
|
247
|
+
if action in BLOCKED_ACTIONS:
|
|
248
|
+
return SafetyCheck(
|
|
249
|
+
action=action,
|
|
250
|
+
allowed=False,
|
|
251
|
+
risk_level="blocked",
|
|
252
|
+
reason=f"'{action}' is a bulk-destructive operation and is blocked by policy.",
|
|
253
|
+
requires_confirmation=False,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# 2. Capability gating — read_only blocks all mutations.
|
|
257
|
+
if mode == "read_only" and not is_read_only_action(action):
|
|
258
|
+
return SafetyCheck(
|
|
259
|
+
action=action,
|
|
260
|
+
allowed=False,
|
|
261
|
+
risk_level="blocked",
|
|
262
|
+
reason="System is in read_only mode — mutations are not allowed.",
|
|
263
|
+
requires_confirmation=False,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# 3. Scope-based gating for degraded modes.
|
|
267
|
+
track_count = scope.get("track_count", 1)
|
|
268
|
+
max_scope = get_max_safe_scope(mode)
|
|
269
|
+
max_tracks = max_scope["max_tracks"]
|
|
270
|
+
if max_tracks > 0 and track_count > max_tracks and not is_read_only_action(action):
|
|
271
|
+
return SafetyCheck(
|
|
272
|
+
action=action,
|
|
273
|
+
allowed=False,
|
|
274
|
+
risk_level="blocked",
|
|
275
|
+
reason=(
|
|
276
|
+
f"Scope ({track_count} tracks) exceeds limit for "
|
|
277
|
+
f"'{mode}' mode (max {max_tracks})."
|
|
278
|
+
),
|
|
279
|
+
requires_confirmation=False,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# 4. Wide-scope caution (normal mode).
|
|
283
|
+
if track_count > _WIDE_SCOPE_THRESHOLD and not is_read_only_action(action):
|
|
284
|
+
return SafetyCheck(
|
|
285
|
+
action=action,
|
|
286
|
+
allowed=True,
|
|
287
|
+
risk_level="caution",
|
|
288
|
+
reason=(
|
|
289
|
+
f"Action affects {track_count} tracks (> {_WIDE_SCOPE_THRESHOLD}). "
|
|
290
|
+
"Proceed with caution."
|
|
291
|
+
),
|
|
292
|
+
requires_confirmation=True,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# 5. Confirm-required actions.
|
|
296
|
+
if action in CONFIRM_REQUIRED_ACTIONS:
|
|
297
|
+
return SafetyCheck(
|
|
298
|
+
action=action,
|
|
299
|
+
allowed=True,
|
|
300
|
+
risk_level="caution",
|
|
301
|
+
reason=f"'{action}' is destructive — user confirmation recommended.",
|
|
302
|
+
requires_confirmation=True,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# 6. Explicitly safe / read-only.
|
|
306
|
+
if is_read_only_action(action):
|
|
307
|
+
return SafetyCheck(
|
|
308
|
+
action=action,
|
|
309
|
+
allowed=True,
|
|
310
|
+
risk_level="safe",
|
|
311
|
+
reason="Read-only action — always safe.",
|
|
312
|
+
requires_confirmation=False,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# 7. Default — allow but note it's a mutation.
|
|
316
|
+
return SafetyCheck(
|
|
317
|
+
action=action,
|
|
318
|
+
allowed=True,
|
|
319
|
+
risk_level="safe",
|
|
320
|
+
reason="Action permitted by policy.",
|
|
321
|
+
requires_confirmation=False,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def check_batch_safety(actions: list[dict]) -> list[SafetyCheck]:
|
|
326
|
+
"""Check a batch of proposed actions.
|
|
327
|
+
|
|
328
|
+
Each element should be a dict with at least an ``"action"`` key,
|
|
329
|
+
plus optional ``"scope"`` and ``"capability_state"`` keys.
|
|
330
|
+
|
|
331
|
+
Returns one :class:`SafetyCheck` per input, in the same order.
|
|
332
|
+
"""
|
|
333
|
+
results: list[SafetyCheck] = []
|
|
334
|
+
for entry in actions:
|
|
335
|
+
action = entry.get("action", "")
|
|
336
|
+
scope = entry.get("scope")
|
|
337
|
+
cap = entry.get("capability_state")
|
|
338
|
+
results.append(check_action_safety(action, scope, cap))
|
|
339
|
+
return results
|