feat(vibe): weigh local calendar context in session planning

The director had no idea what hour or season a session started in, so a
22:00 weeknight and a Sunday morning drew from the same pool. The client
now sends localHour, weekday, month and an optional timeZone; the route
validates and bounds all four, the coordinator threads them through, and
the generators use them as scoring signals.

Also exposes GET /library/stats, which the Home page counts read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-08-05 23:53:22 +04:00
parent 8f33744f8c
commit a7d126787f
9 changed files with 179 additions and 24 deletions
@@ -1,6 +1,6 @@
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
import { Candidate } from './generators.service.js';
import { Candidate, VibeCalendarContext } from './generators.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
@@ -22,6 +22,7 @@ export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
export interface StartVibeSessionInput {
seedTrackId?: string;
resumeSessionId?: string;
context?: VibeCalendarContext;
}
export interface AppendVibeEventInput {
@@ -70,10 +71,15 @@ export class VibeSessionCoordinator {
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
// Vibe has no reliable device/activity/location signal. Start from neutral
// recommendation state and let actual listening behaviour shape the plan.
// 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: 0.5,
energy: contextualEnergy,
noveltyHunger: 0.3,
explorationCoefficient: 0.3,
discoveryRadius: 0.38,
@@ -83,6 +89,7 @@ export class VibeSessionCoordinator {
userId,
policyVersion: DEFAULT_VIBE_POLICY_VERSION,
seedTrackId: input.seedTrackId ?? null,
context: calendar ? { ...calendar } : undefined,
profile: {
goals: initialState.sessionGoal,
explorationCoefficient: initialState.explorationCoefficient,
@@ -95,7 +102,7 @@ export class VibeSessionCoordinator {
// session while the durable tables remain the source of truth.
await this.db.createSessionState(
userId,
undefined,
calendar ? JSON.stringify(calendar) : undefined,
{
energy: initialState.energy,
noveltyHunger: initialState.noveltyHunger,
@@ -109,7 +116,7 @@ export class VibeSessionCoordinator {
sessionId: session.id,
userId,
type: 'session_started',
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION },
payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) },
});
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);