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 +1 -1
- package/src/core/orchestrators/human-extraction.ts +22 -6
- package/src/core/processor.ts +68 -0
- package/src/core/queue-processor.ts +72 -7
- package/src/core/tools/builtin/currently-playing.ts +75 -0
- package/src/core/tools/builtin/pkce.ts +91 -0
- package/src/core/tools/builtin/spotify-auth.ts +90 -0
- package/src/core/tools/builtin/spotify-liked-songs.ts +108 -0
- package/src/core/tools/index.ts +11 -2
- package/src/core/tools/types.ts +5 -1
- package/tui/src/commands/auth.ts +26 -0
- package/tui/src/commands/spotify-auth.ts +169 -0
- package/tui/src/components/PromptInput.tsx +2 -0
package/package.json
CHANGED
|
@@ -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
|
|
package/src/core/processor.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/tools/types.ts
CHANGED
|
@@ -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(
|
|
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
|
|