diff --git a/backend/src/db/migrations.test.ts b/backend/src/db/migrations.test.ts index 148c336..6c2462f 100644 --- a/backend/src/db/migrations.test.ts +++ b/backend/src/db/migrations.test.ts @@ -43,3 +43,19 @@ describe('Vibe durable session migration', () => { expect(migration!.sql).toContain('idx_vibe_events_user_occurred'); }); }); + +describe('Vibe context memory migration', () => { + it('adds bounded session exploration state and exactly-once projection storage', () => { + const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration'); + expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles'); + expect(migration?.sql).toContain('exploration_coefficient'); + expect(migration?.sql).toContain('vibe_session_feedback_projections'); + }); + + it('backfills profiles for durable sessions created before context memory', () => { + const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill'); + expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles'); + expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions'); + expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING'); + }); +}); diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts index ca57ab3..7ea215e 100644 --- a/backend/src/db/migrations.ts +++ b/backend/src/db/migrations.ts @@ -720,4 +720,43 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + // Session-specific exploration, goals, and deliberately lossy session + // fingerprints are derived from the immutable Vibe ledger. Keeping them + // separate from listener_beliefs prevents a transient session from + // rewriting permanent taste. + id: '20260802_vibe_context_memory_exploration', + sql: ` + CREATE TABLE IF NOT EXISTS vibe_session_profiles ( + session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb, + goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb, + exploration_coefficient REAL NOT NULL DEFAULT 0.30 + CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1), + discovery_radius REAL NOT NULL DEFAULT 0.38 + CHECK (discovery_radius >= 0 AND discovery_radius <= 1), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated + ON vibe_session_profiles (user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `, + }, + { + // The profile table was introduced after durable sessions. Backfill every + // pre-existing session before feedback can claim its exactly-once marker; + // newly created sessions receive their context-derived initial goals in + // createVibeSession's transaction. + id: '20260802_vibe_session_profile_backfill', + sql: ` + INSERT INTO vibe_session_profiles (session_id, user_id) + SELECT id, user_id FROM vibe_sessions + ON CONFLICT (session_id) DO NOTHING; + `, + }, ]; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 105936a..dcbba8b 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -543,6 +543,23 @@ CREATE TABLE IF NOT EXISTS vibe_sessions ( CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event ON vibe_sessions (user_id, last_event_at DESC); +-- Compact, derived memory for the session director. Fingerprints are a +-- deliberately lossy description of session shape (not a track list) and are +-- used only as a soft freshness signal against recent sessions. +CREATE TABLE IF NOT EXISTS vibe_session_profiles ( + session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb, + goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb, + exploration_coefficient REAL NOT NULL DEFAULT 0.30 + CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1), + discovery_radius REAL NOT NULL DEFAULT 0.38 + CHECK (discovery_radius >= 0 AND discovery_radius <= 1), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated + ON vibe_session_profiles (user_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS vibe_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), client_event_id UUID, @@ -570,6 +587,14 @@ CREATE TABLE IF NOT EXISTS vibe_event_projections ( projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +-- Separate from listener-belief projection because exploration is session +-- state. It lets a failed post-event replan safely retry the exact same +-- adaptation without counting the feedback twice. +CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections ( + event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE, + projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + CREATE TABLE IF NOT EXISTS vibe_plan_versions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE, diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts index fc51708..6a45f17 100644 --- a/backend/src/db/types.ts +++ b/backend/src/db/types.ts @@ -258,3 +258,16 @@ export interface VibePlanItem { export interface VibePlan extends VibePlanVersion { items: VibePlanItem[]; } + +/** Derived session-director memory. The ledger remains authoritative; this + * compact row makes fingerprints, bounded goals, and exploration state cheap + * to read while planning. */ +export interface VibeSessionProfile { + session_id: string; + user_id: string; + fingerprint: Record; + goals: Record; + exploration_coefficient: number; + discovery_radius: number; + updated_at: Date; +} diff --git a/backend/src/routes/vibe-sessions.routes.ts b/backend/src/routes/vibe-sessions.routes.ts index 6e2b326..8e0eaa7 100644 --- a/backend/src/routes/vibe-sessions.routes.ts +++ b/backend/src/routes/vibe-sessions.routes.ts @@ -6,6 +6,7 @@ import { VibeSessionNotFoundError, VibePlanNotFoundError, } from '../services/vibe-session-coordinator.service.js'; +import { isValidVibeContext } from '../services/vibe-context.service.js'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -56,6 +57,9 @@ export default async function vibeSessionsRoutes( if (body.context !== undefined && !isObject(body.context)) { return reply.code(400).send({ error: 'context must be an object' }); } + if (isObject(body.context) && !isValidVibeContext(body.context)) { + return reply.code(400).send({ error: 'context contains an invalid structured Vibe value' }); + } if (body.intent !== undefined && typeof body.intent !== 'string') { return reply.code(400).send({ error: 'intent must be a string' }); } @@ -125,6 +129,11 @@ export default async function vibeSessionsRoutes( if (body.payload !== undefined && !isObject(body.payload)) { return reply.code(400).send({ error: 'payload must be an object' }); } + if (body.type === 'context_changed' && isObject(body.payload) + && body.payload.context !== undefined + && (!isObject(body.payload.context) || !isValidVibeContext(body.payload.context))) { + return reply.code(400).send({ error: 'context_changed payload.context must be structured Vibe context' }); + } try { return reply.send(await coordinator.appendEvent(userId, sessionId, { eventId: body.eventId as string | undefined, diff --git a/backend/src/services/db.service.test.ts b/backend/src/services/db.service.test.ts index 8ce3ace..fecad8e 100644 --- a/backend/src/services/db.service.test.ts +++ b/backend/src/services/db.service.test.ts @@ -19,6 +19,46 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType describe('DbService v2 methods', () => { describe('durable Vibe sessions', () => { + it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1', + type: 'completed', occurred_at: new Date(), client_event_id: null, + position_ms: null, duration_ms: null, payload: {}, + } as any; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill + .mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker + .mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity + .mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence + .mockResolvedValueOnce({ rowCount: 1 }) // discovery belief + .mockResolvedValueOnce({ rows: [] }) // no artist/genre targets + .mockResolvedValueOnce({ rows: [] }) // no audio targets + .mockResolvedValueOnce({ rows: [{ + exploration_coefficient: 0.36, discovery_radius: 0.434, + goals: { type: 'familiar', target: 1, progress: 1 }, + }] }) + .mockResolvedValueOnce({ rows: [] }) // session_state projection + .mockResolvedValueOnce({ rows: [] }) // COMMIT + .mockResolvedValueOnce({ rows: [] }) // BEGIN retry + .mockResolvedValueOnce({ rows: [] }) // profile upsert retry + .mockResolvedValueOnce({ rows: [] }) // marker conflict + .mockResolvedValueOnce({ rows: [] }); // COMMIT retry + + await service.projectVibeSessionFeedback(event); + await service.projectVibeSessionFeedback(event); + + expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles'); + expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections'); + expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history'); + expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence'); + expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient'); + expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END'); + expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 })); + expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1); + }); + it('creates, reads, and ends sessions scoped to their user', async () => { const { service, poolQuery, clientQuery } = makeTransactionalService(); const session = { @@ -44,12 +84,59 @@ describe('DbService v2 methods', () => { expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock'); expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE"); expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions'); - expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']); + expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([ + 'user-1', null, expect.any(String), 'v2.1', + JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, + ])); + expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual(expect.objectContaining({ + activity: 'focus', + })); expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2'); expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())'); expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END'); }); + it('normalizes context at the persistence boundary for direct callers', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-02T19:00:00.000Z')); + const { service, clientQuery } = makeTransactionalService(); + const session = { id: 'session-1', user_id: 'user-1', status: 'active' }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // user advisory lock + .mockResolvedValueOnce({ rows: [] }) // active-session lock + .mockResolvedValueOnce({ rows: [session] }) // insert + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + try { + await service.createVibeSession({ + userId: 'user-1', + policyVersion: 'v2.1', + context: { + timeZone: 'UTC', + activity: 'walking', + device: 'phone', + exactCoordinates: '53.1959,50.1002', + browserTelemetry: { batteryPercent: 4, ipAddress: '192.0.2.1' }, + localHour: 3, + weekday: 1, + }, + }); + + const insertParameters = clientQuery.mock.calls[3][1]; + expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]); + expect(JSON.parse(insertParameters[2])).toEqual({ + timeZone: 'UTC', localHour: 19, weekday: 0, dayKind: 'weekend', + activity: 'walking', device: 'phone', + }); + expect(insertParameters.slice(3)).toEqual([ + 'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38, + ]); + } finally { + vi.useRealTimers(); + } + }); + it('replaces an owned active session and writes its terminal event before starting another', async () => { const { service, clientQuery } = makeTransactionalService(); const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' }; @@ -102,6 +189,63 @@ describe('DbService v2 methods', () => { ); }); + it('sanitizes and projects an inserted context change atomically with its ledger event', async () => { + const { service, clientQuery } = makeTransactionalService(); + const event = { + id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null, + type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { context: { activity: 'walking', localHour: 12 } }, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock + .mockResolvedValueOnce({ rows: [event] }) // insert + .mockResolvedValueOnce({ rows: [] }) // session context projection + .mockResolvedValueOnce({ rows: [] }) // legacy session-state projection + .mockResolvedValueOnce({ rows: [] }) // last event timestamp + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', type: 'context_changed', + payload: { + context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, + rawBrowserTelemetry: { battery: 4 }, + }, + }); + + const values = clientQuery.mock.calls[2][1]; + const storedPayload = JSON.parse(values[8]); + expect(storedPayload).toEqual({ context: expect.objectContaining({ activity: 'walking' }) }); + expect(storedPayload.context).not.toHaveProperty('exactCoordinates'); + expect(storedPayload).not.toHaveProperty('rawBrowserTelemetry'); + expect(clientQuery.mock.calls[3][0]).toContain('SET context = $3::jsonb'); + expect(clientQuery.mock.calls[4][0]).toContain("jsonb_build_object('context'"); + }); + + it('does not apply a retry body to an existing context-change event', async () => { + const { service, clientQuery } = makeTransactionalService(); + const canonicalEvent = { + id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null, + type: 'context_changed', occurred_at: new Date(), position_ms: null, duration_ms: null, + payload: { context: { activity: 'focus', localHour: 12 } }, + }; + clientQuery + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock + .mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + const result = await service.recordVibeEvent({ + sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'context_changed', + payload: { context: { activity: 'workout', exactCoordinates: '53.2,50.1' } }, + }); + + expect(result).toEqual({ event: canonicalEvent, inserted: false }); + expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain( + expect.stringContaining('SET context = $3::jsonb'), + ); + }); + it('projects material feedback once with the durable event transaction', async () => { const { service, clientQuery } = makeTransactionalService(); const event = { diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 7c3cb60..6c0a890 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -6,6 +6,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { Pool, PoolClient } from 'pg'; import { SearchService } from './search.service.js'; +import { normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; @@ -51,6 +52,7 @@ import type { RecordedVibeEvent, VibePlan, VibePlanItem, + VibeSessionProfile, } from '../db/types.js'; import { AUDIO_PREFERENCE_BUCKETS, FEEDBACK_ACTIONS } from '../db/types.js'; export * from '../db/types.js'; @@ -1514,7 +1516,17 @@ export class DbService { policyVersion: string; seedTrackId?: string | null; context?: Record; + profile?: { + goals: Record; + explorationCoefficient: number; + discoveryRadius: number; + }; }): Promise { + // DbService is also used directly by workers and migrations. Keep the + // durable storage boundary canonical even when callers bypass the HTTP + // coordinator, so opaque or precise client telemetry can never become + // session context. + const canonicalContext = normalizeVibeContext(params.context ?? {}); return this.withTransaction(async (client) => { // Serialize starts for one listener even when there is no active row to // lock yet. The row lock below then safely replaces any prior session. @@ -1542,14 +1554,25 @@ export class DbService { } } const res = await client.query( - `INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) - VALUES ($1, 'active', $2, $3::jsonb, $4) - RETURNING *`, + `WITH created AS ( + INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version) + VALUES ($1, 'active', $2, $3::jsonb, $4) + RETURNING * + ), profile AS ( + INSERT INTO vibe_session_profiles + (session_id, user_id, goals, exploration_coefficient, discovery_radius) + SELECT id, user_id, $5::jsonb, $6::real, $7::real FROM created + ON CONFLICT (session_id) DO NOTHING + ) + SELECT * FROM created`, [ params.userId, params.seedTrackId ?? null, - JSON.stringify(params.context ?? {}), + JSON.stringify(canonicalContext), params.policyVersion, + JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }), + params.profile?.explorationCoefficient ?? 0.3, + params.profile?.discoveryRadius ?? 0.38, ], ); return res.rows[0] as VibeSession; @@ -1565,6 +1588,141 @@ export class DbService { return (res.rows[0] as VibeSession) ?? null; } + /** Recent session shapes, excluding the active session. The fingerprint is + * intentionally aggregate-only and is used as a soft planning penalty. */ + async getRecentVibeSessionFingerprints(userId: string, sessionId: string, limit = 8): Promise[]> { + const res = await this.pgClient.query( + `SELECT p.fingerprint + FROM vibe_session_profiles p + JOIN vibe_sessions s ON s.id = p.session_id + WHERE p.user_id = $1 AND p.session_id <> $2::uuid + AND s.status IN ('ended', 'expired', 'replaced') + AND p.fingerprint <> '{}'::jsonb + ORDER BY p.updated_at DESC + LIMIT $3`, + [userId, sessionId, limit], + ); + return res.rows.map((row: { fingerprint: Record }) => row.fingerprint ?? {}); + } + + async getVibeSessionProfile(sessionId: string, userId: string): Promise { + const res = await this.pgClient.query( + `SELECT p.* FROM vibe_session_profiles p + JOIN vibe_sessions s ON s.id = p.session_id + WHERE p.session_id = $1 AND s.user_id = $2`, + [sessionId, userId], + ); + return (res.rows[0] as VibeSessionProfile | undefined) ?? null; + } + + /** Replace only the coarse, sanitised context attached to an active session. + * The immutable context_changed event remains the audit trail. */ + async updateVibeSessionContext(sessionId: string, userId: string, context: Record): Promise { + const canonicalContext = normalizeVibeContext(context); + await this.withTransaction(async client => { + const updated = await client.query( + `UPDATE vibe_sessions SET context = $3::jsonb, last_event_at = NOW() + WHERE id = $1 AND user_id = $2 AND status = 'active' + RETURNING id`, + [sessionId, userId, JSON.stringify(canonicalContext)], + ); + if (!updated.rows[0]) throw new Error('Vibe session was not found or is not owned by this user'); + await client.query( + `UPDATE session_state + SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), + state_vector = state_vector || jsonb_build_object('context', $3::jsonb), + last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [sessionId, userId, JSON.stringify(canonicalContext)], + ); + }); + } + + /** + * Project unknown-track feedback into the session exploration controls. + * This deliberately runs behind its own projection marker: the immutable + * event has already committed, so retries after a transient failure are + * safe and converge on one evidence row and one coefficient adjustment. + */ + async projectVibeSessionFeedback(event: VibeEvent): Promise { + if (!event.track_id || !['completed', 'skipped', 'kept'].includes(event.type)) return; + const trackId = event.track_id; + await this.withTransaction(async client => { + // Old active/resumable sessions predate vibe_session_profiles. Create a + // neutral profile before claiming the exactly-once marker: otherwise the + // marker could permanently consume a feedback event without adapting its + // session. Existing goals are deliberately never overwritten here. + await client.query( + `INSERT INTO vibe_session_profiles (session_id, user_id) + VALUES ($1, $2) + ON CONFLICT (session_id) DO NOTHING`, + [event.session_id, event.user_id], + ); + const marker = await client.query( + `INSERT INTO vibe_session_feedback_projections (event_id) + VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, + [event.id], + ); + if (!marker.rows[0]) return; + + // This query occurs before a completed event's play_history projection + // can be considered. Favourites and prior evidence count as familiarity + // too, avoiding a false “new discovery” on a locally known track. + const familiarity = await client.query( + `SELECT ( + EXISTS (SELECT 1 FROM play_history WHERE user_id = $1 AND track_id = $2 AND completed = true AND played_at < $3::timestamptz) + OR EXISTS (SELECT 1 FROM favorites WHERE user_id = $1 AND track_id = $2) + OR EXISTS (SELECT 1 FROM evidence WHERE user_id = $1 AND entity_type = 'track' AND entity_id = $2 AND created_at < $3::timestamptz) + ) AS familiar`, + [event.user_id, trackId, event.occurred_at], + ); + const familiar = Boolean(familiarity.rows[0]?.familiar); + + const delta = event.type === 'skipped' ? -0.08 : event.type === 'completed' ? 0.06 : 0.03; + const signal = event.type === 'skipped' ? 'skip_quick' : 'play_of_never_seen'; + const weight = event.type === 'skipped' ? -0.05 : delta; + if (!familiar) { + await this.recordTrackEvidence({ + user_id: event.user_id, + track_id: trackId, + signal, + profile: event.type === 'skipped' ? 'negative' : 'discovery', + weight, + context: { vibe_event_id: event.id, session_id: event.session_id, unfamiliar: true }, + }, client); + } + + const profile = await client.query( + `UPDATE vibe_session_profiles + SET exploration_coefficient = GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)), + discovery_radius = GREATEST(0.15, LEAST(0.9, 0.2 + (GREATEST(0, LEAST(1, exploration_coefficient + CASE WHEN $4 THEN 0 ELSE $3 END)) * 0.65))), + goals = CASE + WHEN goals->>'type' = 'familiar' AND $4 AND $5 + THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1)))) + WHEN goals->>'type' IN ('discovery', 'surprise', 'artist_introduction') AND NOT $4 AND $3 > 0 + THEN jsonb_set(goals, '{progress}', to_jsonb(LEAST(COALESCE((goals->>'progress')::int, 0) + 1, COALESCE((goals->>'target')::int, 1)))) + ELSE goals END, + updated_at = NOW() + WHERE session_id = $1 AND user_id = $2 + RETURNING exploration_coefficient, discovery_radius, goals`, + [event.session_id, event.user_id, delta, familiar, event.type === 'completed' || event.type === 'kept'], + ); + const row = profile.rows[0] as Pick | undefined; + if (row) { + await client.query( + `UPDATE session_state + SET state_vector = state_vector || jsonb_build_object( + 'explorationCoefficient', $3::real, + 'discoveryRadius', $4::real, + 'sessionGoal', $5::jsonb + ), last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [event.session_id, event.user_id, row.exploration_coefficient, row.discovery_radius, JSON.stringify(row.goals)], + ); + } + }); + } + /** * End (or expire/replace) a session without changing its original end time * when a client retries the same request. @@ -1667,6 +1825,10 @@ export class DbService { durationMs?: number | null; payload?: Record; }): Promise { + // This service is also called by jobs and tests which bypass the HTTP + // route. Preserve the context privacy boundary at the final point before + // an immutable ledger write. + const payload = normalizeVibeEventPayload(params.type, params.payload); return this.withTransaction(async (client) => { // A session-row lock serializes both event writes and terminal state // transitions. In particular, it avoids the READ COMMITTED CTE snapshot @@ -1718,7 +1880,7 @@ export class DbService { occurredAt, params.positionMs ?? null, params.durationMs ?? null, - JSON.stringify(params.payload ?? {}), + JSON.stringify(payload ?? {}), ] ); const event = insertRes.rows[0] as VibeEvent | undefined; @@ -1727,6 +1889,7 @@ export class DbService { } await this.projectVibeFeedback(event, client); + await this.projectVibeContextChanged(event, client); await client.query( `UPDATE vibe_sessions @@ -1738,6 +1901,30 @@ export class DbService { }); } + /** Apply the context projection in the same transaction as its *inserted* + * ledger event. A client-event retry returns before this method, so its body + * can never overwrite session state with a different context. */ + private async projectVibeContextChanged(event: VibeEvent, client: PoolClient): Promise { + if (event.type !== 'context_changed') return; + const context = event.payload?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) return; + const canonicalContext = context as Record; + await client.query( + `UPDATE vibe_sessions + SET context = $3::jsonb + WHERE id = $1 AND user_id = $2 AND status = 'active'`, + [event.session_id, event.user_id, JSON.stringify(canonicalContext)], + ); + await client.query( + `UPDATE session_state + SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'), + state_vector = state_vector || jsonb_build_object('context', $3::jsonb), + last_interaction = NOW() + WHERE session_id = $1 AND user_id = $2`, + [event.session_id, event.user_id, JSON.stringify(canonicalContext)], + ); + } + /** * Materialize Vibe feedback into the listener inputs used by the incumbent * director. The projection marker and every write share the event's @@ -1899,6 +2086,32 @@ export class DbService { feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null, })], ); + // Store a compact session shape rather than a replayable queue. It is + // overwritten on each revision so a recently adapted session represents + // its current direction when tomorrow's session asks for freshness. + await client.query( + `WITH selected AS ( + SELECT i.track_id, i.candidate_source + FROM vibe_plan_items i WHERE i.plan_version_id = $1 + ), artists AS ( + SELECT DISTINCT ta.artist_id::text AS value FROM selected s + JOIN track_artists_v2 ta ON ta.track_id = s.track_id AND ta.role = 'main' + ), genres AS ( + SELECT DISTINCT tg.genre_id::text AS value FROM selected s + JOIN track_genre tg ON tg.track_id = s.track_id + ), sources AS ( + SELECT DISTINCT candidate_source AS value FROM selected + ) + UPDATE vibe_session_profiles + SET fingerprint = jsonb_build_object( + 'artists', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM artists), '[]'::jsonb), + 'genres', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM genres), '[]'::jsonb), + 'sources', COALESCE((SELECT jsonb_agg(value ORDER BY value) FROM sources), '[]'::jsonb), + 'context', (SELECT context FROM vibe_sessions WHERE id = $2) + ), updated_at = NOW() + WHERE session_id = $2 AND user_id = $3`, + [planVersion.id, params.sessionId, params.userId], + ); await client.query( `UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [params.sessionId], diff --git a/backend/src/services/generators.service.ts b/backend/src/services/generators.service.ts index 8612b5f..6b937fd 100644 --- a/backend/src/services/generators.service.ts +++ b/backend/src/services/generators.service.ts @@ -49,6 +49,10 @@ export interface GeneratorContext { lastGenreIds: string[]; context: string | null; noveltyHunger: number; + /** Session-local exploration controls; they never overwrite durable taste. */ + explorationCoefficient?: number; + discoveryRadius?: number; + sessionGoal?: { type: string; target: number; progress: number }; sessionAgeMin: number; }; } diff --git a/backend/src/services/session-director.service.ts b/backend/src/services/session-director.service.ts index e04c621..24f107d 100644 --- a/backend/src/services/session-director.service.ts +++ b/backend/src/services/session-director.service.ts @@ -91,6 +91,33 @@ export interface PlanBuildOptions { retainedPlan?: Candidate[]; } +export interface SessionFingerprint { + artists?: string[]; + genres?: string[]; + sources?: string[]; + context?: Record; +} + +/** A compact Jaccard penalty. It is deliberately capped: last night's shape + * should make an alternative more attractive, not make good music ineligible. */ +export function sessionSimilarityPenalty( + metadata: CandidateConstraintMetadata | undefined, + generatorId: string, + fingerprints: SessionFingerprint[], +): number { + if (!metadata || fingerprints.length === 0) return 0; + let strongest = 0; + for (const fingerprint of fingerprints) { + let matches = 0; + let known = 0; + if (metadata.artistId && fingerprint.artists?.length) { known++; if (fingerprint.artists.includes(metadata.artistId)) matches++; } + if (metadata.genreId && fingerprint.genres?.length) { known++; if (fingerprint.genres.includes(metadata.genreId)) matches++; } + if (fingerprint.sources?.length) { known++; if (fingerprint.sources.includes(generatorId)) matches++; } + if (known > 0) strongest = Math.max(strongest, matches / known); + } + return Math.min(0.18, strongest * 0.18); +} + export interface ArcRange { min?: number; max?: number; @@ -729,6 +756,9 @@ export class SessionDirector { lastGenreIds: (row.state_vector?.lastGenreIds as string[]) ?? [], context: row.context, noveltyHunger: (row.state_vector?.noveltyHunger as number) ?? 0.3, + explorationCoefficient: (row.state_vector?.explorationCoefficient as number) ?? 0.3, + discoveryRadius: (row.state_vector?.discoveryRadius as number) ?? 0.38, + sessionGoal: row.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined, sessionAgeMin: row.started_at ? (Date.now() - new Date(row.started_at).getTime()) / 60000 : 0, @@ -745,6 +775,9 @@ export class SessionDirector { lastGenreIds: (latest.state_vector?.lastGenreIds as string[]) ?? [], context: latest.context, noveltyHunger: (latest.state_vector?.noveltyHunger as number) ?? 0.3, + explorationCoefficient: (latest.state_vector?.explorationCoefficient as number) ?? 0.3, + discoveryRadius: (latest.state_vector?.discoveryRadius as number) ?? 0.38, + sessionGoal: latest.state_vector?.sessionGoal as { type: string; target: number; progress: number } | undefined, sessionAgeMin: latest.started_at ? (Date.now() - new Date(latest.started_at).getTime()) / 60000 : 0, @@ -782,6 +815,9 @@ export class SessionDirector { if (preferredEnergy !== null && (energyBelief.rows[0]?.value ?? 0) > 0) { energy = energy * 0.65 + preferredEnergy * 0.35; } + // Activity/device/time supply a boot prior only. Once playback exists its + // contribution stays small so context never masquerades as feedback. + if (savedState) energy = energy * 0.85 + savedState.energy * 0.15; // Read novelty hunger from discovery profile const noveltyRes = await this.db.pgClient.query( @@ -790,7 +826,12 @@ export class SessionDirector { LIMIT 1`, [userId] ); - const noveltyHunger = (noveltyRes.rows[0]?.value as number) ?? 0.3; + const durableNovelty = (noveltyRes.rows[0]?.value as number) ?? 0.3; + const explorationCoefficient = Math.max(0, Math.min(1, savedState?.explorationCoefficient ?? 0.3)); + const discoveryRadius = Math.max(0.15, Math.min(0.9, savedState?.discoveryRadius ?? 0.38)); + // A contextual/session feedback signal is only a minority of the input; + // long-term discovery belief still stabilises the stream across resumes. + const noveltyHunger = durableNovelty * 0.65 + explorationCoefficient * 0.35; // Last distinct artist IDs from recent completed plays. // Use a subquery to order first, then DISTINCT — avoids PG's rule that @@ -832,6 +873,9 @@ export class SessionDirector { lastGenreIds, context: savedState?.context ?? null, noveltyHunger, + explorationCoefficient, + discoveryRadius, + sessionGoal: savedState?.sessionGoal, sessionAgeMin: age, }; } @@ -1042,6 +1086,11 @@ export class SessionDirector { // D.4 — Arc selection // --------------------------------------------------------------- pickArc(state: GeneratorContext['state']): string { + const goal = state.sessionGoal; + if (goal && goal.progress < goal.target) { + if (goal.type === 'familiar') return 'comfort'; + if (goal.type === 'discovery' || goal.type === 'artist_introduction') return 'discovery'; + } if (state.energy < 0.3) return 'late-night'; if (state.energy > 0.6 && state.noveltyHunger > 0.5) return 'discovery'; if (state.energy > 0.6) return 'energetic'; @@ -1505,6 +1554,7 @@ export class SessionDirector { state: GeneratorContext['state'], repetitionState: RepetitionState, userId = '', + recentSessionFingerprints: SessionFingerprint[] = [], ): Promise { if (candidates.length === 0) return []; @@ -1551,11 +1601,16 @@ export class SessionDirector { const wouldRepeat = repetitionState.recentTrackIds.has(c.trackId) || (!!artistId && repetitionState.recentArtistIds.has(artistId)); + const novelty = candidateNovelty(c, item); + const explorationFit = 1 - Math.abs(novelty - (state.discoveryRadius ?? 0.38)); + const sessionSimilarity = sessionSimilarityPenalty(item, c.generatorId, recentSessionFingerprints); let score = W_ENJOY * c.relevance - W_FATIGUE * avgFatigue + W_DIVERSITY * diversityBonus - + W_ENTROPY * entropyBonus; + + W_ENTROPY * entropyBonus + + 0.08 * explorationFit + - sessionSimilarity; if (wouldRepeat) { score *= 0.1; @@ -1597,6 +1652,9 @@ export class SessionDirector { lastArtistIds: state.lastArtistIds, lastGenreIds: state.lastGenreIds, noveltyHunger: state.noveltyHunger, + explorationCoefficient: state.explorationCoefficient ?? 0.3, + discoveryRadius: state.discoveryRadius ?? 0.38, + sessionGoal: state.sessionGoal ?? { type: 'discovery', target: 1, progress: 0 }, }), ] ); @@ -1659,6 +1717,10 @@ export class SessionDirector { // disliked, or otherwise exposed track can never leak into a replacement // revision for this session. const durableSessionTrackIds = await this.db.getVibeSessionTrackIds(sessionId, userId); + const recentFingerprintReader = (this.db as Partial).getRecentVibeSessionFingerprints; + const recentSessionFingerprints = recentFingerprintReader + ? await recentFingerprintReader.call(this.db, userId, sessionId) as SessionFingerprint[] + : []; // Do not let abundant track-level beliefs crowd out the artist/genre // affinities required by the discovery generators. const beliefGroups = await Promise.all([ @@ -1795,7 +1857,7 @@ export class SessionDirector { return []; } const ranked = await this.rankCandidates( - eligibleCandidates, fatigue, budgets, state, repetitionState, userId + eligibleCandidates, fatigue, budgets, state, repetitionState, userId, recentSessionFingerprints ); const loopDim = await this.detectAntiLoop(state, fatigue, budgets, recentPlays); diff --git a/backend/src/services/session-director.test.ts b/backend/src/services/session-director.test.ts index 581035d..87a890f 100644 --- a/backend/src/services/session-director.test.ts +++ b/backend/src/services/session-director.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector } from './session-director.service.js'; +import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js'; import { DbService } from './db.service.js'; import { ALL_GENERATORS } from './generators.service.js'; @@ -425,6 +425,24 @@ describe('SessionDirector', () => { }); + describe('recent session fingerprint penalty', () => { + it('softly penalizes a repeated session shape without excluding it', () => { + const repeated = sessionSimilarityPenalty( + { artistId: 'artist-1', genreId: 'genre-1' }, + 'comfort', + [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }], + ); + const newShape = sessionSimilarityPenalty( + { artistId: 'artist-2', genreId: 'genre-2' }, + 'discovery', + [{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }], + ); + expect(repeated).toBeGreaterThan(0); + expect(repeated).toBeLessThanOrEqual(0.18); + expect(newShape).toBe(0); + }); + }); + describe('sequence constraints', () => { const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' })); const budgets = [ diff --git a/backend/src/services/vibe-context.service.test.ts b/backend/src/services/vibe-context.service.test.ts new file mode 100644 index 0000000..9c1bc5d --- /dev/null +++ b/backend/src/services/vibe-context.service.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { initialVibeState, normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js'; + +describe('Vibe context', () => { + it('keeps only coarse structured values and derives time on the server', () => { + const context = normalizeVibeContext({ + timeZone: 'UTC', activity: 'workout', device: 'headphones', + exactCoordinates: '53.2,50.1', localHour: 3, + }, new Date('2026-08-02T19:00:00.000Z')); + + expect(context).toMatchObject({ localHour: 19, weekday: 0, dayKind: 'weekend', activity: 'workout' }); + expect(context).not.toHaveProperty('exactCoordinates'); + expect(context).not.toHaveProperty('localHour', 3); + }); + + it('uses context only as a bounded initial prior and gives focus a comfort goal', () => { + const state = initialVibeState(normalizeVibeContext({ activity: 'focus' }, new Date('2026-08-03T12:00:00.000Z'))); + expect(state.energy).toBeGreaterThan(0); + expect(state.energy).toBeLessThan(1); + expect(state.sessionGoal).toEqual({ type: 'familiar', target: 1, progress: 0 }); + }); + + it('persists only canonical context for context-change events', () => { + const payload = normalizeVibeEventPayload('context_changed', { + context: { activity: 'walking', exactCoordinates: '53.2,50.1', adId: 'do-not-store' }, + rawBrowserTelemetry: { battery: 4 }, + }); + + expect(payload).toEqual({ + context: expect.objectContaining({ activity: 'walking' }), + }); + expect(payload).not.toHaveProperty('rawBrowserTelemetry'); + expect((payload?.context as Record)).not.toHaveProperty('exactCoordinates'); + expect((payload?.context as Record)).not.toHaveProperty('adId'); + }); +}); diff --git a/backend/src/services/vibe-context.service.ts b/backend/src/services/vibe-context.service.ts new file mode 100644 index 0000000..4c3b497 --- /dev/null +++ b/backend/src/services/vibe-context.service.ts @@ -0,0 +1,120 @@ +/** + * Coarse, opt-in context accepted by the durable Vibe API. It deliberately + * has no precise location, identifiers, or browser telemetry: clients can + * supply a hint, but the server owns the time fields and can ignore all of it. + */ +export const VIBE_CONTEXT_VALUES = { + device: ['desktop', 'phone', 'speaker', 'car', 'headphones'] as const, + activity: ['focus', 'relax', 'walking', 'workout', 'social', 'unknown'] as const, + locationCategory: ['home', 'work', 'gym', 'travel', 'unknown'] as const, + weather: ['clear', 'rain', 'snow', 'hot', 'cold', 'unknown'] as const, + source: ['current_track', 'artist', 'genre', 'surprise', 'resume'] as const, +}; + +export interface VibeContext { + timeZone?: string; + localHour?: number; + weekday?: number; + dayKind?: 'weekday' | 'weekend' | 'holiday'; + device?: (typeof VIBE_CONTEXT_VALUES.device)[number]; + activity?: (typeof VIBE_CONTEXT_VALUES.activity)[number]; + locationCategory?: (typeof VIBE_CONTEXT_VALUES.locationCategory)[number]; + weather?: (typeof VIBE_CONTEXT_VALUES.weather)[number]; + source?: (typeof VIBE_CONTEXT_VALUES.source)[number]; +} + +export interface InitialVibeState { + contextLabel: string | undefined; + energy: number; + noveltyHunger: number; + explorationCoefficient: number; + discoveryRadius: number; + sessionGoal: { type: 'surprise' | 'familiar' | 'discovery' | 'artist_introduction'; target: number; progress: number }; +} + +const hasValue = (values: T, value: unknown): value is T[number] => + typeof value === 'string' && (values as readonly string[]).includes(value); + +function serverTime(timeZone?: string, now = new Date()): Pick { + // Intl rejects bad IANA names. Falling back to the server clock is safe and + // still makes time a weak prior rather than client-controlled fact. + let zone: string | undefined; + try { + if (timeZone) new Intl.DateTimeFormat('en-US', { timeZone }).format(now); + zone = timeZone; + } catch { /* server-local fallback */ } + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, hour: 'numeric', weekday: 'short', hourCycle: 'h23', + }).formatToParts(now); + const hour = Number(parts.find(part => part.type === 'hour')?.value ?? now.getHours()); + const weekdayName = parts.find(part => part.type === 'weekday')?.value; + const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(weekdayName ?? ''); + return { + ...(zone ? { timeZone: zone } : {}), + localHour: Number.isInteger(hour) ? hour : now.getHours(), + weekday: weekday >= 0 ? weekday : now.getDay(), + dayKind: ([0, 6].includes(weekday >= 0 ? weekday : now.getDay()) ? 'weekend' : 'weekday'), + }; +} + +/** Remove unknown fields and derive time server-side. This preserves old + * clients that send an empty object while preventing opaque context blobs from + * becoming a permanent behavioural profile. */ +export function normalizeVibeContext(input: Record = {}, now = new Date()): VibeContext { + const time = serverTime(typeof input.timeZone === 'string' ? input.timeZone : undefined, now); + return { + ...time, + ...(hasValue(VIBE_CONTEXT_VALUES.device, input.device) ? { device: input.device } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.activity, input.activity) ? { activity: input.activity } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.locationCategory, input.locationCategory) ? { locationCategory: input.locationCategory } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.weather, input.weather) ? { weather: input.weather } : {}), + ...(hasValue(VIBE_CONTEXT_VALUES.source, input.source) ? { source: input.source } : {}), + }; +} + +/** + * Context changes are the only event payload with a structured, durable + * context body. Keep their ledger representation intentionally tiny: callers + * cannot smuggle precise location or arbitrary browser telemetry into an + * immutable event by adding sibling fields or unknown context keys. + */ +export function normalizeVibeEventPayload( + type: string, + payload?: Record, +): Record | undefined { + if (type !== 'context_changed') return payload; + const context = payload?.context; + if (!context || typeof context !== 'object' || Array.isArray(context)) return {}; + return { context: normalizeVibeContext(context as Record) }; +} + +/** Context is intentionally a gentle prior. It can nudge the initial arc but + * never overrides observed playback behaviour. */ +export function initialVibeState(context: VibeContext): InitialVibeState { + const activityEnergy: Record = { + focus: 0.42, relax: 0.34, walking: 0.58, workout: 0.72, social: 0.62, unknown: 0.5, + }; + const hour = context.localHour ?? 12; + const hourEnergy = hour < 6 ? 0.32 : hour < 10 ? 0.46 : hour >= 22 ? 0.38 : 0.52; + const activity = context.activity ?? 'unknown'; + const energy = Math.max(0, Math.min(1, activityEnergy[activity] * 0.7 + hourEnergy * 0.3)); + const goal = activity === 'focus' || activity === 'relax' + ? 'familiar' + : activity === 'workout' || activity === 'walking' ? 'surprise' : 'discovery'; + return { + contextLabel: context.activity ?? context.device, + energy, + noveltyHunger: 0.3, + explorationCoefficient: 0.3, + discoveryRadius: 0.38, + sessionGoal: { type: goal, target: 1, progress: 0 }, + }; +} + +export function isValidVibeContext(input: Record): boolean { + const scalar = (key: keyof typeof VIBE_CONTEXT_VALUES) => input[key] === undefined + || hasValue(VIBE_CONTEXT_VALUES[key], input[key]); + return (input.timeZone === undefined || typeof input.timeZone === 'string') + && scalar('device') && scalar('activity') && scalar('locationCategory') + && scalar('weather') && scalar('source'); +} diff --git a/backend/src/services/vibe-session-coordinator.service.test.ts b/backend/src/services/vibe-session-coordinator.service.test.ts index ecf149e..3c771d8 100644 --- a/backend/src/services/vibe-session-coordinator.service.test.ts +++ b/backend/src/services/vibe-session-coordinator.service.test.ts @@ -154,6 +154,44 @@ describe('VibeSessionCoordinator', () => { expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 }); }); + it('normalizes context before the immutable event write', async () => { + const { db, coordinator } = setup(); + (db.recordVibeEvent as any).mockResolvedValueOnce({ + event: { id: 'event-1', type: 'context_changed', payload: {} }, inserted: true, + }); + + await coordinator.appendEvent('user-1', SESSION_ID, { + type: 'context_changed', + payload: { + context: { activity: 'walking', exactCoordinates: '53.2,50.1' }, + rawBrowserTelemetry: { battery: 4 }, + }, + }); + + expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ + payload: { + context: expect.objectContaining({ activity: 'walking' }), + }, + })); + const payload = (db.recordVibeEvent as any).mock.calls[0][0].payload; + expect(payload).not.toHaveProperty('rawBrowserTelemetry'); + expect(payload.context).not.toHaveProperty('exactCoordinates'); + }); + + it('creates the durable profile from the initial context goal', async () => { + const { db, coordinator } = setup(); + + await coordinator.start('user-1', { context: { activity: 'focus' } }); + + expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({ + profile: expect.objectContaining({ + goals: { type: 'familiar', target: 1, progress: 0 }, + explorationCoefficient: 0.3, + discoveryRadius: 0.38, + }), + })); + }); + it('passes the durable unserved callback tail into the retention-aware replan', async () => { const { db, director, coordinator } = setup(); (db.getVibePlan as any).mockResolvedValue({ diff --git a/backend/src/services/vibe-session-coordinator.service.ts b/backend/src/services/vibe-session-coordinator.service.ts index 39779d7..ad0e0db 100644 --- a/backend/src/services/vibe-session-coordinator.service.ts +++ b/backend/src/services/vibe-session-coordinator.service.ts @@ -1,6 +1,12 @@ import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; import { Candidate } from './generators.service.js'; +import { + initialVibeState, + normalizeVibeContext, + normalizeVibeEventPayload, + VibeContext, +} from './vibe-context.service.js'; /** * This is deliberately a narrow bridge between the durable Vibe ledger and @@ -21,7 +27,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number]; export interface StartVibeSessionInput { seedTrackId?: string; - context?: Record; + context?: VibeContext | Record; intent?: string; policyVersion?: string; resumeSessionId?: string; @@ -73,13 +79,19 @@ export class VibeSessionCoordinator { async start(userId: string, input: StartVibeSessionInput): Promise { if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); - const context = input.context ?? {}; + const context = normalizeVibeContext({ ...(input.context ?? {}) }); + const initialState = initialVibeState(context); const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION; const session = await this.db.createVibeSession({ userId, policyVersion, seedTrackId: input.seedTrackId ?? null, - context, + context: { ...context }, + profile: { + goals: initialState.sessionGoal, + explorationCoefficient: initialState.explorationCoefficient, + discoveryRadius: initialState.discoveryRadius, + }, }); // session_state is a derived cache used by the current director. Give it @@ -87,8 +99,15 @@ export class VibeSessionCoordinator { // session while the durable tables remain the source of truth. await this.db.createSessionState( userId, - typeof context.activity === 'string' ? context.activity : input.intent, - { energy: 0.5, noveltyHunger: 0.3 }, + initialState.contextLabel ?? input.intent, + { + energy: initialState.energy, + noveltyHunger: initialState.noveltyHunger, + explorationCoefficient: initialState.explorationCoefficient, + discoveryRadius: initialState.discoveryRadius, + sessionGoal: initialState.sessionGoal, + context, + }, session.id, ); await this.db.recordVibeEvent({ @@ -180,6 +199,11 @@ export class VibeSessionCoordinator { input: AppendVibeEventInput, ): Promise { 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 payload = normalizeVibeEventPayload(input.type, input.payload); const result = await this.db.recordVibeEvent({ sessionId, userId, @@ -189,8 +213,13 @@ export class VibeSessionCoordinator { occurredAt: input.occurredAt, positionMs: input.positionMs, durationMs: input.durationMs, - payload: input.payload, + 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).projectVibeSessionFeedback; + if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event); if (!isMaterialFeedback(input.type)) { const response = await this.getPlan(userId, sessionId); return { ...response, event: result.event, idempotent: !result.inserted };