livepilot 1.9.22 → 1.9.24
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/.mcpbignore +40 -0
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +84 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +141 -72
- package/bin/livepilot.js +135 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +13 -0
- package/livepilot/commands/arrange.md +42 -23
- package/livepilot/commands/mix.md +34 -19
- package/livepilot/commands/perform.md +31 -19
- package/livepilot/commands/sounddesign.md +38 -25
- package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +60 -4
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
- package/livepilot/skills/livepilot-core/references/overview.md +4 -4
- package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
- package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
- package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
- package/livepilot/skills/livepilot-release/SKILL.md +29 -15
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
- package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
- package/livepilot.mcpb +0 -0
- package/manifest.json +91 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/creative_constraints/__init__.py +6 -0
- package/mcp_server/creative_constraints/engine.py +277 -0
- package/mcp_server/creative_constraints/models.py +75 -0
- package/mcp_server/creative_constraints/tools.py +341 -0
- package/mcp_server/experiment/__init__.py +6 -0
- package/mcp_server/experiment/engine.py +213 -0
- package/mcp_server/experiment/models.py +120 -0
- package/mcp_server/experiment/tools.py +263 -0
- package/mcp_server/hook_hunter/__init__.py +5 -0
- package/mcp_server/hook_hunter/analyzer.py +365 -0
- package/mcp_server/hook_hunter/models.py +58 -0
- package/mcp_server/hook_hunter/tools.py +588 -0
- package/mcp_server/memory/taste_graph.py +328 -0
- package/mcp_server/memory/tools.py +99 -0
- package/mcp_server/mix_engine/critics.py +2 -2
- package/mcp_server/mix_engine/models.py +1 -1
- package/mcp_server/mix_engine/state_builder.py +2 -2
- package/mcp_server/musical_intelligence/__init__.py +8 -0
- package/mcp_server/musical_intelligence/detectors.py +434 -0
- package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
- package/mcp_server/musical_intelligence/tools.py +224 -0
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/__init__.py +5 -0
- package/mcp_server/preview_studio/engine.py +280 -0
- package/mcp_server/preview_studio/models.py +74 -0
- package/mcp_server/preview_studio/tools.py +466 -0
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +118 -0
- package/mcp_server/runtime/execution_router.py +139 -0
- package/mcp_server/runtime/remote_commands.py +82 -0
- package/mcp_server/runtime/session_kernel.py +96 -0
- package/mcp_server/runtime/tools.py +90 -1
- package/mcp_server/semantic_moves/__init__.py +13 -0
- package/mcp_server/semantic_moves/compiler.py +116 -0
- package/mcp_server/semantic_moves/mix_compilers.py +291 -0
- package/mcp_server/semantic_moves/mix_moves.py +157 -0
- package/mcp_server/semantic_moves/models.py +46 -0
- package/mcp_server/semantic_moves/performance_compilers.py +208 -0
- package/mcp_server/semantic_moves/performance_moves.py +81 -0
- package/mcp_server/semantic_moves/registry.py +32 -0
- package/mcp_server/semantic_moves/resolvers.py +126 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
- package/mcp_server/semantic_moves/tools.py +205 -0
- package/mcp_server/semantic_moves/transition_compilers.py +222 -0
- package/mcp_server/semantic_moves/transition_moves.py +76 -0
- package/mcp_server/server.py +10 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/__init__.py +6 -0
- package/mcp_server/session_continuity/models.py +86 -0
- package/mcp_server/session_continuity/tools.py +230 -0
- package/mcp_server/session_continuity/tracker.py +263 -0
- package/mcp_server/song_brain/__init__.py +6 -0
- package/mcp_server/song_brain/builder.py +504 -0
- package/mcp_server/song_brain/models.py +136 -0
- package/mcp_server/song_brain/tools.py +312 -0
- package/mcp_server/stuckness_detector/__init__.py +5 -0
- package/mcp_server/stuckness_detector/detector.py +400 -0
- package/mcp_server/stuckness_detector/models.py +66 -0
- package/mcp_server/stuckness_detector/tools.py +195 -0
- package/mcp_server/tools/_conductor.py +104 -6
- package/mcp_server/tools/analyzer.py +1 -1
- package/mcp_server/tools/devices.py +34 -0
- package/mcp_server/wonder_mode/__init__.py +6 -0
- package/mcp_server/wonder_mode/diagnosis.py +84 -0
- package/mcp_server/wonder_mode/engine.py +493 -0
- package/mcp_server/wonder_mode/session.py +114 -0
- package/mcp_server/wonder_mode/tools.py +290 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/browser.py +4 -1
- package/remote_script/LivePilot/devices.py +29 -0
- package/remote_script/LivePilot/tracks.py +11 -4
- package/scripts/generate_tool_catalog.py +131 -0
- package/scripts/sync_metadata.py +132 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Session Continuity MCP tools — 7 tools for collaborative memory.
|
|
2
|
+
|
|
3
|
+
get_session_story — what the track was becoming, what changed, what's open
|
|
4
|
+
resume_last_intent — pick up where you left off
|
|
5
|
+
record_turn_resolution — log what happened in a creative turn
|
|
6
|
+
rank_by_taste_and_identity — rank candidates with separated taste/identity
|
|
7
|
+
open_creative_thread — open a new creative thread for exploration
|
|
8
|
+
list_open_creative_threads — list all open non-stale creative threads
|
|
9
|
+
explain_preference_vs_identity — explain taste vs identity tension
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from fastmcp import Context
|
|
15
|
+
|
|
16
|
+
from ..server import mcp
|
|
17
|
+
from . import tracker
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@mcp.tool()
|
|
21
|
+
def get_session_story(ctx: Context) -> dict:
|
|
22
|
+
"""Get the narrative of the current session.
|
|
23
|
+
|
|
24
|
+
At the start of a resumed session, the agent can say what the track
|
|
25
|
+
was trying to become, what changed last time, and what still feels open.
|
|
26
|
+
|
|
27
|
+
Returns identity summary, recent turns, open creative threads,
|
|
28
|
+
and mood arc.
|
|
29
|
+
"""
|
|
30
|
+
song_brain = _get_song_brain_dict()
|
|
31
|
+
story = tracker.get_session_story(song_brain)
|
|
32
|
+
return story.to_dict()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def resume_last_intent(ctx: Context) -> dict:
|
|
37
|
+
"""Resume the most recent unresolved creative intent.
|
|
38
|
+
|
|
39
|
+
Finds the latest open creative thread and suggests continuing it.
|
|
40
|
+
Stale threads (untouched for >30 minutes) are excluded.
|
|
41
|
+
"""
|
|
42
|
+
return tracker.resume_last_intent()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
def record_turn_resolution(
|
|
47
|
+
ctx: Context,
|
|
48
|
+
request_text: str,
|
|
49
|
+
outcome: str = "accepted",
|
|
50
|
+
move_applied: str = "",
|
|
51
|
+
identity_effect: str = "",
|
|
52
|
+
user_sentiment: str = "neutral",
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Record what happened in a creative turn.
|
|
55
|
+
|
|
56
|
+
Call this after each significant creative action to build the
|
|
57
|
+
session story. Tracks outcomes, identity effects, and user sentiment.
|
|
58
|
+
|
|
59
|
+
request_text: what was requested
|
|
60
|
+
outcome: "accepted", "rejected", "modified", or "undone"
|
|
61
|
+
move_applied: which semantic move was used (if any)
|
|
62
|
+
identity_effect: "preserves", "evolves", "contrasts", or "resets"
|
|
63
|
+
user_sentiment: "loved", "liked", "neutral", "disliked", or "hated"
|
|
64
|
+
"""
|
|
65
|
+
turn = tracker.record_turn_resolution(
|
|
66
|
+
request_text=request_text,
|
|
67
|
+
outcome=outcome,
|
|
68
|
+
move_applied=move_applied,
|
|
69
|
+
identity_effect=identity_effect,
|
|
70
|
+
user_sentiment=user_sentiment,
|
|
71
|
+
)
|
|
72
|
+
return turn.to_dict()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
def rank_by_taste_and_identity(
|
|
77
|
+
ctx: Context,
|
|
78
|
+
candidates: list[dict] | None = None,
|
|
79
|
+
) -> dict:
|
|
80
|
+
"""Rank candidates with separated taste and identity scoring.
|
|
81
|
+
|
|
82
|
+
Taste (cross-session preference) ranks options.
|
|
83
|
+
Identity (in-song) constrains/shapes options.
|
|
84
|
+
Explicit user instructions override both.
|
|
85
|
+
|
|
86
|
+
candidates: list of dicts with at least "id", "novelty_level",
|
|
87
|
+
and "identity_effect" fields
|
|
88
|
+
|
|
89
|
+
Returns ranked list with taste_score, identity_score, composite,
|
|
90
|
+
and explanations for each.
|
|
91
|
+
"""
|
|
92
|
+
if not candidates:
|
|
93
|
+
return {"error": "No candidates provided", "rankings": []}
|
|
94
|
+
|
|
95
|
+
taste_graph = _get_taste_graph(ctx)
|
|
96
|
+
song_brain = _get_song_brain_dict()
|
|
97
|
+
|
|
98
|
+
rankings = tracker.rank_by_taste_and_identity(
|
|
99
|
+
candidates=candidates,
|
|
100
|
+
taste_graph=taste_graph,
|
|
101
|
+
song_brain=song_brain,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"rankings": [r.to_dict() for r in rankings],
|
|
106
|
+
"note": "Identity has stronger weight inside a session; taste has stronger weight when choosing among viable options",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@mcp.tool()
|
|
111
|
+
def open_creative_thread(
|
|
112
|
+
ctx: Context,
|
|
113
|
+
description: str,
|
|
114
|
+
domain: str = "",
|
|
115
|
+
priority: float = 0.5,
|
|
116
|
+
) -> dict:
|
|
117
|
+
"""Open a new creative thread — an unresolved creative goal.
|
|
118
|
+
|
|
119
|
+
Use this to track intentions that span multiple actions, like
|
|
120
|
+
"develop the chorus hook" or "fix the transition energy."
|
|
121
|
+
Threads are surfaced by get_session_story and resume_last_intent.
|
|
122
|
+
|
|
123
|
+
description: what the creative goal is
|
|
124
|
+
domain: "arrangement", "sound_design", "mix", "harmony", "identity"
|
|
125
|
+
priority: 0-1 importance level
|
|
126
|
+
"""
|
|
127
|
+
if not description.strip():
|
|
128
|
+
return {"error": "description cannot be empty"}
|
|
129
|
+
|
|
130
|
+
thread = tracker.open_thread(description, domain=domain, priority=priority)
|
|
131
|
+
return thread.to_dict()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
def list_open_creative_threads(ctx: Context) -> dict:
|
|
136
|
+
"""List all open (non-stale) creative threads in the session.
|
|
137
|
+
|
|
138
|
+
Returns unresolved creative goals, abandoned directions worth
|
|
139
|
+
revisiting, and what the next best unresolved question is.
|
|
140
|
+
Stale threads (untouched for >30 minutes) are excluded.
|
|
141
|
+
"""
|
|
142
|
+
threads = tracker.list_open_threads()
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
"threads": [t.to_dict() for t in threads],
|
|
146
|
+
"thread_count": len(threads),
|
|
147
|
+
"next_best": threads[0].to_dict() if threads else None,
|
|
148
|
+
"note": "Threads decay after 30 minutes of inactivity",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.tool()
|
|
153
|
+
def explain_preference_vs_identity(
|
|
154
|
+
ctx: Context,
|
|
155
|
+
candidate_id: str = "",
|
|
156
|
+
novelty_level: float = 0.5,
|
|
157
|
+
identity_effect: str = "preserves",
|
|
158
|
+
) -> dict:
|
|
159
|
+
"""Explain how taste preference and song identity score a candidate.
|
|
160
|
+
|
|
161
|
+
Shows the tension between what the user tends to like (taste)
|
|
162
|
+
and what the current song needs (identity). Useful for understanding
|
|
163
|
+
why a variant was ranked the way it was.
|
|
164
|
+
|
|
165
|
+
candidate_id: the candidate to explain
|
|
166
|
+
novelty_level: 0-1 how novel the candidate is
|
|
167
|
+
identity_effect: "preserves", "evolves", "contrasts", or "resets"
|
|
168
|
+
"""
|
|
169
|
+
taste_graph = _get_taste_graph(ctx)
|
|
170
|
+
song_brain = _get_song_brain_dict()
|
|
171
|
+
|
|
172
|
+
candidates = [{
|
|
173
|
+
"id": candidate_id,
|
|
174
|
+
"novelty_level": novelty_level,
|
|
175
|
+
"identity_effect": identity_effect,
|
|
176
|
+
}]
|
|
177
|
+
|
|
178
|
+
rankings = tracker.rank_by_taste_and_identity(
|
|
179
|
+
candidates=candidates,
|
|
180
|
+
taste_graph=taste_graph,
|
|
181
|
+
song_brain=song_brain,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if not rankings:
|
|
185
|
+
return {"error": "Could not rank candidate"}
|
|
186
|
+
|
|
187
|
+
r = rankings[0]
|
|
188
|
+
return {
|
|
189
|
+
"candidate_id": candidate_id,
|
|
190
|
+
"taste_score": r.taste_score,
|
|
191
|
+
"identity_score": r.identity_score,
|
|
192
|
+
"composite_score": r.composite_score,
|
|
193
|
+
"taste_explanation": r.taste_explanation,
|
|
194
|
+
"identity_explanation": r.identity_explanation,
|
|
195
|
+
"recommendation": r.recommendation,
|
|
196
|
+
"tension": (
|
|
197
|
+
"aligned" if abs(r.taste_score - r.identity_score) < 0.2
|
|
198
|
+
else "in tension — taste and identity disagree"
|
|
199
|
+
),
|
|
200
|
+
"note": "Identity has stronger weight inside a session (0.65 vs 0.35)",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Helpers ───────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _get_song_brain_dict() -> dict:
|
|
208
|
+
try:
|
|
209
|
+
from ..song_brain.tools import _current_brain
|
|
210
|
+
if _current_brain is not None:
|
|
211
|
+
return _current_brain.to_dict()
|
|
212
|
+
except Exception as _e:
|
|
213
|
+
if __debug__:
|
|
214
|
+
import sys
|
|
215
|
+
print(f"LivePilot: SongBrain unavailable in session_continuity: {_e}", file=sys.stderr)
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _get_taste_graph(ctx: Context) -> dict:
|
|
220
|
+
"""Session-scoped taste graph — matches preview_studio pattern."""
|
|
221
|
+
try:
|
|
222
|
+
from ..memory.taste_graph import build_taste_graph
|
|
223
|
+
from ..memory.taste_memory import TasteMemoryStore
|
|
224
|
+
from ..memory.anti_memory import AntiMemoryStore
|
|
225
|
+
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
226
|
+
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
227
|
+
return build_taste_graph(taste_store=taste_store, anti_store=anti_store).to_dict()
|
|
228
|
+
except Exception:
|
|
229
|
+
pass
|
|
230
|
+
return {}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Session Continuity tracker — pure computation + in-memory state.
|
|
2
|
+
|
|
3
|
+
Manages creative threads, turn resolutions, and session story.
|
|
4
|
+
Separates taste (cross-session) from identity (in-song) ranking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import time
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .models import (
|
|
14
|
+
CreativeThread,
|
|
15
|
+
SessionStory,
|
|
16
|
+
TasteIdentityRanking,
|
|
17
|
+
TurnResolution,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── In-memory state ───────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
_story = SessionStory()
|
|
24
|
+
_threads: dict[str, CreativeThread] = {}
|
|
25
|
+
_turns: list[TurnResolution] = []
|
|
26
|
+
_project_store = None # Optional PersistentProjectStore
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_project_store(store) -> None:
|
|
30
|
+
"""Attach a persistent project store for flush-on-write."""
|
|
31
|
+
global _project_store
|
|
32
|
+
_project_store = store
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def reset_story() -> None:
|
|
36
|
+
"""Reset session story (for testing)."""
|
|
37
|
+
global _story, _threads, _turns, _project_store
|
|
38
|
+
_story = SessionStory()
|
|
39
|
+
_threads = {}
|
|
40
|
+
_turns = []
|
|
41
|
+
_project_store = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Session story ─────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_session_story(
|
|
48
|
+
song_brain: Optional[dict] = None,
|
|
49
|
+
) -> SessionStory:
|
|
50
|
+
"""Get the current session story with identity summary."""
|
|
51
|
+
song_brain = song_brain or {}
|
|
52
|
+
|
|
53
|
+
_story.identity_summary = song_brain.get("identity_core", "")
|
|
54
|
+
_story.threads = [t for t in _threads.values() if t.status == "open"]
|
|
55
|
+
_story.turns = _turns
|
|
56
|
+
_story.what_still_feels_open = [
|
|
57
|
+
t.description for t in _threads.values()
|
|
58
|
+
if t.status == "open" and not t.is_stale
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
if _turns:
|
|
62
|
+
last = _turns[-1]
|
|
63
|
+
_story.what_changed_last = f"{last.request_text} → {last.outcome}"
|
|
64
|
+
|
|
65
|
+
return _story
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resume_last_intent() -> dict:
|
|
69
|
+
"""Resume the most recent unresolved creative intent."""
|
|
70
|
+
open_threads = [
|
|
71
|
+
t for t in _threads.values()
|
|
72
|
+
if t.status == "open" and not t.is_stale
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
if not open_threads:
|
|
76
|
+
return {
|
|
77
|
+
"found": False,
|
|
78
|
+
"note": "No unresolved creative intents to resume",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Sort by last touched (most recent first)
|
|
82
|
+
open_threads.sort(key=lambda t: t.last_touched_ms, reverse=True)
|
|
83
|
+
latest = open_threads[0]
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"found": True,
|
|
87
|
+
"thread_id": latest.thread_id,
|
|
88
|
+
"description": latest.description,
|
|
89
|
+
"domain": latest.domain,
|
|
90
|
+
"priority": latest.priority,
|
|
91
|
+
"suggestion": f"Continue working on: {latest.description}",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ── Turn tracking ─────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def record_turn_resolution(
|
|
99
|
+
request_text: str,
|
|
100
|
+
outcome: str = "accepted",
|
|
101
|
+
move_applied: str = "",
|
|
102
|
+
identity_effect: str = "",
|
|
103
|
+
user_sentiment: str = "neutral",
|
|
104
|
+
) -> TurnResolution:
|
|
105
|
+
"""Record what happened in a creative turn."""
|
|
106
|
+
now = int(time.time() * 1000)
|
|
107
|
+
turn_id = hashlib.sha256(f"{request_text}_{now}".encode()).hexdigest()[:10]
|
|
108
|
+
|
|
109
|
+
turn = TurnResolution(
|
|
110
|
+
turn_id=turn_id,
|
|
111
|
+
request_text=request_text,
|
|
112
|
+
outcome=outcome,
|
|
113
|
+
move_applied=move_applied,
|
|
114
|
+
identity_effect=identity_effect,
|
|
115
|
+
user_sentiment=user_sentiment,
|
|
116
|
+
timestamp_ms=now,
|
|
117
|
+
)
|
|
118
|
+
_turns.append(turn)
|
|
119
|
+
|
|
120
|
+
# Update mood arc
|
|
121
|
+
if user_sentiment in ("loved", "liked"):
|
|
122
|
+
_story.mood_arc.append("positive")
|
|
123
|
+
elif user_sentiment in ("disliked", "hated"):
|
|
124
|
+
_story.mood_arc.append("negative")
|
|
125
|
+
else:
|
|
126
|
+
_story.mood_arc.append("neutral")
|
|
127
|
+
|
|
128
|
+
# Flush to persistent store
|
|
129
|
+
if _project_store is not None:
|
|
130
|
+
try:
|
|
131
|
+
_project_store.save_turn(turn.to_dict())
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
return turn
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Creative threads ──────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def open_thread(description: str, domain: str = "", priority: float = 0.5) -> CreativeThread:
|
|
142
|
+
"""Open a new creative thread."""
|
|
143
|
+
now = int(time.time() * 1000)
|
|
144
|
+
thread_id = hashlib.sha256(f"{description}_{now}".encode()).hexdigest()[:10]
|
|
145
|
+
|
|
146
|
+
thread = CreativeThread(
|
|
147
|
+
thread_id=thread_id,
|
|
148
|
+
description=description,
|
|
149
|
+
domain=domain,
|
|
150
|
+
status="open",
|
|
151
|
+
priority=priority,
|
|
152
|
+
created_at_ms=now,
|
|
153
|
+
last_touched_ms=now,
|
|
154
|
+
)
|
|
155
|
+
_threads[thread_id] = thread
|
|
156
|
+
|
|
157
|
+
# Flush to persistent store
|
|
158
|
+
if _project_store is not None:
|
|
159
|
+
try:
|
|
160
|
+
_project_store.save_thread(thread.to_dict())
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
return thread
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_thread(thread_id: str) -> Optional[CreativeThread]:
|
|
168
|
+
"""Mark a creative thread as resolved."""
|
|
169
|
+
thread = _threads.get(thread_id)
|
|
170
|
+
if thread:
|
|
171
|
+
thread.status = "resolved"
|
|
172
|
+
thread.last_touched_ms = int(time.time() * 1000)
|
|
173
|
+
if _project_store is not None:
|
|
174
|
+
try:
|
|
175
|
+
_project_store.save_thread(thread.to_dict())
|
|
176
|
+
except Exception:
|
|
177
|
+
pass
|
|
178
|
+
return thread
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def list_open_threads() -> list[CreativeThread]:
|
|
182
|
+
"""List all open (non-stale) creative threads."""
|
|
183
|
+
return [
|
|
184
|
+
t for t in _threads.values()
|
|
185
|
+
if t.status == "open" and not t.is_stale
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── Taste vs Identity ranking ────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def rank_by_taste_and_identity(
|
|
193
|
+
candidates: list[dict],
|
|
194
|
+
taste_graph: Optional[dict] = None,
|
|
195
|
+
song_brain: Optional[dict] = None,
|
|
196
|
+
) -> list[TasteIdentityRanking]:
|
|
197
|
+
"""Rank candidates with separated taste and identity scoring.
|
|
198
|
+
|
|
199
|
+
Taste ranks options (cross-session preference).
|
|
200
|
+
Identity constrains/shapes options (in-song).
|
|
201
|
+
Identity has stronger weight inside a session.
|
|
202
|
+
"""
|
|
203
|
+
taste_graph = taste_graph or {}
|
|
204
|
+
song_brain = song_brain or {}
|
|
205
|
+
results: list[TasteIdentityRanking] = []
|
|
206
|
+
|
|
207
|
+
for candidate in candidates:
|
|
208
|
+
cid = candidate.get("id", candidate.get("variant_id", ""))
|
|
209
|
+
novelty = candidate.get("novelty_level", 0.5)
|
|
210
|
+
identity_effect = candidate.get("identity_effect", "preserves")
|
|
211
|
+
|
|
212
|
+
# Taste score — how well does this fit cross-session preferences?
|
|
213
|
+
boldness_pref = taste_graph.get("transition_boldness", 0.5)
|
|
214
|
+
taste_score = 1.0 - abs(novelty - boldness_pref) * 0.8
|
|
215
|
+
taste_score = round(max(0.0, min(1.0, taste_score)), 3)
|
|
216
|
+
|
|
217
|
+
# Identity score — does this serve the current song?
|
|
218
|
+
identity_scores = {
|
|
219
|
+
"preserves": 0.9,
|
|
220
|
+
"evolves": 0.7,
|
|
221
|
+
"contrasts": 0.45,
|
|
222
|
+
"resets": 0.15,
|
|
223
|
+
}
|
|
224
|
+
identity_score = identity_scores.get(identity_effect, 0.5)
|
|
225
|
+
|
|
226
|
+
# Sacred element penalty — penalize non-preserving candidates
|
|
227
|
+
# that target sacred dimensions
|
|
228
|
+
sacred = song_brain.get("sacred_elements", [])
|
|
229
|
+
targets = candidate.get("targets_snapshot", {})
|
|
230
|
+
sacred_penalty = sum(
|
|
231
|
+
s.get("salience", 0.5) * 0.15
|
|
232
|
+
for s in sacred
|
|
233
|
+
if s.get("element_type") in targets and identity_effect != "preserves"
|
|
234
|
+
)
|
|
235
|
+
identity_score = max(0.0, identity_score - sacred_penalty)
|
|
236
|
+
|
|
237
|
+
# Composite: identity weighted more heavily within a session
|
|
238
|
+
composite = taste_score * 0.35 + identity_score * 0.65
|
|
239
|
+
|
|
240
|
+
# Explanations
|
|
241
|
+
taste_exp = (
|
|
242
|
+
f"{'Good' if taste_score > 0.6 else 'Moderate' if taste_score > 0.3 else 'Poor'} "
|
|
243
|
+
f"taste fit — novelty {novelty:.0%} vs preference {boldness_pref:.0%}"
|
|
244
|
+
)
|
|
245
|
+
identity_exp = (
|
|
246
|
+
f"Identity effect: {identity_effect} — "
|
|
247
|
+
f"{'safe for current song' if identity_score > 0.6 else 'risky for song identity'}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
results.append(TasteIdentityRanking(
|
|
251
|
+
candidate_id=cid,
|
|
252
|
+
taste_score=taste_score,
|
|
253
|
+
identity_score=identity_score,
|
|
254
|
+
composite_score=round(composite, 3),
|
|
255
|
+
taste_explanation=taste_exp,
|
|
256
|
+
identity_explanation=identity_exp,
|
|
257
|
+
recommendation="recommended" if composite > 0.6 else (
|
|
258
|
+
"consider" if composite > 0.4 else "caution"
|
|
259
|
+
),
|
|
260
|
+
))
|
|
261
|
+
|
|
262
|
+
results.sort(key=lambda r: r.composite_score, reverse=True)
|
|
263
|
+
return results
|