ei-tui 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -49,7 +49,13 @@ export function queueFactScan(context: ExtractionContext, state: StateManager, o
49
49
  const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
50
50
 
51
51
  if (chunks.length === 0) return 0;
52
-
52
+
53
+ // Pre-mark messages before enqueuing — prevents duplicate scans if the
54
+ // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
55
+ for (const chunk of chunks) {
56
+ state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "f");
57
+ }
58
+
53
59
  for (const chunk of chunks) {
54
60
  const prompt = buildHumanFactScanPrompt({
55
61
  persona_name: chunk.personaDisplayName,
@@ -73,7 +79,7 @@ export function queueFactScan(context: ExtractionContext, state: StateManager, o
73
79
  },
74
80
  });
75
81
  }
76
-
82
+
77
83
  return chunks.length;
78
84
  }
79
85
 
@@ -113,7 +119,13 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
113
119
  const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
114
120
 
115
121
  if (chunks.length === 0) return 0;
116
-
122
+
123
+ // Pre-mark messages before enqueuing — prevents duplicate scans if the
124
+ // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
125
+ for (const chunk of chunks) {
126
+ state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "p");
127
+ }
128
+
117
129
  for (const chunk of chunks) {
118
130
  const prompt = buildHumanTopicScanPrompt({
119
131
  persona_name: chunk.personaDisplayName,
@@ -137,7 +149,7 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
137
149
  },
138
150
  });
139
151
  }
140
-
152
+
141
153
  return chunks.length;
142
154
  }
143
155
 
@@ -145,8 +157,12 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
145
157
  const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state));
146
158
 
147
159
  if (chunks.length === 0) return 0;
148
-
149
160
 
161
+ // Pre-mark messages before enqueuing — prevents duplicate scans if the
162
+ // queue check fires again during LLM latency (100ms loop × 5s call = 50 dupes)
163
+ for (const chunk of chunks) {
164
+ state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "o");
165
+ }
150
166
 
151
167
  for (const chunk of chunks) {
152
168
  const prompt = buildHumanPersonScanPrompt({
@@ -171,7 +187,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
171
187
  },
172
188
  });
173
189
  }
174
-
190
+
175
191
  return chunks.length;
176
192
  }
177
193
 
@@ -509,6 +509,66 @@ export class Processor {
509
509
  max_calls_per_interaction: 3,
510
510
  });
511
511
  }
512
+
513
+ // --- Spotify provider ---
514
+ if (!this.stateManager.tools_getProviderById("spotify")) {
515
+ const spotifyProvider: ToolProvider = {
516
+ id: "spotify",
517
+ name: "spotify",
518
+ display_name: "Spotify",
519
+ description:
520
+ "Access your Spotify playback and music library. Connect via Settings → Tool Kits → Spotify.",
521
+ builtin: true,
522
+ config: { spotify_refresh_token: "" },
523
+ enabled: false,
524
+ created_at: now,
525
+ };
526
+ this.stateManager.tools_addProvider(spotifyProvider);
527
+ }
528
+
529
+ // get_currently_playing
530
+ if (!this.stateManager.tools_getByName("get_currently_playing")) {
531
+ this.stateManager.tools_add({
532
+ id: crypto.randomUUID(),
533
+ provider_id: "spotify",
534
+ name: "get_currently_playing",
535
+ display_name: "Currently Playing",
536
+ description:
537
+ "Get the song currently playing on the user's Spotify. Returns artist, title, album, playback state, and progress. Returns nothing_playing if nothing is active.",
538
+ input_schema: {
539
+ type: "object",
540
+ properties: {},
541
+ required: [],
542
+ },
543
+ runtime: "any",
544
+ builtin: true,
545
+ enabled: true,
546
+ created_at: now,
547
+ max_calls_per_interaction: 3,
548
+ });
549
+ }
550
+
551
+ // get_liked_songs
552
+ if (!this.stateManager.tools_getByName("get_liked_songs")) {
553
+ this.stateManager.tools_add({
554
+ id: crypto.randomUUID(),
555
+ provider_id: "spotify",
556
+ name: "get_liked_songs",
557
+ display_name: "Liked Songs",
558
+ description:
559
+ "Get the user's full Spotify liked songs library. Returns an array of { artist, title, added_at }. Results are cached for 30 minutes. Ask the user before calling — it may return thousands of tracks.",
560
+ input_schema: {
561
+ type: "object",
562
+ properties: {},
563
+ required: [],
564
+ },
565
+ runtime: "any",
566
+ builtin: true,
567
+ enabled: true,
568
+ created_at: now,
569
+ max_calls_per_interaction: 1,
570
+ });
571
+ }
512
572
  }
513
573
 
514
574
  async stop(): Promise<void> {
@@ -661,6 +721,14 @@ const toolNextSteps = new Set([
661
721
  rawMessageFetcher: (pName) => this.stateManager.messages_get(pName),
662
722
  tools: tools.length > 0 ? tools : undefined,
663
723
  onEnqueue: (req) => this.stateManager.queue_enqueue(req),
724
+ onProviderConfigUpdate: (providerId, updates) => {
725
+ const provider = this.stateManager.tools_getProviderById(providerId);
726
+ if (provider) {
727
+ this.stateManager.tools_updateProvider(providerId, {
728
+ config: { ...provider.config, ...updates },
729
+ });
730
+ }
731
+ },
664
732
  }
665
733
  );
666
734
 
@@ -35,6 +35,11 @@ export interface QueueProcessorStartOptions {
35
35
  * Injected by Processor pointing to stateManager.queue_enqueue.
36
36
  */
37
37
  onEnqueue?: EnqueueCallback;
38
+ /**
39
+ * Called when a tool executor updates its provider config (e.g. Spotify refresh token rotation).
40
+ * Injected by Processor to persist the updated config back to storage.
41
+ */
42
+ onProviderConfigUpdate?: (providerId: string, updates: Record<string, string>) => void;
38
43
  }
39
44
 
40
45
  export class QueueProcessor {
@@ -46,6 +51,7 @@ export class QueueProcessor {
46
51
  private currentRawMessageFetcher: RawMessageFetcher | undefined;
47
52
  private currentTools: ToolDefinition[] | undefined;
48
53
  private currentOnEnqueue: EnqueueCallback | undefined;
54
+ private currentOnProviderConfigUpdate: ((providerId: string, updates: Record<string, string>) => void) | undefined;
49
55
 
50
56
  getState(): QueueProcessorState {
51
57
  return this.state;
@@ -63,6 +69,7 @@ export class QueueProcessor {
63
69
  this.currentRawMessageFetcher = options?.rawMessageFetcher;
64
70
  this.currentTools = options?.tools;
65
71
  this.currentOnEnqueue = options?.onEnqueue;
72
+ this.currentOnProviderConfigUpdate = options?.onProviderConfigUpdate;
66
73
  this.abortController = new AbortController();
67
74
 
68
75
  this.processRequest(request)
@@ -95,6 +102,7 @@ export class QueueProcessor {
95
102
  this.currentRawMessageFetcher = undefined;
96
103
  this.currentTools = undefined;
97
104
  this.currentOnEnqueue = undefined;
105
+ this.currentOnProviderConfigUpdate = undefined;
98
106
  this.abortController = null;
99
107
  });
100
108
  }
@@ -182,7 +190,7 @@ export class QueueProcessor {
182
190
  appendedHistory.push(assistantMessage as unknown as LLMHistoryMessage);
183
191
  }
184
192
 
185
- const { results } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls);
193
+ const { results } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls, this.currentOnProviderConfigUpdate);
186
194
  for (const result of results) {
187
195
  appendedHistory.push({
188
196
  role: "tool",
@@ -226,7 +234,7 @@ export class QueueProcessor {
226
234
  // LLM stopped — parse and return.
227
235
  // If JSON parse fails, attempt a single reformat pass: treat the prose as
228
236
  // a tool result and ask the same model to convert it to the required JSON shape.
229
- const firstAttempt = this.handleResponseType(request, content ?? "", finishReason);
237
+ const firstAttempt = await this.handleResponseType(request, content ?? "", finishReason);
230
238
  if (!firstAttempt.success && content) {
231
239
  console.log(`[QueueProcessor] HandleToolContinuation: JSON parse failed — attempting reformat pass`);
232
240
  const reformatResult = await this.attemptReformat(request, messages, content);
@@ -275,7 +283,7 @@ export class QueueProcessor {
275
283
 
276
284
  const callCounts = new Map<string, number>();
277
285
  const totalCalls = { count: 0 };
278
- const { results } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls);
286
+ const { results } = await executeToolCalls(toolCalls, activeTools, callCounts, totalCalls, this.currentOnProviderConfigUpdate);
279
287
 
280
288
  for (const result of results) {
281
289
  toolHistory.push({
@@ -338,11 +346,11 @@ export class QueueProcessor {
338
346
  return this.handleResponseType(request, content ?? "", finishReason);
339
347
  }
340
348
 
341
- private handleResponseType(
349
+ private async handleResponseType(
342
350
  request: LLMRequest,
343
351
  content: string,
344
352
  finishReason: string | null
345
- ): LLMResponse {
353
+ ): Promise<LLMResponse> {
346
354
  const cleanedContent = cleanResponseContent(content);
347
355
  switch (request.type) {
348
356
  case "json" as LLMRequestType:
@@ -359,11 +367,11 @@ export class QueueProcessor {
359
367
  }
360
368
  }
361
369
 
362
- private handleJSONResponse(
370
+ private async handleJSONResponse(
363
371
  request: LLMRequest,
364
372
  content: string,
365
373
  finishReason: string | null
366
- ): LLMResponse {
374
+ ): Promise<LLMResponse> {
367
375
  try {
368
376
  const parsed = parseJSONResponse(content);
369
377
  return {
@@ -377,6 +385,8 @@ export class QueueProcessor {
377
385
  console.warn(
378
386
  `[QueueProcessor] JSON parse failed for ${request.next_step}. Payload:\n${content}`
379
387
  );
388
+ const reformatResult = await this.attemptJSONReformat(request, content);
389
+ if (reformatResult) return reformatResult;
380
390
  return {
381
391
  request,
382
392
  success: false,
@@ -444,4 +454,59 @@ export class QueueProcessor {
444
454
  }
445
455
  }
446
456
 
457
+ /**
458
+ * When any JSON request gets back content that won't parse, attempt a single
459
+ * synchronous reformat pass before falling into the backoff retry loop.
460
+ *
461
+ * The system prompt already contains the full JSON schema for this request type
462
+ * (field names, type-specific extras like `category`, `relationship`, `strength`,
463
+ * etc.), so we don't need to repeat any of that here — just hand the model its
464
+ * own output and ask it to clean it up.
465
+ *
466
+ * Returns null if the reformat attempt also fails — caller falls through to normal
467
+ * failure path (backoff retry).
468
+ */
469
+ private async attemptJSONReformat(
470
+ request: LLMRequest,
471
+ malformedContent: string
472
+ ): Promise<LLMResponse | null> {
473
+ const reformatUserPrompt =
474
+ `An earlier version of you responded with the following content, but it could not ` +
475
+ `be parsed as valid JSON. Please reformat it as the JSON object described in your ` +
476
+ `system instructions. Respond with ONLY the JSON object, or \`{}\` if no changes ` +
477
+ `are needed.\n\n---\n${malformedContent}\n---`;
478
+
479
+ try {
480
+ const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
481
+ request.system,
482
+ reformatUserPrompt,
483
+ [], // no message history needed — schema is already in the system prompt
484
+ request.model,
485
+ { signal: this.abortController?.signal },
486
+ this.currentAccounts
487
+ );
488
+
489
+ if (!reformatContent) return null;
490
+
491
+ const cleaned = cleanResponseContent(reformatContent);
492
+ try {
493
+ const parsed = parseJSONResponse(cleaned);
494
+ console.log(`[QueueProcessor] JSON reformat pass succeeded for ${request.next_step} — saved a retry`);
495
+ return {
496
+ request,
497
+ success: true,
498
+ content: cleaned,
499
+ parsed,
500
+ finish_reason: reformatReason ?? undefined,
501
+ };
502
+ } catch {
503
+ console.warn(`[QueueProcessor] JSON reformat pass also failed for ${request.next_step} — falling through to retry`);
504
+ return null;
505
+ }
506
+ } catch (err) {
507
+ console.warn(`[QueueProcessor] JSON reformat LLM call failed for ${request.next_step}: ${err instanceof Error ? err.message : String(err)}`);
508
+ return null;
509
+ }
510
+ }
511
+
447
512
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * get_currently_playing builtin tool
3
+ *
4
+ * Hits GET /me/player/currently-playing — no cache, always real-time.
5
+ * Config: { spotify_refresh_token: "<token>" } from the spotify provider.
6
+ * runtime: "any" (works in Web + TUI)
7
+ */
8
+ import type { ToolExecutor } from "../types.js";
9
+ import { getSpotifyAccessToken, notAuthenticatedError } from "./spotify-auth.js";
10
+
11
+ interface SpotifyCurrentlyPlayingResponse {
12
+ is_playing: boolean;
13
+ progress_ms: number | null;
14
+ item: {
15
+ name: string;
16
+ duration_ms: number;
17
+ artists: Array<{ name: string }>;
18
+ album: { name: string };
19
+ } | null;
20
+ }
21
+
22
+ export const currentlyPlayingExecutor: ToolExecutor = {
23
+ name: "get_currently_playing",
24
+
25
+ async execute(_args: Record<string, unknown>, config?: Record<string, string>, onConfigUpdate?: (updates: Record<string, string>) => void): Promise<string> {
26
+ const refreshToken = config?.spotify_refresh_token?.trim();
27
+ if (!refreshToken) {
28
+ return notAuthenticatedError("get_currently_playing");
29
+ }
30
+
31
+ let accessToken: string;
32
+ try {
33
+ accessToken = await getSpotifyAccessToken(refreshToken, (newToken) => {
34
+ onConfigUpdate?.({ spotify_refresh_token: newToken });
35
+ });
36
+ } catch (err) {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ // If token refresh fails, the refresh token is likely revoked
39
+ return JSON.stringify({
40
+ error: "token_refresh_failed",
41
+ message: msg,
42
+ });
43
+ }
44
+
45
+ const response = await fetch("https://api.spotify.com/v1/me/player/currently-playing", {
46
+ headers: { Authorization: `Bearer ${accessToken}` },
47
+ });
48
+
49
+ // 204 = nothing playing
50
+ if (response.status === 204 || response.status === 200 && !response.headers.get("content-type")?.includes("json")) {
51
+ return JSON.stringify({ nothing_playing: true });
52
+ }
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`Spotify API error (${response.status}): ${await response.text()}`);
56
+ }
57
+
58
+ const data = (await response.json()) as SpotifyCurrentlyPlayingResponse;
59
+
60
+ if (!data.item) {
61
+ return JSON.stringify({ nothing_playing: true });
62
+ }
63
+
64
+ const result = {
65
+ artist: data.item.artists.map((a) => a.name).join(", "),
66
+ title: data.item.name,
67
+ album: data.item.album.name,
68
+ is_playing: data.is_playing,
69
+ progress_ms: data.progress_ms ?? 0,
70
+ duration_ms: data.item.duration_ms,
71
+ };
72
+ return JSON.stringify(result);
73
+ },
74
+
75
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * PKCE helpers — shared by Web (SpotifyAuthButton) and TUI (spotify-auth command).
3
+ *
4
+ * Uses the Web Crypto API (available in both browser and Bun/Node >= 19).
5
+ * All functions are synchronous except generateChallenge which needs crypto.subtle.
6
+ */
7
+
8
+ /** Generate a random code verifier (128 chars, URL-safe base64). */
9
+ export function generateVerifier(): string {
10
+ const array = new Uint8Array(96); // 96 bytes → 128 chars base64url
11
+ crypto.getRandomValues(array);
12
+ return base64url(array);
13
+ }
14
+
15
+ /** Derive the PKCE code challenge (SHA-256 of verifier, base64url). */
16
+ export async function generateChallenge(verifier: string): Promise<string> {
17
+ const encoder = new TextEncoder();
18
+ const data = encoder.encode(verifier);
19
+ const digest = await crypto.subtle.digest("SHA-256", data);
20
+ return base64url(new Uint8Array(digest));
21
+ }
22
+
23
+ /** Exchange an authorization code for tokens (used by both Web and TUI). */
24
+ export async function exchangeCode(params: {
25
+ code: string;
26
+ verifier: string;
27
+ redirectUri: string;
28
+ clientId: string;
29
+ }): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
30
+ const { code, verifier, redirectUri, clientId } = params;
31
+
32
+ const body = new URLSearchParams({
33
+ grant_type: "authorization_code",
34
+ code,
35
+ redirect_uri: redirectUri,
36
+ client_id: clientId,
37
+ code_verifier: verifier,
38
+ });
39
+
40
+ const response = await fetch("https://accounts.spotify.com/api/token", {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
43
+ body: body.toString(),
44
+ });
45
+
46
+ if (!response.ok) {
47
+ const text = await response.text();
48
+ throw new Error(`Spotify token exchange failed (${response.status}): ${text}`);
49
+ }
50
+
51
+ return response.json() as Promise<{
52
+ access_token: string;
53
+ refresh_token: string;
54
+ expires_in: number;
55
+ }>;
56
+ }
57
+
58
+ /** Build the Spotify authorization URL. */
59
+ export function buildAuthUrl(params: {
60
+ clientId: string;
61
+ redirectUri: string;
62
+ scopes: string[];
63
+ challenge: string;
64
+ state?: string;
65
+ }): string {
66
+ const { clientId, redirectUri, scopes, challenge, state } = params;
67
+ const url = new URL("https://accounts.spotify.com/authorize");
68
+ url.searchParams.set("client_id", clientId);
69
+ url.searchParams.set("response_type", "code");
70
+ url.searchParams.set("redirect_uri", redirectUri);
71
+ url.searchParams.set("scope", scopes.join(" "));
72
+ url.searchParams.set("code_challenge", challenge);
73
+ url.searchParams.set("code_challenge_method", "S256");
74
+ if (state) url.searchParams.set("state", state);
75
+ return url.toString();
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Internal helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function base64url(buffer: Uint8Array): string {
83
+ // btoa works in browser; Buffer works in Node/Bun
84
+ let binary: string;
85
+ if (typeof btoa === "function") {
86
+ binary = String.fromCharCode(...buffer);
87
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
88
+ }
89
+ // Node/Bun fallback
90
+ return Buffer.from(buffer).toString("base64url");
91
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Spotify token refresh — shared helper for both Spotify tool executors.
3
+ *
4
+ * Caches the current access token in module scope so multiple tool calls
5
+ * within the same session don't each trigger a refresh round-trip.
6
+ *
7
+ * The refresh token is read from the ToolProvider config at call time,
8
+ * so it always reflects the latest stored value.
9
+ */
10
+
11
+ export const SPOTIFY_CLIENT_ID = "41a10178f66946f78d4a1e265606ba36";
12
+ export const SPOTIFY_SCOPES = ["user-read-currently-playing", "user-library-read"];
13
+
14
+ // Web redirect URI (hosted)
15
+ export const SPOTIFY_WEB_REDIRECT_URI = "https://ei.flare576.com/callback/spotify";
16
+ // TUI redirect URI (fixed port — Spotify rejected bare 127.0.0.1)
17
+ export const SPOTIFY_TUI_REDIRECT_URI = "http://127.0.0.1:4242";
18
+ export const SPOTIFY_TUI_PORT = 4242;
19
+
20
+ interface CachedToken {
21
+ token: string;
22
+ expires_at: number; // Date.now() ms
23
+ }
24
+
25
+ let cachedToken: CachedToken | null = null;
26
+
27
+ /**
28
+ * Get a valid Spotify access token, refreshing if needed.
29
+ * @param refreshToken - The stored refresh token from provider config
30
+ * @param onTokenRotated - Called with the new refresh token if Spotify rotates it
31
+ */
32
+ export async function getSpotifyAccessToken(
33
+ refreshToken: string,
34
+ onTokenRotated?: (newRefreshToken: string) => void
35
+ ): Promise<string> {
36
+ // Return cached token if still valid (60s buffer)
37
+ if (cachedToken && Date.now() < cachedToken.expires_at - 60_000) {
38
+ return cachedToken.token;
39
+ }
40
+
41
+ const body = new URLSearchParams({
42
+ grant_type: "refresh_token",
43
+ refresh_token: refreshToken,
44
+ client_id: SPOTIFY_CLIENT_ID,
45
+ });
46
+
47
+ const response = await fetch("https://accounts.spotify.com/api/token", {
48
+ method: "POST",
49
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
50
+ body: body.toString(),
51
+ });
52
+
53
+ if (!response.ok) {
54
+ const text = await response.text();
55
+ throw new Error(`Spotify token refresh failed (${response.status}): ${text}`);
56
+ }
57
+
58
+ const data = (await response.json()) as {
59
+ access_token: string;
60
+ expires_in: number;
61
+ refresh_token?: string; // Spotify may rotate the refresh token
62
+ };
63
+
64
+ cachedToken = {
65
+ token: data.access_token,
66
+ expires_at: Date.now() + data.expires_in * 1000,
67
+ };
68
+
69
+ // Spotify may rotate the refresh token — persist the new one if provided
70
+ if (data.refresh_token && data.refresh_token !== refreshToken) {
71
+ onTokenRotated?.(data.refresh_token);
72
+ }
73
+
74
+ return cachedToken.token;
75
+ }
76
+
77
+ /** Clear the cached token (call if refresh token changes). */
78
+ export function clearTokenCache(): void {
79
+ cachedToken = null;
80
+ }
81
+
82
+ /** Return a structured "not authenticated" error for the LLM to relay to the user. */
83
+ export function notAuthenticatedError(tool: string): string {
84
+ return JSON.stringify({
85
+ error: "not_authenticated",
86
+ tool,
87
+ message:
88
+ "Spotify is not connected. In the web app: Settings → Tool Kits → Spotify → Connect Spotify. In the TUI: /auth spotify",
89
+ });
90
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * get_liked_songs builtin tool
3
+ *
4
+ * Paginates GET /me/tracks until exhausted, caches in memory for 30 minutes.
5
+ * Returns a flat array of { artist, title, added_at } — the LLM filters/summarizes.
6
+ * Config: { spotify_refresh_token: "<token>" } from the spotify provider.
7
+ * runtime: "any" (works in Web + TUI)
8
+ */
9
+ import type { ToolExecutor } from "../types.js";
10
+ import { getSpotifyAccessToken, notAuthenticatedError } from "./spotify-auth.js";
11
+
12
+ interface SpotifyTrack {
13
+ artist: string;
14
+ title: string;
15
+ added_at: string;
16
+ }
17
+
18
+ interface LikedSongsCache {
19
+ tracks: SpotifyTrack[];
20
+ fetched_at: number; // Date.now() ms
21
+ }
22
+
23
+ interface SpotifyTracksPage {
24
+ items: Array<{
25
+ added_at: string;
26
+ track: {
27
+ name: string;
28
+ artists: Array<{ name: string }>;
29
+ } | null;
30
+ }>;
31
+ next: string | null;
32
+ }
33
+
34
+ const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
35
+
36
+ let likedSongsCache: LikedSongsCache | null = null;
37
+
38
+ /** Clear the in-memory liked songs cache (call on Spotify reconnect/disconnect). */
39
+ export function clearLikedSongsCache(): void {
40
+ likedSongsCache = null;
41
+ }
42
+
43
+ /** Fetch all liked songs from Spotify, paginating 50 at a time. */
44
+ async function fetchAllLikedSongs(accessToken: string): Promise<SpotifyTrack[]> {
45
+ const tracks: SpotifyTrack[] = [];
46
+ let url: string | null = "https://api.spotify.com/v1/me/tracks?limit=50&market=from_token";
47
+
48
+ while (url) {
49
+ const response = await fetch(url, {
50
+ headers: { Authorization: `Bearer ${accessToken}` },
51
+ });
52
+
53
+ if (!response.ok) {
54
+ throw new Error(`Spotify liked songs API error (${response.status}): ${await response.text()}`);
55
+ }
56
+
57
+ const page = (await response.json()) as SpotifyTracksPage;
58
+
59
+ for (const item of page.items) {
60
+ if (!item.track) continue; // local files etc. may have null track
61
+ tracks.push({
62
+ artist: item.track.artists.map((a) => a.name).join(", "),
63
+ title: item.track.name,
64
+ added_at: item.added_at,
65
+ });
66
+ }
67
+
68
+ url = page.next;
69
+ }
70
+
71
+ return tracks;
72
+ }
73
+
74
+ export const likedSongsExecutor: ToolExecutor = {
75
+ name: "get_liked_songs",
76
+
77
+ async execute(_args: Record<string, unknown>, config?: Record<string, string>, onConfigUpdate?: (updates: Record<string, string>) => void): Promise<string> {
78
+ const refreshToken = config?.spotify_refresh_token?.trim();
79
+ if (!refreshToken) {
80
+ return notAuthenticatedError("get_liked_songs");
81
+ }
82
+
83
+ // Return cached result if still fresh
84
+ if (likedSongsCache && Date.now() < likedSongsCache.fetched_at + CACHE_TTL_MS) {
85
+ return JSON.stringify({ tracks: likedSongsCache.tracks, cached: true });
86
+ }
87
+
88
+ let accessToken: string;
89
+ try {
90
+ accessToken = await getSpotifyAccessToken(refreshToken, (newToken) => {
91
+ onConfigUpdate?.({ spotify_refresh_token: newToken });
92
+ });
93
+ } catch (err) {
94
+ const msg = err instanceof Error ? err.message : String(err);
95
+ return JSON.stringify({
96
+ error: "token_refresh_failed",
97
+ message: msg,
98
+ });
99
+ }
100
+
101
+ const tracks = await fetchAllLikedSongs(accessToken);
102
+
103
+ likedSongsCache = { tracks, fetched_at: Date.now() };
104
+ console.log(`[get_liked_songs] fetched and cached ${tracks.length} tracks`);
105
+
106
+ return JSON.stringify({ tracks, cached: false });
107
+ },
108
+ };
@@ -9,6 +9,9 @@
9
9
  import type { ToolDefinition } from "../types.js";
10
10
  import type { ToolCall, ToolResult, ToolExecutor } from "./types.js";
11
11
  import { tavilyWebSearchExecutor, tavilyNewsSearchExecutor } from "./builtin/web-search.js";
12
+ import { currentlyPlayingExecutor } from "./builtin/currently-playing.js";
13
+ import { likedSongsExecutor } from "./builtin/spotify-liked-songs.js";
14
+ // file-read and list-directory are Node-only — imported lazily via registerFileReadExecutor() to avoid
12
15
  // file-read and list-directory are Node-only — imported lazily via registerFileReadExecutor() to avoid
13
16
 
14
17
  /** Hard upper limit on total tool calls per interaction, regardless of individual limits. */
@@ -32,6 +35,8 @@ export function registerExecutor(executor: ToolExecutor): void {
32
35
  // because it requires Processor.searchHumanData injection.
33
36
  registerExecutor(tavilyWebSearchExecutor);
34
37
  registerExecutor(tavilyNewsSearchExecutor);
38
+ registerExecutor(currentlyPlayingExecutor);
39
+ registerExecutor(likedSongsExecutor);
35
40
  // file_read and list_directory are registered lazily via registerFileReadExecutor() — Node/TUI only.
36
41
 
37
42
  /**
@@ -92,7 +97,8 @@ export async function executeToolCalls(
92
97
  calls: ToolCall[],
93
98
  tools: ToolDefinition[],
94
99
  callCounts: Map<string, number>,
95
- totalCalls: { count: number }
100
+ totalCalls: { count: number },
101
+ onProviderConfigUpdate?: (providerId: string, updates: Record<string, string>) => void
96
102
  ): Promise<{ results: ToolResult[]; exhaustedToolNames: Set<string> }> {
97
103
  const results: ToolResult[] = [];
98
104
  const exhaustedToolNames = new Set<string>();
@@ -154,7 +160,10 @@ export async function executeToolCalls(
154
160
 
155
161
  try {
156
162
  console.log(`[Tools] Executing ${call.name} (call ${newCount}/${maxCalls})`);
157
- const result = await executor.execute(call.arguments, definition.config);
163
+ const onConfigUpdate = onProviderConfigUpdate && definition.provider_id
164
+ ? (updates: Record<string, string>) => onProviderConfigUpdate(definition.provider_id, updates)
165
+ : undefined;
166
+ const result = await executor.execute(call.arguments, definition.config, onConfigUpdate);
158
167
  results.push({
159
168
  tool_call_id: call.id,
160
169
  name: call.name,
@@ -23,5 +23,9 @@ export interface ToolExecutor {
23
23
  /** Tool machine name — must match ToolDefinition.name exactly. */
24
24
  name: string;
25
25
  /** Execute the tool. Returns a result string (JSON or plain text). Throws on unrecoverable error. */
26
- execute(args: Record<string, unknown>, config?: Record<string, string>): Promise<string>;
26
+ execute(
27
+ args: Record<string, unknown>,
28
+ config?: Record<string, string>,
29
+ onConfigUpdate?: (updates: Record<string, string>) => void
30
+ ): Promise<string>;
27
31
  }
@@ -0,0 +1,26 @@
1
+ import type { Command } from "./registry.js";
2
+ import { runSpotifyAuth } from "./spotify-auth.js";
3
+
4
+ export const authCommand: Command = {
5
+ name: "auth",
6
+ aliases: [],
7
+ description: "Authenticate with a service (e.g. /auth spotify)",
8
+ usage: "/auth <service> — supported: spotify",
9
+
10
+ async execute(args, ctx) {
11
+ const service = args[0]?.toLowerCase();
12
+
13
+ if (!service) {
14
+ ctx.showNotification("Usage: /auth <service> (supported: spotify)", "error");
15
+ return;
16
+ }
17
+
18
+ switch (service) {
19
+ case "spotify":
20
+ await runSpotifyAuth(ctx);
21
+ break;
22
+ default:
23
+ ctx.showNotification(`Unknown service: ${service}. Supported: spotify`, "error");
24
+ }
25
+ },
26
+ };
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Spotify PKCE auth flow for TUI.
3
+ *
4
+ * Spins up a temporary Bun HTTP server on port 4242, opens the Spotify
5
+ * authorization URL in the user's browser, waits for the redirect, then
6
+ * exchanges the auth code for tokens and stores the refresh token via
7
+ * ctx.ei.updateToolProvider().
8
+ */
9
+ import type { CommandContext } from "./registry.js";
10
+ import { logger } from "../util/logger.js";
11
+ import {
12
+ generateVerifier,
13
+ generateChallenge,
14
+ exchangeCode,
15
+ buildAuthUrl,
16
+ } from "../../../src/core/tools/builtin/pkce.js";
17
+ import {
18
+ SPOTIFY_CLIENT_ID,
19
+ SPOTIFY_SCOPES,
20
+ SPOTIFY_TUI_REDIRECT_URI,
21
+ SPOTIFY_TUI_PORT,
22
+ clearTokenCache,
23
+ } from "../../../src/core/tools/builtin/spotify-auth.js";
24
+ import { clearLikedSongsCache } from "../../../src/core/tools/builtin/spotify-liked-songs.js";
25
+
26
+ export async function runSpotifyAuth(ctx: CommandContext): Promise<void> {
27
+ logger.info("[spotify-auth] runSpotifyAuth() called");
28
+ ctx.showNotification("Starting Spotify auth — opening browser…", "info");
29
+
30
+ const verifier = generateVerifier();
31
+ const challenge = await generateChallenge(verifier);
32
+ logger.info("[spotify-auth] PKCE verifier + challenge generated");
33
+
34
+ const authUrl = buildAuthUrl({
35
+ clientId: SPOTIFY_CLIENT_ID,
36
+ redirectUri: SPOTIFY_TUI_REDIRECT_URI,
37
+ scopes: SPOTIFY_SCOPES,
38
+ challenge,
39
+ });
40
+ logger.info("[spotify-auth] Auth URL built", { redirectUri: SPOTIFY_TUI_REDIRECT_URI });
41
+
42
+ // Start the local server FIRST, then open the browser so the server
43
+ // is already listening when Spotify redirects back.
44
+ logger.info("[spotify-auth] Starting local HTTP server on port", SPOTIFY_TUI_PORT);
45
+ const codePromise = waitForAuthCode(ctx);
46
+
47
+ // Give the server a tick to bind its port before opening the browser
48
+ await new Promise<void>((r) => setTimeout(r, 50));
49
+ logger.info("[spotify-auth] Server should be up — opening browser now");
50
+
51
+ // Open the authorization URL in the user's default browser
52
+ const openCmd = process.platform === "darwin"
53
+ ? "open"
54
+ : process.platform === "win32"
55
+ ? "cmd /c start"
56
+ : "xdg-open";
57
+
58
+ logger.info("[spotify-auth] Spawning browser with", { openCmd });
59
+ Bun.spawn([openCmd, authUrl], { stdio: ["ignore", "ignore", "ignore"] });
60
+ logger.info("[spotify-auth] Browser spawned — awaiting OAuth callback…");
61
+
62
+ const code = await codePromise;
63
+ logger.info("[spotify-auth] codePromise resolved", { gotCode: !!code });
64
+
65
+ if (!code) return; // user cancelled or error already shown
66
+
67
+ ctx.showNotification("Exchanging auth code for tokens…", "info");
68
+
69
+ try {
70
+ logger.info("[spotify-auth] Exchanging code for tokens");
71
+ const tokens = await exchangeCode({
72
+ code,
73
+ verifier,
74
+ redirectUri: SPOTIFY_TUI_REDIRECT_URI,
75
+ clientId: SPOTIFY_CLIENT_ID,
76
+ });
77
+ logger.info("[spotify-auth] Token exchange succeeded — storing refresh token");
78
+
79
+ clearTokenCache();
80
+ clearLikedSongsCache();
81
+ await ctx.ei.updateToolProvider("spotify", {
82
+ config: { spotify_refresh_token: tokens.refresh_token },
83
+ enabled: true,
84
+ });
85
+ logger.info("[spotify-auth] Refresh token stored — done!");
86
+
87
+ ctx.showNotification("✓ Spotify connected successfully!", "info");
88
+ } catch (err) {
89
+ const msg = err instanceof Error ? err.message : String(err);
90
+ logger.error("[spotify-auth] Token exchange failed", { msg });
91
+ ctx.showNotification(`Spotify auth failed: ${msg}`, "error");
92
+ }
93
+ }
94
+
95
+ /** Spin up a one-shot HTTP server on SPOTIFY_TUI_PORT and return the auth code. */
96
+ async function waitForAuthCode(ctx: CommandContext): Promise<string | null> {
97
+ return new Promise<string | null>((resolve) => {
98
+ const TIMEOUT_MS = 120_000; // 2 minutes
99
+
100
+ let resolved = false;
101
+ let server: ReturnType<typeof Bun.serve> | null = null;
102
+
103
+ const finish = (code: string | null) => {
104
+ if (resolved) return;
105
+ resolved = true;
106
+ logger.info("[spotify-auth] finish() called", { gotCode: !!code });
107
+ try { server?.stop(true); } catch { /* ignore */ }
108
+ clearTimeout(timer);
109
+ resolve(code);
110
+ };
111
+
112
+ const timer = setTimeout(() => {
113
+ logger.warn("[spotify-auth] Timed out waiting for callback");
114
+ ctx.showNotification("Spotify auth timed out (2 min)", "error");
115
+ finish(null);
116
+ }, TIMEOUT_MS);
117
+
118
+ try {
119
+ server = Bun.serve({
120
+ port: SPOTIFY_TUI_PORT,
121
+ hostname: "127.0.0.1", // explicit IPv4 — macOS 'localhost' resolves to ::1 (IPv6)
122
+ fetch(req) {
123
+ const url = new URL(req.url);
124
+ logger.info("[spotify-auth] Incoming request", { method: req.method, path: url.pathname });
125
+
126
+ if (url.pathname !== "/") {
127
+ return new Response("Not found", { status: 404 });
128
+ }
129
+
130
+ const code = url.searchParams.get("code");
131
+ const error = url.searchParams.get("error");
132
+ logger.info("[spotify-auth] Callback params", { hasCode: !!code, error });
133
+
134
+ if (error || !code) {
135
+ const msg = error ?? "no code in callback";
136
+ logger.error("[spotify-auth] Auth denied or missing code", { msg });
137
+ ctx.showNotification(`Spotify denied auth: ${msg}`, "error");
138
+ finish(null);
139
+ return new Response(
140
+ "<html><body><h2>Auth failed — return to your terminal.</h2></body></html>",
141
+ { headers: { "Content-Type": "text/html" } }
142
+ );
143
+ }
144
+
145
+ const resp = new Response(
146
+ "<html><head><meta charset=\"utf-8\"></head><body><h2>✓ Spotify connected! You can close this tab.</h2><p>Be sure to <strong>enable</strong> the tool for a persona!</p></body></html>",
147
+ { headers: { "Content-Type": "text/html; charset=utf-8" } }
148
+ );
149
+ // Defer finish() so the response flushes before the server stops
150
+ setTimeout(() => finish(code), 0);
151
+ return resp;
152
+ },
153
+ error(err) {
154
+ logger.error("[spotify-auth] Bun.serve error handler", { msg: err.message });
155
+ ctx.showNotification(`Local auth server error: ${err.message}`, "error");
156
+ finish(null);
157
+ return new Response("Internal Server Error", { status: 500 });
158
+ },
159
+ });
160
+ logger.info("[spotify-auth] Bun.serve started", { port: server.port, hostname: server.hostname });
161
+ } catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ logger.error("[spotify-auth] Bun.serve failed to start", { msg });
164
+ ctx.showNotification(`Failed to start local auth server: ${msg}`, "error");
165
+ clearTimeout(timer);
166
+ resolve(null);
167
+ }
168
+ });
169
+ }
@@ -24,6 +24,7 @@ import { setSyncCommand } from "../commands/setsync";
24
24
  import { queueCommand } from "../commands/queue";
25
25
  import { dlqCommand } from "../commands/dlq";
26
26
  import { toolsCommand } from "../commands/tools";
27
+ import { authCommand } from '../commands/auth';
27
28
  import { useOverlay } from "../context/overlay";
28
29
  import { CommandSuggest } from "./CommandSuggest";
29
30
  import { useKeyboard } from "@opentui/solid";
@@ -62,6 +63,7 @@ export function PromptInput() {
62
63
  registerCommand(queueCommand);
63
64
  registerCommand(dlqCommand);
64
65
  registerCommand(toolsCommand);
66
+ registerCommand(authCommand);
65
67
 
66
68
  let textareaRef: TextareaRenderable | undefined;
67
69