@sriinnu/harmon-core 0.1.0

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/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @sriinnu/harmon-core
2
+
3
+ ![logo](./logo.svg)
4
+
5
+ > Mood-aware session engine with track ranking, energy arc modulation, and adaptive playback.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @sriinnu/harmon-core
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```typescript
16
+ import { createEngine } from '@sriinnu/harmon-core';
17
+
18
+ const engine = createEngine({ provider, playback, store });
19
+ await engine.start({ version: 1, mode: 'focus' });
20
+ engine.on('track.started', (track) => console.log(track.name));
21
+ await engine.stop();
22
+ ```
23
+
24
+ ## API
25
+
26
+ | Export | Description |
27
+ |---|---|
28
+ | `createEngine(config)` | Create a session engine instance |
29
+ | `rankTracks(tracks, policy)` | Score and sort tracks against a session policy |
30
+ | `fetchCandidates(sources)` | Gather candidate tracks from configured providers |
31
+ | `calculateArcModulation(elapsed, arc)` | Compute energy multiplier for current position in arc |
32
+ | `checkRecencyPenalty(track, history)` | Penalize recently played tracks/artists |
33
+ | `SessionEngine` | Engine instance type |
34
+ | `EngineConfig` | Configuration type |
35
+ | `AudioFeatures` | Spotify-style audio feature vector |
36
+ | `SessionState` | Current engine state |
37
+ | `RankedTrack` | Track with computed score |
38
+
39
+ ## Architecture
40
+
41
+ harmon-core is the central orchestrator. It consumes a `SessionPolicy` from harmon-protocol, pulls candidates from provider packages (harmon-spotify, harmon-apple, harmon-youtube), ranks them against soft/hard constraints, and drives playback through the active provider's controller.
42
+
43
+ ## License
44
+
45
+ GNU Affero General Public License v3.0 only. See [LICENSE](../../LICENSE).
package/SKILL.md ADDED
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: harmon-core
3
+ description: Session engine with track ranking, candidate sourcing, and energy arc modulation
4
+ capabilities:
5
+ - Create and manage music listening sessions with policy-driven behavior
6
+ - Rank candidate tracks using soft weights, hard constraints, and recency penalties
7
+ - Fetch candidates from multiple music providers and blend discovery ratios
8
+ tags:
9
+ - engine
10
+ - ranking
11
+ - session
12
+ - music
13
+ provider: harmon
14
+ version: 0.1.0
15
+ ---
16
+
17
+ # Harmon Core
18
+
19
+ ## What this does
20
+ harmon-core is the decision-making engine at the heart of harmon. It accepts a SessionPolicy, sources candidate tracks from one or more MusicProvider adapters, ranks them according to soft weights, energy arcs, and hard constraints, and drives playback through a PlaybackController. It also tracks play history to apply recency penalties and prevent repetition.
21
+
22
+ ## When to use
23
+ - Building a new session orchestrator or daemon that needs track selection logic
24
+ - Integrating a new music provider that must participate in ranking and queue filling
25
+ - Customizing how tracks are scored, filtered, or ordered during a session
26
+
27
+ ## Key exports
28
+ - `createEngine` — factory that wires providers, store, and policy into a running SessionEngine
29
+ - `MusicProvider` — interface for any streaming backend (Spotify, Apple, YouTube, etc.)
30
+ - `PlaybackController` — interface for play/pause/skip/seek on a device
31
+ - `AudioFeatures` — normalized audio feature vector (energy, tempo, valence, etc.)
32
+ - `rankTracks` — pure function that scores and sorts a list of TrackWithFeatures
33
+ - `fetchCandidates` — pulls tracks from configured sources and deduplicates them
34
+
35
+ ## Example
36
+ ```typescript
37
+ import { createEngine } from '@sriinnu/harmon-core';
38
+
39
+ const engine = createEngine({
40
+ providers: [spotifyProvider],
41
+ playback: spotifyPlayback,
42
+ store,
43
+ policy: { version: 1, mode: 'focus' },
44
+ });
45
+ engine.on('track.started', (e) => console.log(e));
46
+ await engine.start();
47
+ ```
package/dist/arc.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Energy arc modulation for session progression
3
+ */
4
+ import type { EnergyArc } from '@sriinnu/harmon-protocol';
5
+ /**
6
+ * Calculate energy arc modulation based on session progress
7
+ *
8
+ * Returns: -0.5 to +0.5 modifier to apply to target energy
9
+ *
10
+ * @param elapsedMs Milliseconds elapsed since session start
11
+ * @param arc Energy arc configuration
12
+ * @param durationMs Total session duration (optional)
13
+ * @returns Energy modulation value
14
+ */
15
+ export declare function calculateArcModulation(elapsedMs: number, arc?: EnergyArc, durationMs?: number): number;
16
+ //# sourceMappingURL=arc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arc.d.ts","sourceRoot":"","sources":["../src/arc.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,MAAM,EACjB,GAAG,CAAC,EAAE,SAAS,EACf,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAsER"}
package/dist/arc.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Energy arc modulation for session progression
3
+ */
4
+ /**
5
+ * Calculate energy arc modulation based on session progress
6
+ *
7
+ * Returns: -0.5 to +0.5 modifier to apply to target energy
8
+ *
9
+ * @param elapsedMs Milliseconds elapsed since session start
10
+ * @param arc Energy arc configuration
11
+ * @param durationMs Total session duration (optional)
12
+ * @returns Energy modulation value
13
+ */
14
+ export function calculateArcModulation(elapsedMs, arc, durationMs) {
15
+ if (!arc || !arc.shape || arc.shape === 'flat') {
16
+ return 0;
17
+ }
18
+ const totalDuration = durationMs || 3600000; // Default 1 hour
19
+ const progress = Math.min(elapsedMs / totalDuration, 1); // 0-1
20
+ const warmupMs = arc.warmupMs || 0;
21
+ const cooldownMs = arc.cooldownMs || 0;
22
+ const warmupProgress = warmupMs > 0 ? Math.min(elapsedMs / warmupMs, 1) : 1;
23
+ const cooldownProgress = cooldownMs > 0 && durationMs
24
+ ? Math.max(0, (elapsedMs - (durationMs - cooldownMs)) / cooldownMs)
25
+ : 0;
26
+ switch (arc.shape) {
27
+ case 'ramp-up': {
28
+ // Start low (-0.3), end high (+0.3)
29
+ if (warmupProgress < 1) {
30
+ return -0.3 * (1 - warmupProgress); // Warming up: -0.3 → 0
31
+ }
32
+ // Post-warmup: use adjusted progress for continuity
33
+ // At warmup end, this yields 0. At session end, yields +0.3.
34
+ const postWarmupProgress = warmupMs > 0
35
+ ? Math.min((elapsedMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1)
36
+ : progress;
37
+ // Without warmup: -0.3 + 0.6*progress (full range -0.3 to +0.3)
38
+ // With warmup: 0 + 0.3*postProgress (0 to +0.3, continuous from warmup end)
39
+ if (warmupMs > 0) {
40
+ return 0.3 * postWarmupProgress;
41
+ }
42
+ return -0.3 + (0.6 * progress);
43
+ }
44
+ case 'ramp-down': {
45
+ // Start high (+0.3), end low (-0.3)
46
+ if (warmupProgress < 1) {
47
+ return 0.3 * warmupProgress; // Warming up: 0 → +0.3
48
+ }
49
+ if (cooldownProgress > 0) {
50
+ // During cooldown, ramp to negative
51
+ const preCooldownValue = warmupMs > 0
52
+ ? (() => {
53
+ const postProg = Math.min((durationMs - cooldownMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1);
54
+ return 0.3 - (0.6 * postProg);
55
+ })()
56
+ : 0.3 - (0.6 * ((durationMs - cooldownMs) / totalDuration));
57
+ return preCooldownValue + ((-0.3 - preCooldownValue) * cooldownProgress);
58
+ }
59
+ // Post-warmup: linear ramp from +0.3 to -0.3
60
+ if (warmupMs > 0) {
61
+ const postWarmupProgress = Math.min((elapsedMs - warmupMs) / Math.max(totalDuration - warmupMs, 1), 1);
62
+ return 0.3 - (0.6 * postWarmupProgress);
63
+ }
64
+ return 0.3 - (0.6 * progress);
65
+ }
66
+ case 'wave':
67
+ // Sine wave: low -> high -> low
68
+ if (warmupProgress < 1) {
69
+ return -0.3 * (1 - warmupProgress);
70
+ }
71
+ if (cooldownProgress > 0) {
72
+ return -0.3 * cooldownProgress;
73
+ }
74
+ return 0.3 * Math.sin(progress * Math.PI); // Peak at middle
75
+ default:
76
+ return 0;
77
+ }
78
+ }
79
+ //# sourceMappingURL=arc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"arc.js","sourceRoot":"","sources":["../src/arc.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CACpC,SAAiB,EACjB,GAAe,EACf,UAAmB;IAEnB,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,KAAK,KAAK,MAAM,EAAE,CAAC;QAC/C,OAAO,CAAC,CAAC;IACX,CAAC;IAED,MAAM,aAAa,GAAG,UAAU,IAAI,OAAO,CAAC,CAAE,iBAAiB;IAC/D,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,aAAa,EAAE,CAAC,CAAC,CAAC,CAAE,MAAM;IAEhE,MAAM,QAAQ,GAAG,GAAG,CAAC,QAAQ,IAAI,CAAC,CAAC;IACnC,MAAM,UAAU,GAAG,GAAG,CAAC,UAAU,IAAI,CAAC,CAAC;IACvC,MAAM,cAAc,GAAG,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,GAAG,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5E,MAAM,gBAAgB,GAAG,UAAU,GAAG,CAAC,IAAI,UAAU;QACnD,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,SAAS,GAAG,CAAC,UAAU,GAAG,UAAU,CAAC,CAAC,GAAG,UAAU,CAAC;QACnE,CAAC,CAAC,CAAC,CAAC;IAEN,QAAQ,GAAG,CAAC,KAAK,EAAE,CAAC;QAClB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,oCAAoC;YACpC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC,CAAE,uBAAuB;YAC9D,CAAC;YACD,oDAAoD;YACpD,6DAA6D;YAC7D,MAAM,kBAAkB,GAAG,QAAQ,GAAG,CAAC;gBACrC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;gBAC7E,CAAC,CAAC,QAAQ,CAAC;YACb,gEAAgE;YAChE,4EAA4E;YAC5E,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;gBACjB,OAAO,GAAG,GAAG,kBAAkB,CAAC;YAClC,CAAC;YACD,OAAO,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;QACjC,CAAC;QAED,KAAK,WAAW,CAAC,CAAC,CAAC;YACjB,oCAAoC;YACpC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,GAAG,GAAG,cAAc,CAAC,CAAE,uBAAuB;YACvD,CAAC;YACD,IAAI,gBAAgB,GAAG,CAAC,EAAE,CAAC;gBACzB,oCAAoC;gBACpC,MAAM,gBAAgB,GAAG,QAAQ,GAAG,CAAC;oBACnC,CAAC,CAAC,CAAC,GAAG,EAAE;wBACJ,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,UAAW,GAAG,UAAU,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;wBAC5G,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;oBAChC,CAAC,CAAC,EAAE;oBACN,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,UAAW,GAAG,UAAU,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC;gBAC/D,OAAO,gBAAgB,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,gBAAgB,CAAC,GAAG,gBAAgB,CAAC,CAAC;YAC3E,CAAC;YACD,6CAA6C;YAC7C,IAAI,QAAQ,GAAG,CAAC,EAAE,CAAC;gBACjB,MAAM,kBAAkB,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,SAAS,GAAG,QAAQ,CAAC,GAAG,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,QAAQ,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;gBACvG,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,kBAAkB,CAAC,CAAC;YAC1C,CAAC;YACD,OAAO,GAAG,GAAG,CAAC,GAAG,GAAG,QAAQ,CAAC,CAAC;QAChC,CAAC;QAED,KAAK,MAAM;YACT,gCAAgC;YAChC,IAAI,cAAc,GAAG,CAAC,EAAE,CAAC;gBACvB,OAAO,CAAC,GAAG,GAAG,CAAC,CAAC,GAAG,cAAc,CAAC,CAAC;YACrC,CAAC;YACD,IAAI,gBAAgB,GAAG,CAAC,EAAE,CAAC;gBACzB,OAAO,CAAC,GAAG,GAAG,gBAAgB,CAAC;YACjC,CAAC;YACD,OAAO,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,QAAQ,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC,CAAE,iBAAiB;QAE/D;YACE,OAAO,CAAC,CAAC;IACb,CAAC;AACH,CAAC"}
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Session engine - Core orchestrator for session lifecycle and queue management
3
+ *
4
+ * Provider-agnostic: works with any MusicProvider + PlaybackController.
5
+ */
6
+ import type { SessionPolicy, TrackInfo } from '@sriinnu/harmon-protocol';
7
+ import type { MusicProvider, PlaybackController, SessionState, EventCallback, EngineLogger, SessionStore } from './types.js';
8
+ export interface SessionEngine {
9
+ start(policy: SessionPolicy): Promise<void>;
10
+ stop(): Promise<void>;
11
+ pause(): Promise<void>;
12
+ resume(): Promise<void>;
13
+ nudge(direction: 'calmer' | 'sharper', amount?: number): Promise<void>;
14
+ getQueue(): TrackInfo[];
15
+ getState(): SessionState | null;
16
+ refillQueue(): Promise<void>;
17
+ recordPlay(track: TrackInfo): Promise<void>;
18
+ }
19
+ export interface EngineConfig {
20
+ provider: MusicProvider;
21
+ playback: PlaybackController;
22
+ store: SessionStore;
23
+ onEvent?: EventCallback;
24
+ /** Optional logger for engine internals. Falls back to no-op if omitted. */
25
+ logger?: EngineLogger;
26
+ }
27
+ export declare function createEngine(config: EngineConfig): SessionEngine;
28
+ //# sourceMappingURL=engine.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AACzE,OAAO,KAAK,EACV,aAAa,EACb,kBAAkB,EAClB,YAAY,EACZ,aAAa,EAEb,YAAY,EAEZ,YAAY,EACb,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,aAAa;IAC5B,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,KAAK,CAAC,SAAS,EAAE,QAAQ,GAAG,SAAS,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvE,QAAQ,IAAI,SAAS,EAAE,CAAC;IACxB,QAAQ,IAAI,YAAY,GAAG,IAAI,CAAC;IAChC,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,kBAAkB,CAAC;IAC7B,KAAK,EAAE,YAAY,CAAC;IACpB,OAAO,CAAC,EAAE,aAAa,CAAC;IACxB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AA+VD,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,CAEhE"}
package/dist/engine.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Session engine - Core orchestrator for session lifecycle and queue management
3
+ *
4
+ * Provider-agnostic: works with any MusicProvider + PlaybackController.
5
+ */
6
+ import { fetchCandidates } from './sources.js';
7
+ import { rankTracks } from './ranking.js';
8
+ class SessionEngineImpl {
9
+ provider;
10
+ playback;
11
+ store;
12
+ onEvent;
13
+ logger;
14
+ state = null;
15
+ refillInterval = null;
16
+ refilling = false;
17
+ // Constants
18
+ REFILL_CHECK_INTERVAL_MS = 10000; // Check every 10s
19
+ constructor(config) {
20
+ this.provider = config.provider;
21
+ this.playback = config.playback;
22
+ this.store = config.store;
23
+ this.onEvent = config.onEvent || (() => { });
24
+ this.logger = config.logger || { warn: () => { }, error: () => { } };
25
+ }
26
+ async start(policy) {
27
+ if (this.state !== null) {
28
+ throw new Error('Session already active. Stop current session first.');
29
+ }
30
+ // Create session in store
31
+ const sessionId = await this.store.createSession(JSON.stringify(policy));
32
+ // Initialize state
33
+ this.state = {
34
+ id: sessionId,
35
+ policy,
36
+ startedAt: Date.now(),
37
+ status: 'running',
38
+ history: [],
39
+ currentTrack: null,
40
+ queuedTracks: [],
41
+ };
42
+ // Initial queue fill
43
+ await this.refillQueue();
44
+ // Start auto-refill monitoring
45
+ this.startRefillMonitoring();
46
+ // Emit event
47
+ this.emit({
48
+ type: 'session.started',
49
+ payload: {
50
+ sessionId,
51
+ policy,
52
+ startedAt: this.state.startedAt,
53
+ },
54
+ });
55
+ await this.store.logEvent('session.started', { sessionId, policy }, sessionId);
56
+ }
57
+ async stop() {
58
+ if (!this.state) {
59
+ throw new Error('No active session');
60
+ }
61
+ const stoppedState = this.state;
62
+ const elapsedMs = Date.now() - stoppedState.startedAt;
63
+ // Stop monitoring
64
+ this.stopRefillMonitoring();
65
+ // Clear the live session before store finalization so a persistence error
66
+ // cannot strand a ghost in-memory session.
67
+ this.state = null;
68
+ // End session in store
69
+ await this.store.endSession(stoppedState.id);
70
+ await this.store.logEvent('session.stopped', { sessionId: stoppedState.id }, stoppedState.id);
71
+ // Emit event
72
+ this.emit({
73
+ type: 'session.stopped',
74
+ payload: {
75
+ sessionId: stoppedState.id,
76
+ elapsedMs,
77
+ duration: elapsedMs,
78
+ durationMs: elapsedMs,
79
+ },
80
+ });
81
+ }
82
+ async pause() {
83
+ if (!this.state) {
84
+ throw new Error('No active session');
85
+ }
86
+ this.state.status = 'paused';
87
+ this.stopRefillMonitoring();
88
+ }
89
+ async resume() {
90
+ if (!this.state) {
91
+ throw new Error('No active session');
92
+ }
93
+ this.state.status = 'running';
94
+ this.startRefillMonitoring();
95
+ }
96
+ async nudge(direction, amount = 0.1) {
97
+ if (!this.state) {
98
+ throw new Error('No active session');
99
+ }
100
+ const sign = direction === 'calmer' ? -1 : 1;
101
+ const policy = this.state.policy;
102
+ const previousPolicy = policy;
103
+ const previousQueue = [...this.state.queuedTracks];
104
+ // Update soft weights
105
+ const currentWeights = policy.soft?.weights || {};
106
+ const newWeights = { ...currentWeights };
107
+ // Adjust energy and valence — clamp to protocol range [-1, 1]
108
+ if (typeof newWeights.energy === 'number') {
109
+ newWeights.energy = clamp(newWeights.energy + sign * amount, -1, 1);
110
+ }
111
+ else {
112
+ newWeights.energy = clamp(0.5 + sign * amount, -1, 1);
113
+ }
114
+ if (typeof newWeights.valence === 'number') {
115
+ newWeights.valence = clamp(newWeights.valence + sign * amount * 0.5, -1, 1);
116
+ }
117
+ else {
118
+ newWeights.valence = clamp(0.5 + sign * amount * 0.5, -1, 1);
119
+ }
120
+ // Update policy
121
+ this.state.policy = {
122
+ ...policy,
123
+ soft: {
124
+ ...policy.soft,
125
+ weights: newWeights,
126
+ },
127
+ };
128
+ // Clear queue and refill with new weights
129
+ this.state.queuedTracks = [];
130
+ await this.refillQueue();
131
+ if (this.state.queuedTracks.length === 0) {
132
+ this.state.policy = previousPolicy;
133
+ this.state.queuedTracks = previousQueue;
134
+ throw new Error('Nudge could not refill the queue with the updated session policy.');
135
+ }
136
+ await this.store.logEvent('session.nudged', { direction, amount, newWeights }, this.state.id);
137
+ this.emit({
138
+ type: 'session.nudged',
139
+ payload: {
140
+ sessionId: this.state.id,
141
+ direction,
142
+ amount,
143
+ newWeights,
144
+ },
145
+ });
146
+ }
147
+ getQueue() {
148
+ return this.state?.queuedTracks || [];
149
+ }
150
+ getState() {
151
+ return this.state;
152
+ }
153
+ async refillQueue() {
154
+ if (!this.state || this.refilling) {
155
+ return;
156
+ }
157
+ this.refilling = true;
158
+ try {
159
+ const policy = this.state.policy;
160
+ const queuePrefs = policy.queue || {};
161
+ const targetDepth = queuePrefs.target || 12;
162
+ const refillThreshold = queuePrefs.refillWhenBelow || 5;
163
+ const currentDepth = this.state.queuedTracks.length;
164
+ if (currentDepth >= refillThreshold) {
165
+ return; // Queue still healthy
166
+ }
167
+ const needed = targetDepth - currentDepth;
168
+ // Fetch candidates via provider
169
+ const candidates = await fetchCandidates(this.provider, policy.sources || {}, needed * 3, // Fetch 3x needed for filtering
170
+ this.logger);
171
+ if (candidates.length === 0) {
172
+ this.emit({
173
+ type: 'error',
174
+ payload: { message: 'No candidates found for queue refill' },
175
+ });
176
+ return;
177
+ }
178
+ // Rank tracks
179
+ const ranked = await rankTracks(candidates, policy, this.state.history, this.getElapsedMs());
180
+ // Take top N
181
+ const topTracks = ranked.slice(0, needed);
182
+ // Add to playback queue
183
+ for (const { track } of topTracks) {
184
+ if (track.uri) {
185
+ await this.playback.addToQueue(track.uri, track);
186
+ }
187
+ }
188
+ // Update local queue state
189
+ this.state.queuedTracks.push(...topTracks.map(r => r.track));
190
+ // Emit event
191
+ this.emit({
192
+ type: 'queue.refilled',
193
+ payload: {
194
+ sessionId: this.state.id,
195
+ added: topTracks.length,
196
+ queueDepth: this.state.queuedTracks.length,
197
+ },
198
+ });
199
+ await this.store.logEvent('queue.refilled', { added: topTracks.length, queueDepth: this.state.queuedTracks.length }, this.state.id);
200
+ }
201
+ catch (error) {
202
+ this.emit({
203
+ type: 'error',
204
+ payload: {
205
+ message: error instanceof Error ? error.message : 'Queue refill failed',
206
+ },
207
+ });
208
+ }
209
+ finally {
210
+ this.refilling = false;
211
+ }
212
+ }
213
+ // Track when a track finishes (called by daemon monitoring)
214
+ async recordPlay(track) {
215
+ if (!this.state) {
216
+ return;
217
+ }
218
+ const record = {
219
+ trackId: track.id,
220
+ artistIds: track.artistIds && track.artistIds.length > 0
221
+ ? track.artistIds
222
+ : [track.artist],
223
+ playedAt: Date.now(),
224
+ };
225
+ this.state.history.push(record);
226
+ // Trim history to last 48 hours to prevent unbounded memory growth.
227
+ // 48h covers the widest repetition limit (repeatTrackWithinDays: 1 = 24h)
228
+ // plus a generous safety margin.
229
+ const HISTORY_RETENTION_MS = 48 * 60 * 60 * 1000;
230
+ const cutoff = Date.now() - HISTORY_RETENTION_MS;
231
+ if (this.state.history.length > 100) {
232
+ this.state.history = this.state.history.filter(r => r.playedAt >= cutoff);
233
+ }
234
+ this.state.currentTrack = track;
235
+ // Remove from queue
236
+ this.state.queuedTracks = this.state.queuedTracks.filter(t => t.id !== track.id);
237
+ // Emit engine event so the daemon can broadcast to SSE clients
238
+ this.emit({
239
+ type: 'track.started',
240
+ payload: {
241
+ sessionId: this.state.id,
242
+ playedAt: record.playedAt,
243
+ track,
244
+ },
245
+ });
246
+ await this.store.logEvent('track.started', {
247
+ playedAt: record.playedAt,
248
+ track,
249
+ }, this.state.id);
250
+ }
251
+ startRefillMonitoring() {
252
+ if (this.refillInterval !== null) {
253
+ return;
254
+ }
255
+ this.refillInterval = setInterval(() => {
256
+ this.refillQueue().catch(err => {
257
+ this.logger.error({ error: err instanceof Error ? err.message : String(err) }, 'Auto-refill failed');
258
+ });
259
+ }, this.REFILL_CHECK_INTERVAL_MS);
260
+ }
261
+ stopRefillMonitoring() {
262
+ if (this.refillInterval !== null) {
263
+ clearInterval(this.refillInterval);
264
+ this.refillInterval = null;
265
+ }
266
+ }
267
+ emit(event) {
268
+ try {
269
+ this.onEvent(event);
270
+ }
271
+ catch (error) {
272
+ this.logger.error({ error: error instanceof Error ? error.message : String(error) }, 'Event callback error');
273
+ }
274
+ }
275
+ getElapsedMs() {
276
+ return this.state ? Date.now() - this.state.startedAt : 0;
277
+ }
278
+ }
279
+ function clamp(value, min, max) {
280
+ return Math.min(max, Math.max(min, value));
281
+ }
282
+ export function createEngine(config) {
283
+ return new SessionEngineImpl(config);
284
+ }
285
+ //# sourceMappingURL=engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engine.js","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Play history tracking and recency penalty calculation
3
+ */
4
+ import type { RepetitionLimits } from '@sriinnu/harmon-protocol';
5
+ import type { TrackWithFeatures, PlayRecord } from './types.js';
6
+ /**
7
+ * Calculate recency penalty (0-1)
8
+ *
9
+ * 0 = no penalty, 1 = maximum penalty (completely exclude)
10
+ *
11
+ * @param track Track to check
12
+ * @param history Play history
13
+ * @param limits Repetition limits from policy
14
+ * @returns Penalty value 0-1
15
+ */
16
+ export declare function checkRecencyPenalty(track: TrackWithFeatures, history: PlayRecord[], limits?: RepetitionLimits): number;
17
+ /**
18
+ * Get tracks played in the last N hours
19
+ *
20
+ * @param history Play history
21
+ * @param hoursAgo Hours to look back
22
+ * @returns Recent play records
23
+ */
24
+ export declare function getRecentPlays(history: PlayRecord[], hoursAgo: number): PlayRecord[];
25
+ /**
26
+ * Get unique artists from history
27
+ *
28
+ * @param history Play history
29
+ * @param hoursAgo Hours to look back
30
+ * @returns Array of artist IDs
31
+ */
32
+ export declare function getRecentArtists(history: PlayRecord[], hoursAgo: number): string[];
33
+ //# sourceMappingURL=history.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history.d.ts","sourceRoot":"","sources":["../src/history.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACjE,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEhE;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,iBAAiB,EACxB,OAAO,EAAE,UAAU,EAAE,EACrB,MAAM,CAAC,EAAE,gBAAgB,GACxB,MAAM,CA+CR;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,UAAU,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,UAAU,EAAE,CAGd;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,UAAU,EAAE,EACrB,QAAQ,EAAE,MAAM,GACf,MAAM,EAAE,CAWV"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Play history tracking and recency penalty calculation
3
+ */
4
+ /**
5
+ * Calculate recency penalty (0-1)
6
+ *
7
+ * 0 = no penalty, 1 = maximum penalty (completely exclude)
8
+ *
9
+ * @param track Track to check
10
+ * @param history Play history
11
+ * @param limits Repetition limits from policy
12
+ * @returns Penalty value 0-1
13
+ */
14
+ export function checkRecencyPenalty(track, history, limits) {
15
+ if (!limits) {
16
+ return 0;
17
+ }
18
+ const now = Date.now();
19
+ let penalty = 0;
20
+ // Check track repetition
21
+ if (limits.repeatTrackWithinDays) {
22
+ const windowMs = limits.repeatTrackWithinDays * 24 * 60 * 60 * 1000;
23
+ const recentPlay = history.find(r => r.trackId === track.id && (now - r.playedAt) < windowMs);
24
+ if (recentPlay) {
25
+ // Full penalty if played within window
26
+ penalty = Math.max(penalty, 1.0);
27
+ }
28
+ }
29
+ // Check artist repetition
30
+ if (limits.repeatArtistWithinHours && penalty < 1.0) {
31
+ const windowMs = limits.repeatArtistWithinHours * 60 * 60 * 1000;
32
+ // Find recent plays of same artist
33
+ const recentArtistPlays = history.filter(r => {
34
+ const timeSince = now - r.playedAt;
35
+ if (timeSince >= windowMs)
36
+ return false;
37
+ // Use structured artistIds when available, fall back to exact name match
38
+ return r.artistIds.some(aid => {
39
+ if (track.artistIds && track.artistIds.length > 0) {
40
+ return track.artistIds.includes(aid);
41
+ }
42
+ return aid === track.artist;
43
+ });
44
+ });
45
+ if (recentArtistPlays.length > 0) {
46
+ // Graduated penalty based on how many recent plays
47
+ const artistPenalty = Math.min(recentArtistPlays.length * 0.3, 0.8);
48
+ penalty = Math.max(penalty, artistPenalty);
49
+ }
50
+ }
51
+ return penalty;
52
+ }
53
+ /**
54
+ * Get tracks played in the last N hours
55
+ *
56
+ * @param history Play history
57
+ * @param hoursAgo Hours to look back
58
+ * @returns Recent play records
59
+ */
60
+ export function getRecentPlays(history, hoursAgo) {
61
+ const cutoff = Date.now() - (hoursAgo * 60 * 60 * 1000);
62
+ return history.filter(r => r.playedAt >= cutoff);
63
+ }
64
+ /**
65
+ * Get unique artists from history
66
+ *
67
+ * @param history Play history
68
+ * @param hoursAgo Hours to look back
69
+ * @returns Array of artist IDs
70
+ */
71
+ export function getRecentArtists(history, hoursAgo) {
72
+ const recent = getRecentPlays(history, hoursAgo);
73
+ const artists = new Set();
74
+ for (const record of recent) {
75
+ for (const artistId of record.artistIds) {
76
+ artists.add(artistId);
77
+ }
78
+ }
79
+ return Array.from(artists);
80
+ }
81
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"history.js","sourceRoot":"","sources":["../src/history.ts"],"names":[],"mappings":"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"}