feat(vibe): plan musical arcs and callbacks

This commit is contained in:
kami
2026-08-02 00:49:27 +04:00
parent 89a23e3703
commit fe13798c99
7 changed files with 910 additions and 42 deletions
@@ -1,5 +1,6 @@
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate } from './generators.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
@@ -67,7 +68,7 @@ export interface VibeSessionResponse {
export class VibeSessionCoordinator {
constructor(
private readonly db: DbService,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState'>,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
) {}
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
@@ -111,15 +112,18 @@ export class VibeSessionCoordinator {
policyVersion,
intent: input.intent ?? null,
horizonTracks: candidates.length,
...(candidates[0]?.plan?.objective ?? {}),
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: ordinal === 0 ? 'next' : null,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
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,
})),
});
@@ -216,11 +220,24 @@ export class VibeSessionCoordinator {
// 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;
const candidates = seedTrackId
? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, {
excludedTrackIds: new Set([seedTrackId]),
})
: await this.director.buildPlan(userId, sessionId, input.trackId);
// 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,
@@ -232,15 +249,18 @@ export class VibeSessionCoordinator {
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: ordinal === 0 ? 'next' : null,
slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null),
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
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,
})),
});
@@ -303,6 +323,37 @@ export class VibeSessionCoordinator {
};
}
/** 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);