5a73a6a6f3
Disliking a track in a Vibe wrote one row to the session ledger and nothing else. The ledger only excludes a track from the session it was recorded in, so the same track came back the next evening, and the one after that. A dislike in a Vibe is the same verdict as a dislike anywhere else, so it now takes the same path. Two more things undid a dislike that did land. The library scan rewrote every track's state from the file on disk, which restored every HIDDEN track to LIBRARY on every scan; finding a file again says nothing about whether the listener wants to hear it. And hiding only matched tracks in LIBRARY, so a disliked probation recommendation stayed eligible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
419 lines
17 KiB
TypeScript
419 lines
17 KiB
TypeScript
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
|
import { SessionDirector } from './session-director.service.js';
|
|
import { Candidate, VibeCalendarContext } from './generators.service.js';
|
|
|
|
/**
|
|
* This is deliberately a narrow bridge between the durable Vibe ledger and
|
|
* the current deterministic director. It writes authoritative revisions and
|
|
* lets the playback client replace only its unserved preview after feedback.
|
|
*/
|
|
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';
|
|
|
|
export const VIBE_EVENT_TYPES = [
|
|
'session_started', 'session_resumed', 'session_ended',
|
|
'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
|
|
'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
|
|
'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
|
|
'track_replayed', 'volume_changed', 'playback_error',
|
|
] as const;
|
|
|
|
export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
|
|
|
|
export interface StartVibeSessionInput {
|
|
seedTrackId?: string;
|
|
resumeSessionId?: string;
|
|
context?: VibeCalendarContext;
|
|
}
|
|
|
|
export interface AppendVibeEventInput {
|
|
eventId?: string;
|
|
type: VibeEventType;
|
|
trackId?: string;
|
|
occurredAt?: Date;
|
|
positionMs?: number;
|
|
durationMs?: number;
|
|
payload?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* An explicit advancement protocol for a plan item that was durably served
|
|
* but cannot be played locally. `eventId` is the idempotency key for this
|
|
* state transition; it is intentionally separate from a retry of /next.
|
|
*/
|
|
export interface AdvanceUnplayableVibeItemInput {
|
|
expectedPlanVersion: number;
|
|
planVersionId: string;
|
|
ordinal: number;
|
|
trackId: string;
|
|
eventId: string;
|
|
}
|
|
|
|
/**
|
|
* How much of the plan a client is shown and holds ready. The plan itself is
|
|
* PLAN_SIZE long and stays that way; this is only the served window. Every
|
|
* preview item costs the client one track fetch on every advance, and a replan
|
|
* discards whatever is still unplayed, so a long window buys little beyond a
|
|
* longer Up next list.
|
|
*/
|
|
const PREVIEW_SIZE = 3;
|
|
|
|
export class VibeSessionNotFoundError extends Error {}
|
|
export class VibeSessionLifecycleError extends Error {}
|
|
export class VibePlanNotFoundError extends Error {}
|
|
|
|
export interface VibeSessionResponse {
|
|
session: VibeSession;
|
|
sessionId: string;
|
|
planVersion: number | null;
|
|
now: VibePlan['items'][number] | null;
|
|
preview: VibePlan['items'];
|
|
state: Record<string, unknown>;
|
|
replanned: boolean;
|
|
replanReason: string | null;
|
|
}
|
|
|
|
export class VibeSessionCoordinator {
|
|
constructor(
|
|
private readonly db: DbService,
|
|
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
|
|
) {}
|
|
|
|
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
|
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
|
|
// Calendar context is a weak boot prior, never a substitute for listening
|
|
// feedback or long-term preference. It is intentionally coarse and is
|
|
// persisted with the session so a resume remains coherent.
|
|
const calendar = input.context;
|
|
const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32
|
|
: calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58
|
|
: 0.5;
|
|
const initialState = {
|
|
energy: contextualEnergy,
|
|
noveltyHunger: 0.3,
|
|
explorationCoefficient: 0.3,
|
|
discoveryRadius: 0.38,
|
|
sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 },
|
|
};
|
|
const session = await this.db.createVibeSession({
|
|
userId,
|
|
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
|
seedTrackId: input.seedTrackId ?? null,
|
|
context: calendar ? { ...calendar } : undefined,
|
|
profile: {
|
|
goals: initialState.sessionGoal,
|
|
explorationCoefficient: initialState.explorationCoefficient,
|
|
discoveryRadius: initialState.discoveryRadius,
|
|
},
|
|
});
|
|
|
|
// session_state is a derived cache used by the current director. Give it
|
|
// the durable ID so director state cannot accidentally bleed into another
|
|
// session while the durable tables remain the source of truth.
|
|
await this.db.createSessionState(
|
|
userId,
|
|
calendar ? JSON.stringify(calendar) : undefined,
|
|
{
|
|
energy: initialState.energy,
|
|
noveltyHunger: initialState.noveltyHunger,
|
|
explorationCoefficient: initialState.explorationCoefficient,
|
|
discoveryRadius: initialState.discoveryRadius,
|
|
sessionGoal: initialState.sessionGoal,
|
|
},
|
|
session.id,
|
|
);
|
|
await this.db.recordVibeEvent({
|
|
sessionId: session.id,
|
|
userId,
|
|
type: 'session_started',
|
|
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
|
|
});
|
|
|
|
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
|
|
const state = await this.director.buildState(userId, session.id);
|
|
let plan: VibePlan;
|
|
try {
|
|
plan = await this.db.publishVibePlan({
|
|
sessionId: session.id,
|
|
userId,
|
|
version: 1,
|
|
reason: 'session_started',
|
|
stateSnapshot: state,
|
|
objectiveSnapshot: {
|
|
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
|
horizonTracks: candidates.length,
|
|
...(candidates[0]?.plan?.objective ?? {}),
|
|
},
|
|
items: candidates.map((candidate, ordinal) => ({
|
|
ordinal,
|
|
track_id: candidate.trackId,
|
|
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
|
candidate_source: candidate.generatorId,
|
|
score: candidate.plan?.score ?? candidate.relevance,
|
|
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
|
explanation: candidate.plan
|
|
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
|
: candidate.explanation,
|
|
committed: false,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
|
|
return this.toResponse(session, plan, state);
|
|
}
|
|
|
|
async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
|
|
const session = await this.requireSession(userId, sessionId);
|
|
const plan = await this.db.getVibePlan(sessionId, userId, version);
|
|
if (!plan) throw new VibePlanNotFoundError(
|
|
version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
|
|
);
|
|
return this.toResponse(session, plan, plan?.state_snapshot ?? {});
|
|
}
|
|
|
|
async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
|
|
try {
|
|
const served = expectedPlanVersion === undefined
|
|
? await this.db.serveNextVibePlanItem(sessionId, userId)
|
|
: await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
|
|
const response = await this.getPlan(userId, sessionId);
|
|
// A plan may be replaced between the client's preview and this request.
|
|
// In that case the database does not commit anything and this is the
|
|
// current, revisable preview the client must reconcile to.
|
|
if (served.stale) return response;
|
|
return { ...response, now: served.item, preview: response.preview };
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
}
|
|
|
|
async advancePastUnplayable(
|
|
userId: string,
|
|
sessionId: string,
|
|
input: AdvanceUnplayableVibeItemInput,
|
|
): Promise<VibeSessionResponse> {
|
|
try {
|
|
const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input);
|
|
const response = await this.getPlan(userId, sessionId);
|
|
if (served.stale) return response;
|
|
return { ...response, now: served.item, preview: response.preview };
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
}
|
|
|
|
async appendEvent(
|
|
userId: string,
|
|
sessionId: string,
|
|
input: AppendVibeEventInput,
|
|
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
|
|
try {
|
|
// Do this before the ledger write, rather than after it, because Vibe
|
|
// events are immutable. The DB repeats this boundary for non-HTTP
|
|
// callers; keeping it here also makes coordinator callers see exactly
|
|
// what will be persisted.
|
|
const result = await this.db.recordVibeEvent({
|
|
sessionId,
|
|
userId,
|
|
clientEventId: input.eventId,
|
|
type: input.type,
|
|
trackId: input.trackId,
|
|
occurredAt: input.occurredAt,
|
|
positionMs: input.positionMs,
|
|
durationMs: input.durationMs,
|
|
payload: input.payload,
|
|
});
|
|
// The ledger write is authoritative; this idempotent projection updates
|
|
// exploration only after the exact event exists. Keep the compatibility
|
|
// guard for old coordinator test doubles during the migration.
|
|
const projectSessionFeedback = (this.db as Partial<DbService>).projectVibeSessionFeedback;
|
|
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
|
// A dislike in a Vibe is the same verdict as a dislike anywhere else. The
|
|
// ledger alone only excludes the track from this one session, which is
|
|
// why a disliked track kept coming back the next evening. Run it once per
|
|
// distinct event so a retried delivery cannot log a second feedback row.
|
|
if (input.type === 'disliked' && input.trackId && result.inserted) {
|
|
const dislikeTrack = (this.db as Partial<DbService>).dislikeTrack;
|
|
if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId);
|
|
}
|
|
if (!isMaterialFeedback(input.type)) {
|
|
const response = await this.getPlan(userId, sessionId);
|
|
return { ...response, event: result.event, idempotent: !result.inserted };
|
|
}
|
|
|
|
// A material event is durable before its computed replacement can be
|
|
// written. If planning/persistence failed after that event committed, a
|
|
// retry must finish the missing replacement instead of permanently
|
|
// returning an obsolete preview. Once a replacement exists, a duplicate
|
|
// retry returns that canonical revision without doing work again.
|
|
if (!result.inserted) {
|
|
const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
|
|
if (existingReplacement) {
|
|
const session = await this.requireSession(userId, sessionId);
|
|
return {
|
|
...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
|
|
event: result.event,
|
|
idempotent: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const session = await this.requireSession(userId, sessionId);
|
|
const state = await this.director.buildState(userId, sessionId);
|
|
// A feedback target is useful as the local replan anchor, but it must
|
|
// never displace the durable seed from the exclusion boundary. Unlike
|
|
// feedback tracks, the seed is not necessarily present in the event
|
|
// ledger, so carry it explicitly into every replacement request.
|
|
const seedTrackId = session.seed_track_id ?? undefined;
|
|
// Revisions retain the durable, unserved queue tail rather than building
|
|
// an unrelated plan after every signal. Besides reducing churn, this
|
|
// preserves a valid callback/recovery pair that has already been shown
|
|
// to the client while allowing the director to refill under the same
|
|
// hard caps and current feedback state.
|
|
const current = await this.db.getVibePlan(sessionId, userId);
|
|
const retained = current ? this.unservedCandidates(current) : [];
|
|
const excludedTrackIds = new Set<string>([
|
|
...(seedTrackId ? [seedTrackId] : []),
|
|
]);
|
|
const candidates = await this.director.replan(
|
|
userId,
|
|
sessionId,
|
|
retained,
|
|
input.trackId ? [input.trackId] : [],
|
|
input.trackId ?? seedTrackId,
|
|
{ excludedTrackIds },
|
|
);
|
|
const reason = `feedback:${input.type}`;
|
|
const plan = await this.db.publishVibePlan({
|
|
sessionId,
|
|
userId,
|
|
reason,
|
|
stateSnapshot: state,
|
|
objectiveSnapshot: {
|
|
policyVersion: session.policy_version,
|
|
feedbackEventId: result.event.id,
|
|
feedbackType: input.type,
|
|
horizonTracks: candidates.length,
|
|
...(candidates[0]?.plan?.objective ?? {}),
|
|
},
|
|
items: candidates.map((candidate, ordinal) => ({
|
|
ordinal,
|
|
track_id: candidate.trackId,
|
|
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
|
|
candidate_source: candidate.generatorId,
|
|
score: candidate.plan?.score ?? candidate.relevance,
|
|
score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance },
|
|
explanation: candidate.plan
|
|
? { paths: candidate.explanation, planner: candidate.plan.explanation }
|
|
: candidate.explanation,
|
|
committed: false,
|
|
})),
|
|
});
|
|
return {
|
|
...this.toResponse(session, plan, state),
|
|
event: result.event,
|
|
idempotent: !result.inserted,
|
|
replanned: true,
|
|
replanReason: reason,
|
|
};
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
}
|
|
|
|
async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
|
|
let ended: VibeSession;
|
|
try {
|
|
ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
const plan = await this.db.getVibePlan(sessionId, userId);
|
|
return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
|
|
}
|
|
|
|
private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
|
|
try {
|
|
const resumed = await this.db.resumeVibeSession(sessionId, userId);
|
|
const plan = await this.db.getVibePlan(sessionId, userId);
|
|
return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
|
|
} catch (error) {
|
|
throw this.mapLifecycleError(error);
|
|
}
|
|
}
|
|
|
|
private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
|
|
const session = await this.db.getVibeSession(sessionId, userId);
|
|
if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
|
|
return session;
|
|
}
|
|
|
|
private toResponse(
|
|
session: VibeSession,
|
|
plan: VibePlan | null,
|
|
state: Record<string, unknown>,
|
|
): VibeSessionResponse {
|
|
// A revision is immutable, but clients need a live future: already served
|
|
// rows stay in the ledger and are excluded from the replacement preview.
|
|
const preview = plan?.items.filter((item) => !item.committed).slice(0, PREVIEW_SIZE) ?? [];
|
|
return {
|
|
session,
|
|
sessionId: session.id,
|
|
planVersion: plan?.version ?? null,
|
|
now: preview[0] ?? null,
|
|
preview,
|
|
state,
|
|
replanned: false,
|
|
replanReason: null,
|
|
};
|
|
}
|
|
|
|
/** Reconstruct the planner envelope from the durable revision. Older
|
|
* revisions stored only paths, so they remain valid retention inputs. */
|
|
private unservedCandidates(plan: VibePlan): Candidate[] {
|
|
return plan.items
|
|
.filter(item => !item.committed)
|
|
.map(item => {
|
|
const stored = item.explanation;
|
|
const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object'
|
|
&& 'planner' in stored;
|
|
const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record<string, unknown> } : undefined;
|
|
const planner = object?.planner;
|
|
return {
|
|
trackId: item.track_id,
|
|
generatorId: item.candidate_source,
|
|
relevance: item.score,
|
|
explanation: object?.paths ?? (Array.isArray(stored) ? stored : []),
|
|
plan: planner ? {
|
|
slotRole: item.slot_role ?? 'retained',
|
|
score: item.score,
|
|
scoreBreakdown: item.score_breakdown,
|
|
explanation: planner,
|
|
objective: {
|
|
policy: planner.policy,
|
|
constraints: planner.constraints,
|
|
relaxations: planner.relaxations,
|
|
},
|
|
} : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
private mapLifecycleError(error: unknown): Error {
|
|
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
|
|
return new VibeSessionLifecycleError(error.message);
|
|
}
|
|
if (error instanceof Error && error.message.includes('not found or is not owned')) {
|
|
return new VibeSessionNotFoundError('Vibe session was not found');
|
|
}
|
|
return error instanceof Error ? error : new Error(String(error));
|
|
}
|
|
}
|
|
|
|
const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
|
|
|
|
function isMaterialFeedback(type: VibeEventType): boolean {
|
|
return MATERIAL_FEEDBACK_EVENTS.has(type);
|
|
}
|