iris-chatbot 0.2.4
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/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- package/template/tsconfig.json +34 -0
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
import { ensureMacOS } from "../safety";
|
|
2
|
+
import { runCommandSafe } from "../runtime";
|
|
3
|
+
import type { ToolDefinition, ToolExecutionContext } from "../types";
|
|
4
|
+
|
|
5
|
+
type MusicPlayInput = {
|
|
6
|
+
query?: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
artist?: string;
|
|
9
|
+
source?: "apple_music" | "local_library";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type MusicSetVolumeInput = {
|
|
13
|
+
level?: number;
|
|
14
|
+
target?: "system" | "music";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type LibraryTrackCandidate = {
|
|
18
|
+
persistentId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
artist: string;
|
|
21
|
+
album: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type LibraryTrackScore = {
|
|
25
|
+
candidate: LibraryTrackCandidate;
|
|
26
|
+
score: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type LibraryPlaylistCandidate = {
|
|
30
|
+
persistentId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type LibraryPlaylistScore = {
|
|
35
|
+
candidate: LibraryPlaylistCandidate;
|
|
36
|
+
score: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const APPLESCRIPT_TIMEOUT_MS = 30_000;
|
|
40
|
+
const MAX_LIBRARY_CANDIDATES_PER_QUERY = 30;
|
|
41
|
+
const MAX_PLAYBACK_ATTEMPTS = 3;
|
|
42
|
+
|
|
43
|
+
function asObject(input: unknown): Record<string, unknown> {
|
|
44
|
+
if (!input || typeof input !== "object") {
|
|
45
|
+
throw new Error("Tool input must be an object.");
|
|
46
|
+
}
|
|
47
|
+
return input as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asNumber(input: unknown, field: string): number {
|
|
51
|
+
if (typeof input !== "number" || !Number.isFinite(input)) {
|
|
52
|
+
throw new Error(`Missing required numeric field: ${field}`);
|
|
53
|
+
}
|
|
54
|
+
return input;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeLoose(value: string): string {
|
|
58
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeSearchText(value: string): string {
|
|
62
|
+
return value
|
|
63
|
+
.replace(/[’']/g, "")
|
|
64
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
65
|
+
.replace(/\s+/g, " ")
|
|
66
|
+
.trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function splitNormalizedTokens(value: string): string[] {
|
|
70
|
+
const normalized = normalizeSearchText(value).toLowerCase();
|
|
71
|
+
if (!normalized) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return normalized.split(" ").filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function dedupeList(values: string[]): string[] {
|
|
78
|
+
const seen = new Set<string>();
|
|
79
|
+
const output: string[] = [];
|
|
80
|
+
for (const value of values) {
|
|
81
|
+
const normalized = value.trim();
|
|
82
|
+
if (!normalized) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const key = normalized.toLowerCase();
|
|
86
|
+
if (seen.has(key)) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
seen.add(key);
|
|
90
|
+
output.push(normalized);
|
|
91
|
+
}
|
|
92
|
+
return output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildSearchQueries(params: {
|
|
96
|
+
query: string;
|
|
97
|
+
title: string;
|
|
98
|
+
artist: string;
|
|
99
|
+
}): string[] {
|
|
100
|
+
const candidates: string[] = [];
|
|
101
|
+
const pushWithForms = (value: string) => {
|
|
102
|
+
const trimmed = value.trim();
|
|
103
|
+
if (!trimmed) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const compact = normalizeSearchText(trimmed);
|
|
107
|
+
candidates.push(trimmed);
|
|
108
|
+
if (compact && compact.toLowerCase() !== trimmed.toLowerCase()) {
|
|
109
|
+
candidates.push(compact);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (params.title && params.artist) {
|
|
114
|
+
pushWithForms(`${params.title} ${params.artist}`);
|
|
115
|
+
pushWithForms(`${params.artist} ${params.title}`);
|
|
116
|
+
}
|
|
117
|
+
if (params.query) {
|
|
118
|
+
pushWithForms(params.query);
|
|
119
|
+
}
|
|
120
|
+
if (params.title) {
|
|
121
|
+
pushWithForms(params.title);
|
|
122
|
+
}
|
|
123
|
+
if (params.artist) {
|
|
124
|
+
pushWithForms(params.artist);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return dedupeList(candidates).filter((value) => value.length >= 2);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function normalizePlaylistNeedle(value: string): string {
|
|
131
|
+
return value
|
|
132
|
+
.replace(/\b(play|playlist|playlists|music|songs|song|track|tracks|please|for me)\b/gi, " ")
|
|
133
|
+
.replace(/\s+/g, " ")
|
|
134
|
+
.trim();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildPlaylistQueries(params: {
|
|
138
|
+
query: string;
|
|
139
|
+
title: string;
|
|
140
|
+
artist: string;
|
|
141
|
+
rawQuery: string;
|
|
142
|
+
}): string[] {
|
|
143
|
+
const candidates = [
|
|
144
|
+
params.query,
|
|
145
|
+
normalizePlaylistNeedle(params.query),
|
|
146
|
+
params.rawQuery,
|
|
147
|
+
normalizePlaylistNeedle(params.rawQuery),
|
|
148
|
+
params.title,
|
|
149
|
+
normalizePlaylistNeedle(params.title),
|
|
150
|
+
].filter((value) => value.trim().length > 0);
|
|
151
|
+
|
|
152
|
+
// Playlists are named text, so keep slightly shorter inputs for partial matches.
|
|
153
|
+
return dedupeList(candidates).filter((value) => value.length >= 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function textMatchScore(actual: string, requested: string): number {
|
|
157
|
+
const normalizedActual = normalizeLoose(actual);
|
|
158
|
+
const normalizedRequested = normalizeLoose(requested);
|
|
159
|
+
if (!normalizedActual || !normalizedRequested) {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
if (normalizedActual === normalizedRequested) {
|
|
163
|
+
return 100;
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
normalizedActual.includes(normalizedRequested) ||
|
|
167
|
+
normalizedRequested.includes(normalizedActual)
|
|
168
|
+
) {
|
|
169
|
+
return 75;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const requestedTokens = splitNormalizedTokens(requested);
|
|
173
|
+
const actualTokens = splitNormalizedTokens(actual);
|
|
174
|
+
if (requestedTokens.length === 0 || actualTokens.length === 0) {
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const actualTokenSet = new Set(actualTokens);
|
|
179
|
+
let overlap = 0;
|
|
180
|
+
for (const token of requestedTokens) {
|
|
181
|
+
if (actualTokenSet.has(token)) {
|
|
182
|
+
overlap += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ratio = overlap / requestedTokens.length;
|
|
187
|
+
if (ratio >= 1) {
|
|
188
|
+
return 65;
|
|
189
|
+
}
|
|
190
|
+
if (ratio >= 0.75) {
|
|
191
|
+
return 45;
|
|
192
|
+
}
|
|
193
|
+
if (ratio >= 0.5) {
|
|
194
|
+
return 25;
|
|
195
|
+
}
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isLikelyTextMatch(actual: string, requested: string): boolean {
|
|
200
|
+
return textMatchScore(actual, requested) >= 45;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function rankLibraryCandidates(params: {
|
|
204
|
+
candidates: LibraryTrackCandidate[];
|
|
205
|
+
query: string;
|
|
206
|
+
title: string;
|
|
207
|
+
artist: string;
|
|
208
|
+
enforceTitleMatch: boolean;
|
|
209
|
+
enforceArtistMatch: boolean;
|
|
210
|
+
}): LibraryTrackScore[] {
|
|
211
|
+
const scores: LibraryTrackScore[] = [];
|
|
212
|
+
|
|
213
|
+
for (const candidate of params.candidates) {
|
|
214
|
+
const titleScore = textMatchScore(candidate.title, params.title);
|
|
215
|
+
const artistScore = textMatchScore(candidate.artist, params.artist);
|
|
216
|
+
const queryScore = textMatchScore(
|
|
217
|
+
`${candidate.title} ${candidate.artist}`,
|
|
218
|
+
params.query,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (params.enforceTitleMatch && params.title && titleScore < 45) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (params.enforceArtistMatch && params.artist && artistScore < 35) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const score = titleScore * 1.8 + artistScore * 1.5 + queryScore * 0.9;
|
|
229
|
+
scores.push({ candidate, score });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return scores.sort((a, b) => b.score - a.score);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function rankPlaylistCandidates(params: {
|
|
236
|
+
candidates: LibraryPlaylistCandidate[];
|
|
237
|
+
query: string;
|
|
238
|
+
rawQuery: string;
|
|
239
|
+
playlistRequested: boolean;
|
|
240
|
+
}): LibraryPlaylistScore[] {
|
|
241
|
+
const cleanedQuery = normalizePlaylistNeedle(params.query) || params.query;
|
|
242
|
+
const cleanedRaw = normalizePlaylistNeedle(params.rawQuery) || params.rawQuery;
|
|
243
|
+
const scores: LibraryPlaylistScore[] = [];
|
|
244
|
+
|
|
245
|
+
for (const candidate of params.candidates) {
|
|
246
|
+
const queryScore = textMatchScore(candidate.name, cleanedQuery);
|
|
247
|
+
const rawScore = textMatchScore(candidate.name, cleanedRaw);
|
|
248
|
+
const exactBonus =
|
|
249
|
+
normalizeLoose(candidate.name) === normalizeLoose(cleanedQuery) ||
|
|
250
|
+
normalizeLoose(candidate.name) === normalizeLoose(cleanedRaw)
|
|
251
|
+
? 30
|
|
252
|
+
: 0;
|
|
253
|
+
const playlistBonus = params.playlistRequested ? 20 : 0;
|
|
254
|
+
const score = queryScore * 1.8 + rawScore * 1.2 + exactBonus + playlistBonus;
|
|
255
|
+
scores.push({ candidate, score });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return scores.sort((a, b) => b.score - a.score);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async function runAppleScript(script: string, args: string[] = [], signal?: AbortSignal) {
|
|
263
|
+
const { stdout } = await runCommandSafe({
|
|
264
|
+
command: "osascript",
|
|
265
|
+
args: ["-e", script, ...args],
|
|
266
|
+
signal,
|
|
267
|
+
timeoutMs: APPLESCRIPT_TIMEOUT_MS,
|
|
268
|
+
});
|
|
269
|
+
return stdout;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function searchLibraryTracks(
|
|
273
|
+
queries: string[],
|
|
274
|
+
signal?: AbortSignal,
|
|
275
|
+
): Promise<LibraryTrackCandidate[]> {
|
|
276
|
+
if (queries.length === 0) {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const script =
|
|
281
|
+
"on run argv\n" +
|
|
282
|
+
`set perQueryLimit to ${MAX_LIBRARY_CANDIDATES_PER_QUERY}\n` +
|
|
283
|
+
'set outText to ""\n' +
|
|
284
|
+
'tell application "Music"\n' +
|
|
285
|
+
'set libraryList to library playlist 1\n' +
|
|
286
|
+
"repeat with term in argv\n" +
|
|
287
|
+
'set q to term as text\n' +
|
|
288
|
+
'if q is not "" then\n' +
|
|
289
|
+
"try\n" +
|
|
290
|
+
'set foundTracks to (search libraryList for q)\n' +
|
|
291
|
+
"set emitted to 0\n" +
|
|
292
|
+
"repeat with t in foundTracks\n" +
|
|
293
|
+
'set outText to outText & (persistent ID of t as text) & tab & (name of t as text) & tab & (artist of t as text) & tab & (album of t as text) & linefeed\n' +
|
|
294
|
+
"set emitted to emitted + 1\n" +
|
|
295
|
+
"if emitted is greater than or equal to perQueryLimit then exit repeat\n" +
|
|
296
|
+
"end repeat\n" +
|
|
297
|
+
"end try\n" +
|
|
298
|
+
"end if\n" +
|
|
299
|
+
"end repeat\n" +
|
|
300
|
+
"end tell\n" +
|
|
301
|
+
"return outText\n" +
|
|
302
|
+
"end run";
|
|
303
|
+
|
|
304
|
+
const output = await runAppleScript(script, queries, signal);
|
|
305
|
+
const byId = new Map<string, LibraryTrackCandidate>();
|
|
306
|
+
|
|
307
|
+
for (const line of output.split("\n")) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const [persistentId, title, artist, album] = trimmed.split("\t");
|
|
313
|
+
if (!persistentId || !title) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (!byId.has(persistentId)) {
|
|
317
|
+
byId.set(persistentId, {
|
|
318
|
+
persistentId,
|
|
319
|
+
title: title.trim(),
|
|
320
|
+
artist: (artist ?? "").trim(),
|
|
321
|
+
album: (album ?? "").trim(),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return [...byId.values()];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function searchLibraryTracksByMetadata(params: {
|
|
330
|
+
title: string;
|
|
331
|
+
artist: string;
|
|
332
|
+
query: string;
|
|
333
|
+
signal?: AbortSignal;
|
|
334
|
+
}): Promise<LibraryTrackCandidate[]> {
|
|
335
|
+
const titleNeedle = params.title.trim();
|
|
336
|
+
const artistNeedle = params.artist.trim();
|
|
337
|
+
const queryNeedle = params.query.trim();
|
|
338
|
+
|
|
339
|
+
if (!titleNeedle && !artistNeedle && !queryNeedle) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const perQueryLimit = Math.max(40, MAX_LIBRARY_CANDIDATES_PER_QUERY * 2);
|
|
344
|
+
const script =
|
|
345
|
+
"on run argv\n" +
|
|
346
|
+
'set titleNeedle to item 1 of argv\n' +
|
|
347
|
+
'set artistNeedle to item 2 of argv\n' +
|
|
348
|
+
'set queryNeedle to item 3 of argv\n' +
|
|
349
|
+
`set perQueryLimit to ${perQueryLimit}\n` +
|
|
350
|
+
'set outText to ""\n' +
|
|
351
|
+
'tell application "Music"\n' +
|
|
352
|
+
'set libraryList to library playlist 1\n' +
|
|
353
|
+
"set foundTracks to {}\n" +
|
|
354
|
+
"if titleNeedle is not \"\" and artistNeedle is not \"\" then\n" +
|
|
355
|
+
"try\n" +
|
|
356
|
+
'set foundTracks to (every track of libraryList whose name contains titleNeedle and artist contains artistNeedle)\n' +
|
|
357
|
+
"end try\n" +
|
|
358
|
+
"else if titleNeedle is not \"\" then\n" +
|
|
359
|
+
"try\n" +
|
|
360
|
+
'set foundTracks to (every track of libraryList whose name contains titleNeedle)\n' +
|
|
361
|
+
"end try\n" +
|
|
362
|
+
"else if artistNeedle is not \"\" then\n" +
|
|
363
|
+
"try\n" +
|
|
364
|
+
'set foundTracks to (every track of libraryList whose artist contains artistNeedle)\n' +
|
|
365
|
+
"end try\n" +
|
|
366
|
+
"else if queryNeedle is not \"\" then\n" +
|
|
367
|
+
"try\n" +
|
|
368
|
+
'set foundTracks to (search libraryList for queryNeedle)\n' +
|
|
369
|
+
"end try\n" +
|
|
370
|
+
"end if\n" +
|
|
371
|
+
"set emitted to 0\n" +
|
|
372
|
+
"repeat with t in foundTracks\n" +
|
|
373
|
+
"try\n" +
|
|
374
|
+
'set outText to outText & (persistent ID of t as text) & tab & (name of t as text) & tab & (artist of t as text) & tab & (album of t as text) & linefeed\n' +
|
|
375
|
+
"set emitted to emitted + 1\n" +
|
|
376
|
+
"if emitted is greater than or equal to perQueryLimit then exit repeat\n" +
|
|
377
|
+
"end try\n" +
|
|
378
|
+
"end repeat\n" +
|
|
379
|
+
"end tell\n" +
|
|
380
|
+
"return outText\n" +
|
|
381
|
+
"end run";
|
|
382
|
+
|
|
383
|
+
const output = await runAppleScript(script, [titleNeedle, artistNeedle, queryNeedle], params.signal);
|
|
384
|
+
const byId = new Map<string, LibraryTrackCandidate>();
|
|
385
|
+
|
|
386
|
+
for (const line of output.split("\n")) {
|
|
387
|
+
const trimmed = line.trim();
|
|
388
|
+
if (!trimmed) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
const [persistentId, title, artist, album] = trimmed.split("\t");
|
|
392
|
+
if (!persistentId || !title) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!byId.has(persistentId)) {
|
|
396
|
+
byId.set(persistentId, {
|
|
397
|
+
persistentId,
|
|
398
|
+
title: title.trim(),
|
|
399
|
+
artist: (artist ?? "").trim(),
|
|
400
|
+
album: (album ?? "").trim(),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return [...byId.values()];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function searchLibraryPlaylists(
|
|
409
|
+
queries: string[],
|
|
410
|
+
signal?: AbortSignal,
|
|
411
|
+
): Promise<LibraryPlaylistCandidate[]> {
|
|
412
|
+
if (queries.length === 0) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const perQueryLimit = Math.max(30, MAX_LIBRARY_CANDIDATES_PER_QUERY);
|
|
417
|
+
const script =
|
|
418
|
+
"on run argv\n" +
|
|
419
|
+
`set perQueryLimit to ${perQueryLimit}\n` +
|
|
420
|
+
'set outText to ""\n' +
|
|
421
|
+
'tell application "Music"\n' +
|
|
422
|
+
"repeat with term in argv\n" +
|
|
423
|
+
'set q to term as text\n' +
|
|
424
|
+
'if q is not "" then\n' +
|
|
425
|
+
"try\n" +
|
|
426
|
+
'set foundPlaylists to (every user playlist whose name contains q)\n' +
|
|
427
|
+
"set emitted to 0\n" +
|
|
428
|
+
"repeat with p in foundPlaylists\n" +
|
|
429
|
+
"try\n" +
|
|
430
|
+
'set outText to outText & (persistent ID of p as text) & tab & (name of p as text) & linefeed\n' +
|
|
431
|
+
"set emitted to emitted + 1\n" +
|
|
432
|
+
"if emitted is greater than or equal to perQueryLimit then exit repeat\n" +
|
|
433
|
+
"end try\n" +
|
|
434
|
+
"end repeat\n" +
|
|
435
|
+
"end try\n" +
|
|
436
|
+
"end if\n" +
|
|
437
|
+
"end repeat\n" +
|
|
438
|
+
"end tell\n" +
|
|
439
|
+
"return outText\n" +
|
|
440
|
+
"end run";
|
|
441
|
+
|
|
442
|
+
const output = await runAppleScript(script, queries, signal);
|
|
443
|
+
const byId = new Map<string, LibraryPlaylistCandidate>();
|
|
444
|
+
for (const line of output.split("\n")) {
|
|
445
|
+
const trimmed = line.trim();
|
|
446
|
+
if (!trimmed) {
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const [persistentId, name] = trimmed.split("\t");
|
|
450
|
+
if (!persistentId || !name) {
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (!byId.has(persistentId)) {
|
|
454
|
+
byId.set(persistentId, {
|
|
455
|
+
persistentId: persistentId.trim(),
|
|
456
|
+
name: name.trim(),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return [...byId.values()];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function playLibraryPlaylistByPersistentId(
|
|
464
|
+
persistentId: string,
|
|
465
|
+
signal?: AbortSignal,
|
|
466
|
+
): Promise<{
|
|
467
|
+
playing: boolean;
|
|
468
|
+
playlistName: string | null;
|
|
469
|
+
title: string | null;
|
|
470
|
+
artist: string | null;
|
|
471
|
+
album: string | null;
|
|
472
|
+
}> {
|
|
473
|
+
const script =
|
|
474
|
+
"on run argv\n" +
|
|
475
|
+
'set requestedId to item 1 of argv\n' +
|
|
476
|
+
'tell application "Music"\n' +
|
|
477
|
+
'set selectedPlaylist to missing value\n' +
|
|
478
|
+
"try\n" +
|
|
479
|
+
'set matches to (every user playlist whose persistent ID is requestedId)\n' +
|
|
480
|
+
'if (count of matches) > 0 then set selectedPlaylist to item 1 of matches\n' +
|
|
481
|
+
"end try\n" +
|
|
482
|
+
'if selectedPlaylist is missing value then return "not_found"\n' +
|
|
483
|
+
"play selectedPlaylist\n" +
|
|
484
|
+
"delay 1.1\n" +
|
|
485
|
+
'set pState to player state as text\n' +
|
|
486
|
+
'if pState is not "playing" then return "not_playing"\n' +
|
|
487
|
+
'set pName to (name of selectedPlaylist as text)\n' +
|
|
488
|
+
'set tName to ""\n' +
|
|
489
|
+
'set tArtist to ""\n' +
|
|
490
|
+
'set tAlbum to ""\n' +
|
|
491
|
+
"try\n" +
|
|
492
|
+
'set tName to (name of current track as text)\n' +
|
|
493
|
+
'set tArtist to (artist of current track as text)\n' +
|
|
494
|
+
'set tAlbum to (album of current track as text)\n' +
|
|
495
|
+
"end try\n" +
|
|
496
|
+
'return "ok" & tab & pName & tab & tName & tab & tArtist & tab & tAlbum\n' +
|
|
497
|
+
"end tell\n" +
|
|
498
|
+
"end run";
|
|
499
|
+
|
|
500
|
+
const output = await runAppleScript(script, [persistentId], signal);
|
|
501
|
+
if (output === "not_found" || output === "not_playing") {
|
|
502
|
+
return {
|
|
503
|
+
playing: false,
|
|
504
|
+
playlistName: null,
|
|
505
|
+
title: null,
|
|
506
|
+
artist: null,
|
|
507
|
+
album: null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const [, playlistName, title, artist, album] = output.split("\t");
|
|
511
|
+
return {
|
|
512
|
+
playing: true,
|
|
513
|
+
playlistName: playlistName?.trim() || null,
|
|
514
|
+
title: title?.trim() || null,
|
|
515
|
+
artist: artist?.trim() || null,
|
|
516
|
+
album: album?.trim() || null,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function playLibraryTrackByPersistentId(
|
|
521
|
+
persistentId: string,
|
|
522
|
+
signal?: AbortSignal,
|
|
523
|
+
): Promise<{
|
|
524
|
+
playing: boolean;
|
|
525
|
+
title: string | null;
|
|
526
|
+
artist: string | null;
|
|
527
|
+
album: string | null;
|
|
528
|
+
}> {
|
|
529
|
+
const script =
|
|
530
|
+
"on run argv\n" +
|
|
531
|
+
'set requestedId to item 1 of argv\n' +
|
|
532
|
+
'tell application "Music"\n' +
|
|
533
|
+
'set selectedTrack to missing value\n' +
|
|
534
|
+
"try\n" +
|
|
535
|
+
'set matches to (every track of library playlist 1 whose persistent ID is requestedId)\n' +
|
|
536
|
+
'if (count of matches) > 0 then set selectedTrack to item 1 of matches\n' +
|
|
537
|
+
"end try\n" +
|
|
538
|
+
'if selectedTrack is missing value then return "not_found"\n' +
|
|
539
|
+
"play selectedTrack\n" +
|
|
540
|
+
"delay 1.2\n" +
|
|
541
|
+
'set pState to player state as text\n' +
|
|
542
|
+
'if pState is not "playing" then return "not_playing"\n' +
|
|
543
|
+
'set tName to ""\n' +
|
|
544
|
+
'set tArtist to ""\n' +
|
|
545
|
+
'set tAlbum to ""\n' +
|
|
546
|
+
"try\n" +
|
|
547
|
+
'set tName to (name of current track as text)\n' +
|
|
548
|
+
'set tArtist to (artist of current track as text)\n' +
|
|
549
|
+
'set tAlbum to (album of current track as text)\n' +
|
|
550
|
+
"on error\n" +
|
|
551
|
+
'set tName to (name of selectedTrack as text)\n' +
|
|
552
|
+
'set tArtist to (artist of selectedTrack as text)\n' +
|
|
553
|
+
'set tAlbum to (album of selectedTrack as text)\n' +
|
|
554
|
+
"end try\n" +
|
|
555
|
+
'return "ok" & tab & tName & tab & tArtist & tab & tAlbum\n' +
|
|
556
|
+
"end tell\n" +
|
|
557
|
+
"end run";
|
|
558
|
+
|
|
559
|
+
const output = await runAppleScript(script, [persistentId], signal);
|
|
560
|
+
if (output === "not_found" || output === "not_playing") {
|
|
561
|
+
return { playing: false, title: null, artist: null, album: null };
|
|
562
|
+
}
|
|
563
|
+
const [, title, artist, album] = output.split("\t");
|
|
564
|
+
return {
|
|
565
|
+
playing: true,
|
|
566
|
+
title: title?.trim() || null,
|
|
567
|
+
artist: artist?.trim() || null,
|
|
568
|
+
album: album?.trim() || null,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function matchesRequestedTrack(params: {
|
|
573
|
+
nowTitle: string;
|
|
574
|
+
nowArtist: string;
|
|
575
|
+
requestedTitle: string;
|
|
576
|
+
requestedArtist: string;
|
|
577
|
+
enforceTitleMatch: boolean;
|
|
578
|
+
enforceArtistMatch: boolean;
|
|
579
|
+
}): boolean {
|
|
580
|
+
if (params.enforceTitleMatch && params.requestedTitle) {
|
|
581
|
+
if (!isLikelyTextMatch(params.nowTitle, params.requestedTitle)) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (params.enforceArtistMatch && params.requestedArtist) {
|
|
586
|
+
if (!isLikelyTextMatch(params.nowArtist, params.requestedArtist)) {
|
|
587
|
+
return false;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function runMusicPlay(input: unknown, context: ToolExecutionContext) {
|
|
594
|
+
ensureMacOS("Music automation");
|
|
595
|
+
const payload = asObject(input) as MusicPlayInput;
|
|
596
|
+
const rawQuery = typeof payload.query === "string" ? payload.query.trim() : "";
|
|
597
|
+
let title = typeof payload.title === "string" ? payload.title.trim() : "";
|
|
598
|
+
let artist = typeof payload.artist === "string" ? payload.artist.trim() : "";
|
|
599
|
+
|
|
600
|
+
if (!title && rawQuery) {
|
|
601
|
+
const byMatch = rawQuery.match(/^(.+?)\s+by\s+(.+)$/i);
|
|
602
|
+
if (byMatch?.[1]) {
|
|
603
|
+
title = byMatch[1].trim();
|
|
604
|
+
if (!artist && byMatch[2]) {
|
|
605
|
+
artist = byMatch[2].trim();
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const query = rawQuery || [title, artist].filter(Boolean).join(" ").trim();
|
|
610
|
+
const playlistRequested = /\bplaylist\b/i.test(rawQuery);
|
|
611
|
+
|
|
612
|
+
const enforceArtistMatch = Boolean(artist);
|
|
613
|
+
const enforceTitleMatch =
|
|
614
|
+
Boolean(title) &&
|
|
615
|
+
(enforceArtistMatch || !rawQuery || normalizeLoose(rawQuery) !== normalizeLoose(title));
|
|
616
|
+
|
|
617
|
+
if (context.localTools.dryRun) {
|
|
618
|
+
return {
|
|
619
|
+
dryRun: true,
|
|
620
|
+
action: "music_play",
|
|
621
|
+
query,
|
|
622
|
+
title,
|
|
623
|
+
artist,
|
|
624
|
+
source: payload.source ?? "apple_music",
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!query) {
|
|
629
|
+
const resumeScript =
|
|
630
|
+
'tell application "Music"\n' +
|
|
631
|
+
"play\n" +
|
|
632
|
+
"delay 1\n" +
|
|
633
|
+
'set pState to player state as text\n' +
|
|
634
|
+
'if pState is not "playing" then return "not_playing"\n' +
|
|
635
|
+
'set tName to ""\n' +
|
|
636
|
+
'set tArtist to ""\n' +
|
|
637
|
+
'set tAlbum to ""\n' +
|
|
638
|
+
"try\n" +
|
|
639
|
+
'set tName to (name of current track as text)\n' +
|
|
640
|
+
'set tArtist to (artist of current track as text)\n' +
|
|
641
|
+
'set tAlbum to (album of current track as text)\n' +
|
|
642
|
+
"end try\n" +
|
|
643
|
+
'return "ok" & tab & tName & tab & tArtist & tab & tAlbum\n' +
|
|
644
|
+
"end tell";
|
|
645
|
+
const output = await runAppleScript(resumeScript, [], context.signal);
|
|
646
|
+
if (output === "not_playing") {
|
|
647
|
+
return {
|
|
648
|
+
playing: false,
|
|
649
|
+
query: null,
|
|
650
|
+
title: null,
|
|
651
|
+
artist: null,
|
|
652
|
+
reason: "No active track was available to resume.",
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const [, nowTitle, nowArtist, nowAlbum] = output.split("\t");
|
|
656
|
+
return {
|
|
657
|
+
playing: true,
|
|
658
|
+
matched: true,
|
|
659
|
+
query: null,
|
|
660
|
+
title: nowTitle || null,
|
|
661
|
+
artist: nowArtist || null,
|
|
662
|
+
album: nowAlbum || null,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const searchQueries = buildSearchQueries({
|
|
667
|
+
query,
|
|
668
|
+
title,
|
|
669
|
+
artist,
|
|
670
|
+
});
|
|
671
|
+
const primaryLibraryCandidates = await searchLibraryTracks(searchQueries, context.signal);
|
|
672
|
+
let libraryCandidates = primaryLibraryCandidates;
|
|
673
|
+
if (libraryCandidates.length === 0 || enforceTitleMatch || enforceArtistMatch) {
|
|
674
|
+
try {
|
|
675
|
+
const metadataCandidates = await searchLibraryTracksByMetadata({
|
|
676
|
+
title,
|
|
677
|
+
artist,
|
|
678
|
+
query,
|
|
679
|
+
signal: context.signal,
|
|
680
|
+
});
|
|
681
|
+
if (metadataCandidates.length > 0) {
|
|
682
|
+
const mergedById = new Map<string, LibraryTrackCandidate>();
|
|
683
|
+
for (const candidate of primaryLibraryCandidates) {
|
|
684
|
+
mergedById.set(candidate.persistentId, candidate);
|
|
685
|
+
}
|
|
686
|
+
for (const candidate of metadataCandidates) {
|
|
687
|
+
if (!mergedById.has(candidate.persistentId)) {
|
|
688
|
+
mergedById.set(candidate.persistentId, candidate);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
libraryCandidates = [...mergedById.values()];
|
|
692
|
+
}
|
|
693
|
+
} catch {
|
|
694
|
+
// Keep primary library results if metadata fallback fails.
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const rankedCandidates = rankLibraryCandidates({
|
|
699
|
+
candidates: libraryCandidates,
|
|
700
|
+
query,
|
|
701
|
+
title: title || query,
|
|
702
|
+
artist: artist || "",
|
|
703
|
+
enforceTitleMatch,
|
|
704
|
+
enforceArtistMatch,
|
|
705
|
+
});
|
|
706
|
+
const playlistQueries = buildPlaylistQueries({
|
|
707
|
+
query,
|
|
708
|
+
title,
|
|
709
|
+
artist,
|
|
710
|
+
rawQuery,
|
|
711
|
+
});
|
|
712
|
+
let playlistCandidates: LibraryPlaylistCandidate[] = [];
|
|
713
|
+
try {
|
|
714
|
+
playlistCandidates = await searchLibraryPlaylists(playlistQueries, context.signal);
|
|
715
|
+
} catch {
|
|
716
|
+
playlistCandidates = [];
|
|
717
|
+
}
|
|
718
|
+
const rankedPlaylists = rankPlaylistCandidates({
|
|
719
|
+
candidates: playlistCandidates,
|
|
720
|
+
query,
|
|
721
|
+
rawQuery,
|
|
722
|
+
playlistRequested,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
const tryPlayTopPlaylist = async () => {
|
|
726
|
+
for (const entry of rankedPlaylists.slice(0, 3)) {
|
|
727
|
+
const playlistPlayback = await playLibraryPlaylistByPersistentId(
|
|
728
|
+
entry.candidate.persistentId,
|
|
729
|
+
context.signal,
|
|
730
|
+
);
|
|
731
|
+
if (!playlistPlayback.playing) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
playing: true,
|
|
736
|
+
matched: true,
|
|
737
|
+
query: rawQuery || query || null,
|
|
738
|
+
playlistName: playlistPlayback.playlistName,
|
|
739
|
+
title: playlistPlayback.title,
|
|
740
|
+
artist: playlistPlayback.artist,
|
|
741
|
+
album: playlistPlayback.album,
|
|
742
|
+
source: "library_playlist",
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
return null;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const preferPlaylistByScore =
|
|
749
|
+
rankedPlaylists.length > 0 &&
|
|
750
|
+
!title &&
|
|
751
|
+
!artist &&
|
|
752
|
+
(rankedPlaylists[0]?.score ?? 0) >= 140;
|
|
753
|
+
const shouldPreferPlaylist = playlistRequested || preferPlaylistByScore;
|
|
754
|
+
if (shouldPreferPlaylist && rankedPlaylists.length > 0) {
|
|
755
|
+
const playlistPlayback = await tryPlayTopPlaylist();
|
|
756
|
+
if (playlistPlayback) {
|
|
757
|
+
return playlistPlayback;
|
|
758
|
+
}
|
|
759
|
+
if (playlistRequested) {
|
|
760
|
+
return {
|
|
761
|
+
playing: false,
|
|
762
|
+
query: rawQuery || query || null,
|
|
763
|
+
title: null,
|
|
764
|
+
artist: null,
|
|
765
|
+
playlistName: rankedPlaylists[0]?.candidate.name ?? null,
|
|
766
|
+
reason:
|
|
767
|
+
rankedPlaylists[0]?.candidate.name
|
|
768
|
+
? `Found playlist "${rankedPlaylists[0].candidate.name}" in your Music library, but playback did not start.`
|
|
769
|
+
: "No matching playlist could be started from your Music library.",
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const requestedTitleForMatch = title || query;
|
|
774
|
+
const requestedArtistForMatch = artist || "";
|
|
775
|
+
|
|
776
|
+
if (rankedCandidates.length === 0) {
|
|
777
|
+
if (!enforceTitleMatch && !enforceArtistMatch && rankedPlaylists.length > 0) {
|
|
778
|
+
const playlistPlayback = await tryPlayTopPlaylist();
|
|
779
|
+
if (playlistPlayback) {
|
|
780
|
+
return playlistPlayback;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
playing: false,
|
|
785
|
+
query: rawQuery || query || null,
|
|
786
|
+
title: title || null,
|
|
787
|
+
artist: artist || null,
|
|
788
|
+
reason:
|
|
789
|
+
playlistRequested || (!enforceTitleMatch && !enforceArtistMatch)
|
|
790
|
+
? "No matching track or playlist was found in your Music library."
|
|
791
|
+
: "No matching track was found in your Music library.",
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
let nearestMatch: {
|
|
796
|
+
title: string | null;
|
|
797
|
+
artist: string | null;
|
|
798
|
+
album: string | null;
|
|
799
|
+
} | null = null;
|
|
800
|
+
|
|
801
|
+
for (const entry of rankedCandidates.slice(0, MAX_PLAYBACK_ATTEMPTS)) {
|
|
802
|
+
const playback = await playLibraryTrackByPersistentId(
|
|
803
|
+
entry.candidate.persistentId,
|
|
804
|
+
context.signal,
|
|
805
|
+
);
|
|
806
|
+
if (!playback.playing) {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const resolvedTitle = playback.title || entry.candidate.title;
|
|
811
|
+
const resolvedArtist = playback.artist || entry.candidate.artist;
|
|
812
|
+
const resolvedAlbum = playback.album || entry.candidate.album || null;
|
|
813
|
+
|
|
814
|
+
const isMatch = matchesRequestedTrack({
|
|
815
|
+
nowTitle: resolvedTitle,
|
|
816
|
+
nowArtist: resolvedArtist,
|
|
817
|
+
requestedTitle: requestedTitleForMatch,
|
|
818
|
+
requestedArtist: requestedArtistForMatch,
|
|
819
|
+
enforceTitleMatch,
|
|
820
|
+
enforceArtistMatch,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (isMatch) {
|
|
824
|
+
return {
|
|
825
|
+
playing: true,
|
|
826
|
+
matched: true,
|
|
827
|
+
query: rawQuery || query || null,
|
|
828
|
+
title: resolvedTitle || null,
|
|
829
|
+
artist: resolvedArtist || null,
|
|
830
|
+
album: resolvedAlbum,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
nearestMatch = {
|
|
835
|
+
title: resolvedTitle || null,
|
|
836
|
+
artist: resolvedArtist || null,
|
|
837
|
+
album: resolvedAlbum,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (nearestMatch) {
|
|
842
|
+
if (enforceTitleMatch || enforceArtistMatch) {
|
|
843
|
+
return {
|
|
844
|
+
playing: false,
|
|
845
|
+
matched: false,
|
|
846
|
+
query: rawQuery || query || null,
|
|
847
|
+
title: title || null,
|
|
848
|
+
artist: artist || null,
|
|
849
|
+
matchedTitle: nearestMatch.title,
|
|
850
|
+
matchedArtist: nearestMatch.artist,
|
|
851
|
+
matchedAlbum: nearestMatch.album,
|
|
852
|
+
reason: "A nearby track played, but it did not match the requested song.",
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
return {
|
|
856
|
+
playing: true,
|
|
857
|
+
matched: false,
|
|
858
|
+
query: rawQuery || query || null,
|
|
859
|
+
title: nearestMatch.title,
|
|
860
|
+
artist: nearestMatch.artist,
|
|
861
|
+
album: nearestMatch.album,
|
|
862
|
+
reason: "Playback started with a nearby match.",
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return {
|
|
867
|
+
playing: false,
|
|
868
|
+
query: rawQuery || query || null,
|
|
869
|
+
title: title || null,
|
|
870
|
+
artist: artist || null,
|
|
871
|
+
reason: "No matching track started playback.",
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function runMusicCommand(action: "pause" | "next" | "previous", context: ToolExecutionContext) {
|
|
876
|
+
ensureMacOS("Music automation");
|
|
877
|
+
if (context.localTools.dryRun) {
|
|
878
|
+
return { dryRun: true, action: `music_${action}` };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const script =
|
|
882
|
+
action === "pause"
|
|
883
|
+
? 'tell application "Music" to pause'
|
|
884
|
+
: action === "next"
|
|
885
|
+
? 'tell application "Music" to next track'
|
|
886
|
+
: 'tell application "Music" to previous track';
|
|
887
|
+
await runAppleScript(script, [], context.signal);
|
|
888
|
+
return { ok: true, action: `music_${action}` };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
async function runMusicSetVolume(input: unknown, context: ToolExecutionContext) {
|
|
892
|
+
ensureMacOS("Volume automation");
|
|
893
|
+
const payload = asObject(input) as MusicSetVolumeInput;
|
|
894
|
+
const level = Math.max(0, Math.min(100, Math.round(asNumber(payload.level, "level"))));
|
|
895
|
+
const target = payload.target === "music" ? "music" : "system";
|
|
896
|
+
|
|
897
|
+
if (context.localTools.dryRun) {
|
|
898
|
+
return { dryRun: true, action: "music_set_volume", target, level };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const script =
|
|
902
|
+
target === "music"
|
|
903
|
+
? `tell application "Music" to set sound volume to ${level}`
|
|
904
|
+
: `set volume output volume ${level}`;
|
|
905
|
+
await runAppleScript(script, [], context.signal);
|
|
906
|
+
return { target, level };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function runMusicNowPlaying(_: unknown, context: ToolExecutionContext) {
|
|
910
|
+
ensureMacOS("Music automation");
|
|
911
|
+
const script =
|
|
912
|
+
'tell application "Music"\n' +
|
|
913
|
+
"if player state is stopped then return \"stopped\"\n" +
|
|
914
|
+
'set tName to name of current track\n' +
|
|
915
|
+
'set tArtist to artist of current track\n' +
|
|
916
|
+
'set tAlbum to album of current track\n' +
|
|
917
|
+
'set pState to player state as text\n' +
|
|
918
|
+
'return tName & tab & tArtist & tab & tAlbum & tab & pState\n' +
|
|
919
|
+
"end tell";
|
|
920
|
+
const output = await runAppleScript(script, [], context.signal);
|
|
921
|
+
if (output === "stopped") {
|
|
922
|
+
return { state: "stopped" };
|
|
923
|
+
}
|
|
924
|
+
const [title, artist, album, state] = output.split("\t");
|
|
925
|
+
return { state, title, artist, album };
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export const musicTools: ToolDefinition[] = [
|
|
929
|
+
{
|
|
930
|
+
name: "music_play",
|
|
931
|
+
description: "Play Apple Music or resume playback.",
|
|
932
|
+
inputSchema: {
|
|
933
|
+
type: "object",
|
|
934
|
+
properties: {
|
|
935
|
+
query: { type: "string" },
|
|
936
|
+
title: { type: "string" },
|
|
937
|
+
artist: { type: "string" },
|
|
938
|
+
source: { type: "string", enum: ["apple_music", "local_library"] },
|
|
939
|
+
},
|
|
940
|
+
additionalProperties: false,
|
|
941
|
+
},
|
|
942
|
+
risk: "app",
|
|
943
|
+
execute: runMusicPlay,
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
name: "music_pause",
|
|
947
|
+
description: "Pause Apple Music playback.",
|
|
948
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
949
|
+
risk: "app",
|
|
950
|
+
execute: async (_input, context) => runMusicCommand("pause", context),
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
name: "music_next",
|
|
954
|
+
description: "Skip to next track in Apple Music.",
|
|
955
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
956
|
+
risk: "app",
|
|
957
|
+
execute: async (_input, context) => runMusicCommand("next", context),
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
name: "music_previous",
|
|
961
|
+
description: "Go to previous track in Apple Music.",
|
|
962
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
963
|
+
risk: "app",
|
|
964
|
+
execute: async (_input, context) => runMusicCommand("previous", context),
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
name: "music_set_volume",
|
|
968
|
+
description: "Set system or Music app volume level.",
|
|
969
|
+
inputSchema: {
|
|
970
|
+
type: "object",
|
|
971
|
+
required: ["level"],
|
|
972
|
+
properties: {
|
|
973
|
+
level: { type: "number" },
|
|
974
|
+
target: { type: "string", enum: ["system", "music"] },
|
|
975
|
+
},
|
|
976
|
+
additionalProperties: false,
|
|
977
|
+
},
|
|
978
|
+
risk: "system",
|
|
979
|
+
execute: runMusicSetVolume,
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
name: "music_get_now_playing",
|
|
983
|
+
description: "Get currently playing track metadata.",
|
|
984
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
985
|
+
risk: "read",
|
|
986
|
+
execute: runMusicNowPlaying,
|
|
987
|
+
},
|
|
988
|
+
];
|