engine-dj-mcp 0.11.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,11 +54,15 @@ the configuration you are reading:
54
54
  ```json
55
55
  {
56
56
  "mcpServers": {
57
- "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp", "--allow-writes"] }
57
+ "engine-dj": { "command": "npx", "args": ["-y", "engine-dj-mcp@0.15.0", "--allow-writes"] }
58
58
  }
59
59
  }
60
60
  ```
61
61
 
62
+ Pin the version in this one. Unpinned, `npx` fetches whatever is newest at
63
+ every launch, and this configuration gives that code write access to your
64
+ library. Pinned, a new release reaches it only when you change the number.
65
+
62
66
  **Requirements:** Node.js 22.16 or newer (`node:sqlite` stopped needing a
63
67
  flag in 22.13, but the pre-write snapshot uses its `backup()`, added in
64
68
  22.16;
@@ -67,10 +71,11 @@ through 3.0.2 — Engine DJ 4.5 and 5.x.
67
71
 
68
72
  ## Tools
69
73
 
70
- Nine read-only tools, and a tenth — `create_playlist` — that appears only
71
- when you start the server with `--allow-writes`. Every tool that reads
72
- library data also accepts an optional `library` argument see
73
- [Choosing a library](#choosing-a-library).
74
+ Nine read-only tools, and four that write — `create_playlist`,
75
+ `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
76
+ `reorder_playlist` that appear only when you start the server with
77
+ `--allow-writes`. Every tool that reads library data also accepts an
78
+ optional `library` argument — see [Choosing a library](#choosing-a-library).
74
79
 
75
80
  ### `search_tracks`
76
81
 
@@ -155,7 +160,7 @@ Positions are sample offsets; cue and loop items also carry seconds.
155
160
 
156
161
  ### `audit_library`
157
162
 
158
- Ten collection health checks. Returns a count and a small sample of ids per
163
+ Eleven collection health checks. Returns a count and a small sample of ids per
159
164
  check, never the full result set — a library with thousands of unanalysed
160
165
  tracks should not fill an assistant's context.
161
166
 
@@ -168,11 +173,12 @@ tracks should not fill an assistant's context.
168
173
  | `no_beatgrid` | Tracks with no beatgrid data |
169
174
  | `missing_key` | Tracks with no key detected |
170
175
  | `suspicious_bpm` | Analysed and tagged tempo disagree, or tempo is outside 60–200 |
171
- | `duplicates` | Same artist and title, or same size and length |
176
+ | `duplicates` | Same artist and title, compared regardless of case in any script |
172
177
  | `empty_metadata` | No artist or no title |
173
178
  | `orphan_entries` | Playlist entries pointing at tracks not in this library — `get_playlist_tracks` shows where each one sits |
179
+ | `path_form_mismatch` | The file is on disk, but its name there — or a folder's on the way — is in a different Unicode form from the path Engine stored. macOS finds it anyway; Linux does not (measured on the kernel's exFAT driver), and Engine OS on a player is Linux, so these may fail to load on hardware. Differences in case alone are not counted: exFAT and Windows ignore case |
174
180
 
175
- `checks` — omit it to run all ten.
181
+ `checks` — omit it to run all eleven.
176
182
 
177
183
  ### `run_sql`
178
184
 
@@ -204,7 +210,7 @@ server checks staleness itself before answering.
204
210
 
205
211
  ### `create_playlist`
206
212
 
207
- The only tool that writes, and the only one that is not registered at all
213
+ The first of the four tools that write, none of which is registered at all
208
214
  unless the server was started with `--allow-writes`.
209
215
 
210
216
  Creates one new top-level playlist from track ids — `track_ids` sets both
@@ -223,19 +229,140 @@ Each entry stores the track's origin identity — `(originDatabaseUuid,
223
229
  originTrackId)`, the pair Engine matches on — not the local row id, so a
224
230
  playlist built here reads the same way Engine's own does.
225
231
 
226
- The result carries `playlist_id`, `tracks_added` and `backup_path`. **To undo
232
+ The result carries `playlist_id`, `tracks_added`, `library` and `backup_path`. **To undo
227
233
  it, delete the playlist in Engine DJ**; `backup_path` is a whole-library
228
234
  snapshot for the case where something went wrong at a lower level, not an
229
235
  undo — see [Restoring a snapshot](#restoring-a-snapshot).
230
236
 
231
- Refusals name themselves: `playlist_exists` for a taken title,
232
- `unknown_track` for an id this library does not have, `duplicate_track` for
233
- the same id twice, `library_busy` if something else holds a conflicting lock
234
- right then, `library_needs_recovery` if Engine DJ left an unrecovered
235
- journal behind. Every error also carries `detail`: `not_committed` means the
236
- library is exactly what it was, and `committed_unverified` — the rare one —
237
- means the write may have landed but could not be verified afterwards, and is
238
- the only case that hands back a `backup_path`.
237
+ Its own refusals: `playlist_exists` for a taken title, `unknown_track` for an
238
+ id this library does not have, `duplicate_track` for the same id twice — plus
239
+ the ones [every write tool shares](#refusals-every-write-tool-shares).
240
+
241
+ ### `add_tracks_to_playlist`
242
+
243
+ Adds one or more tracks to an **existing** playlist this edits that
244
+ playlist's contents, it does not create a new one (`create_playlist` does
245
+ that). If the playlist's entry chain is already damaged, the write is
246
+ refused outright rather than repaired, and nothing is added.
247
+
248
+ A playlist that is a **folder** (`is_folder: true` — it has child lists) is
249
+ edited like any other: Engine has no separate folder type, a folder can hold
250
+ entries of its own, and all three edit tools add to, remove from and reorder
251
+ those entries without complaint. The lists inside it are untouched either
252
+ way.
253
+
254
+ | Argument | What it does |
255
+ | --- | --- |
256
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does: a name matching more than one playlist is refused with every candidate's id and full path listed, not guessed at. |
257
+ | `track_ids` | Ids from `search_tracks` or `get_tracks`, in the order they should appear. A track already in the playlist is refused as `duplicate_track` — Engine allows a track in a playlist only once. |
258
+ | `at` | Where the new tracks land, against the playlist's current 1-based positions (the same numbering `get_playlist_tracks` reports): `"start"`, `"end"` (the default), or `{ after_position: n }`. |
259
+
260
+ The result carries `playlist_id`, `tracks_added`, `positions` — where the
261
+ new tracks landed — `undo`, `undo_complete` (always `true` here), `library`
262
+ and `backup_path`. `undo` is the exact `remove_tracks_from_playlist` call that
263
+ reverses this edit: the positions the tracks landed at, plus
264
+ `expect_track_ids` naming the tracks that landed there, so a playlist
265
+ something else changed in the meantime is refused rather than having the
266
+ wrong rows removed. Call it to undo rather than restoring `backup_path` —
267
+ see [Restoring a snapshot](#restoring-a-snapshot), and
268
+ [An undo covers one library](#an-undo-covers-one-library) for what it does
269
+ not reach. Its own refusals: `playlist_not_found`, `playlist_chain_damaged`,
270
+ `invalid_position`, and `unknown_track` / `duplicate_track` as for
271
+ `create_playlist` — plus the ones
272
+ [every write tool shares](#refusals-every-write-tool-shares).
273
+
274
+ ### `remove_tracks_from_playlist`
275
+
276
+ Removes one or more tracks from an **existing** playlist by position — this
277
+ edits that playlist's contents; it never touches any other playlist. If the
278
+ entry chain is already damaged, the write is refused outright rather than
279
+ repaired.
280
+
281
+ | Argument | What it does |
282
+ | --- | --- |
283
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
284
+ | `positions` | 1-based positions `get_playlist_tracks` reports for this playlist right now. Includes entries whose track is missing from the library (`missing: true`) — removing one is a legitimate way to clean up a hole, and the one removal `undo` cannot reverse (see below). |
285
+ | `expect_track_ids` | Optional, one entry per position: verifies each named position still holds the track expected before anything is removed, refusing the whole call otherwise. `null` means "this position should hold an entry whose track is missing", not "no expectation". |
286
+
287
+ The result carries `playlist_id`, `tracks_removed`, `removed` — each
288
+ position's `track_id`, `null` for a missing one — `undo`, `undo_complete`,
289
+ `library` and `backup_path`. `undo` is a **sequence** of `add_tracks_to_playlist` calls,
290
+ one per removed track that can be restored. Run them in the order given,
291
+ never in parallel and never reversed — each step's target position is
292
+ computed against the list as it stands after the previous step has already
293
+ run, so firing them out of order puts tracks back in the wrong places.
294
+ Preferred over restoring `backup_path` for the same reason as above.
295
+
296
+ `undo_complete` is `false` when the removal included an entry whose track is
297
+ missing from the library: that entry named a track this library does not
298
+ have, so no `add_tracks_to_playlist` call can put it back, and an
299
+ `undo_note` names those positions. The steps that are returned still run and
300
+ still restore everything else; the missing entries are recoverable only from
301
+ `backup_path`, which reverts the whole library.
302
+
303
+ Its own refusals: `playlist_not_found`, `playlist_chain_damaged`, and
304
+ `invalid_position` — for a repeated or out-of-range position, or one that
305
+ does not hold what `expect_track_ids` expected — plus the ones
306
+ [every write tool shares](#refusals-every-write-tool-shares).
307
+
308
+ `playlist_chain_damaged` always means the same thing for all three edit
309
+ tools: the playlist's entry chain was already broken **before** the edit,
310
+ which is why the edit refused to touch it. If instead the check each edit
311
+ runs on its own work disagrees — the chain did not read back as it was
312
+ written — the transaction is rolled back and that comes back as
313
+ `library_unreadable`, with `detail: "not_committed"`. Both leave the library
314
+ exactly as it was; only the second one is this server saying it does not
315
+ understand what the library just did.
316
+
317
+ ### `reorder_playlist`
318
+
319
+ Reorders an **existing** playlist's tracks — this changes the order of that
320
+ playlist's existing entries; it adds nothing and removes nothing. If the
321
+ entry chain is already damaged, the write is refused outright rather than
322
+ repaired.
323
+
324
+ | Argument | What it does |
325
+ | --- | --- |
326
+ | `playlist_id` / `playlist_name` | Exactly one of the two, resolved the same way `get_playlist_tracks` does. |
327
+ | `order` | A full permutation of `1..n`, `n` being the playlist's current entry count. `order[i]` names the *current* 1-based position (from `get_playlist_tracks`) of the track that should end up at position `i + 1`. A partial "move x to y" instruction is not accepted — name every position, including ones that do not move. |
328
+
329
+ The result carries `playlist_id`, `undo`, `undo_complete` (always `true`
330
+ here), `library` and `backup_path`. `undo` is the exact inverse permutation, as a single
331
+ `reorder_playlist` call. Its own refusals: `playlist_not_found`,
332
+ `playlist_chain_damaged`, and `invalid_position` if `order` is not a full
333
+ permutation of the playlist's current positions — plus the ones
334
+ [every write tool shares](#refusals-every-write-tool-shares).
335
+
336
+ Reordering to the order a playlist is already in is accepted and rewrites no
337
+ entry: it still stamps the playlist's `lastEditTime`, and still costs this
338
+ session's snapshot if nothing had been written yet.
339
+
340
+ ### Refusals every write tool shares
341
+
342
+ These come from what happens before the write itself — choosing the library,
343
+ bringing its index up to date, resolving the playlist — and from the write's
344
+ own checks.
345
+
346
+ | Code | Means | Nothing written? |
347
+ | --- | --- | --- |
348
+ | `invalid_argument` | The arguments do not make sense — both `playlist_id` and `playlist_name`, an empty list where one is required, or a `playlist_name` that matches several playlists (every candidate is listed). | yes |
349
+ | `library_not_found` | `library` names nothing connected — the refusal lists what is — or the library's header could not be read. | yes |
350
+ | `ambiguous_library` | No `library` given, and two libraries tie for the default. Lists both — see [Choosing a library](#choosing-a-library). | yes |
351
+ | `unsupported_schema` | The library's version is outside what this server supports. | yes |
352
+ | `library_needs_recovery` | Engine DJ left an unrecovered journal. Launch Engine once. | yes |
353
+ | `library_busy` | Something holds a conflicting lock right now. Retry. | yes |
354
+ | `index_stale` | The index could not be built yet, typically because Engine holds a lock on a first run. Carries `retry_after_ms`. | yes |
355
+ | `query_timeout`, `query_process_crashed` | The lookup that resolves a playlist failed. Edit tools only. | yes |
356
+ | `library_unreadable` | The library could not be read; the snapshot taken before the first write could not be made (a full disk, or a Node older than 22.16); or a write's own read-back disagreed with what it wrote, and it was rolled back. | see `detail` |
357
+
358
+ **`detail` on these errors.** Once the write itself has started, `detail` is
359
+ exactly one of two strings, and a client can read it to decide whether the
360
+ library changed: `not_committed` — the library is what it was — or
361
+ `committed_unverified` — the rare one: the write may have landed but could not
362
+ be confirmed, and only this case hands back a `backup_path`. Refusals raised
363
+ *before* that point — every row above marked "yes" — never opened the library
364
+ for writing, whatever their `detail` says: it may be absent, `not_committed`,
365
+ or explanatory text such as the candidates an ambiguous `playlist_name` lists.
239
366
 
240
367
  ## Resources
241
368
 
@@ -260,6 +387,26 @@ tracks**. That matters: the local library Engine DJ creates on install is
260
387
  scanned first and is often empty, so "the first one found" would hide the
261
388
  drive you actually work from.
262
389
 
390
+ When two supported libraries hold the *same* highest number of tracks, that
391
+ rule names no winner — and a tie is the ordinary case, not an exotic one: a
392
+ USB drive and its copy on the computer tie precisely because one is a copy of
393
+ the other. Measured 2026-09-01, both real libraries reported 257 tracks.
394
+
395
+ **A read still chooses for itself.** Tied libraries hold the same tracks, so
396
+ either answer is very nearly the same answer, and making you name a library
397
+ you have no reason to care about would be noise.
398
+
399
+ **A write refuses**, with `ambiguous_library` listing every candidate and its
400
+ track count, and `detail: "not_committed"`. The choice decides which physical
401
+ disk changes, and one of the two may be the drive you perform from; scan
402
+ order is not a reason to pick it. Name a library and the write goes through,
403
+ tie or not — the ambiguity being refused is the server's, not yours.
404
+
405
+ The refusal tells the assistant to **ask you** rather than choose. Otherwise
406
+ "pass `library`, here are the two" is an invitation to take the first one,
407
+ which puts the write back on an arbitrary disk and makes the refusal
408
+ pointless.
409
+
263
410
  Each library gets its own index and its own connection, opened the first time
264
411
  you ask that library something. Comparing two libraries against each other —
265
412
  *"what is on this drive but not that one?"* — is **not** something this server
@@ -278,11 +425,32 @@ created inside your `Engine Library` folder. The search index lives in
278
425
  Without `--allow-writes` the server has no tool that can write, and the
279
426
  paragraph above holds exactly as written: SQLite itself refuses.
280
427
 
281
- With the flag, one tool appears `create_playlist`. It adds a new playlist
282
- and nothing else: no existing playlist is renamed, reordered, emptied or
283
- deleted, and no track, cue or beatgrid is touched. The single change to an
284
- existing row is the previous last playlist's link, made by Engine's own
285
- trigger.
428
+ With the flag, four tools appear. `create_playlist` adds a new playlist and
429
+ nothing else. `add_tracks_to_playlist`, `remove_tracks_from_playlist` and
430
+ `reorder_playlist` go further: with the flag, an **existing** playlist can
431
+ now be changed, not only created its tracks added to, removed from, or put
432
+ in a different order. What each one touches is the named playlist's own
433
+ entries, plus exactly two rows elsewhere: that playlist's own row, whose
434
+ `lastEditTime` every edit stamps so Engine sees the change, and — for
435
+ `create_playlist` only — the previous last playlist's link, made by Engine's
436
+ own insert trigger. No other playlist is renamed, emptied or deleted, and no
437
+ track, cue or beatgrid is touched by any of the four.
438
+
439
+ Every edit returns `undo` — the exact tool call that reverses it, expressed
440
+ against the positions the edit itself produced — and `undo_complete`, saying
441
+ whether replaying it puts the playlist back exactly as it was. Replaying
442
+ `undo` is the right way back from an edit; restoring `backup_path` is not,
443
+ because it reverts the **whole library** to before this session's first
444
+ write, discarding every play count, import, cue and beatgrid change Engine
445
+ DJ has recorded since, along with the one edit you actually wanted undone.
446
+ See [Restoring a snapshot](#restoring-a-snapshot).
447
+
448
+ There is exactly one edit `undo` cannot reverse, and it says so rather than
449
+ pretending otherwise: removing an entry whose track is missing from the
450
+ library (`missing: true`). Such an entry names a track this library does not
451
+ have, so there is no track id to add back — the result comes back with
452
+ `undo_complete: false` and an `undo_note` naming those positions, and the
453
+ steps it does return still restore everything else.
286
454
 
287
455
  Before the first write of a session the database is snapshotted to
288
456
  `~/.engine-dj-mcp/backups/`, and every write of that session returns its
@@ -302,23 +470,74 @@ and without `--allow-writes` not even this.
302
470
  The write takes SQLite's own write lock for the length of one transaction and
303
471
  does not wait for it: if something else — Engine DJ mid-save, a player — is
304
472
  holding a conflicting lock at that moment, the write is refused with
305
- `library_busy` and nothing is changed. Merely having Engine DJ *open* is not
306
- usually a conflict, and the write normally succeeds with Engine running;
307
- Engine will show the new playlist after it next re-reads the library.
473
+ `library_busy` and nothing is changed.
474
+
475
+ **Quit Engine DJ before writing.** Having Engine open is not usually a lock
476
+ conflict, so the write itself will normally go through — but what Engine then
477
+ does with a change made underneath it has never been measured here. Every
478
+ acceptance check of a write was run with Engine closed. What *has* been
479
+ measured is that Engine does its own work on the library as it loads: it
480
+ renumbers playlist entries, and it copies playlist changes to another
481
+ connected library (see below). Quit, write, relaunch — Engine reads the
482
+ library on startup and shows the change.
483
+
484
+ Quit, not close. On macOS, closing Engine's window leaves the application
485
+ running: observed 2026-09-01 with the main process and seven
486
+ `OfflineAnalyzer` workers — which write to the database — still alive
487
+ afterwards. Use ⌘Q.
488
+
489
+ ### An undo covers one library
490
+
491
+ Every write result carries a `library` field — the `uuid` and `path` of the
492
+ library the write actually landed in. Two libraries connected at once is the
493
+ ordinary setup: a USB drive and its copy on the computer. This is where you
494
+ check which of them a write went to.
495
+
496
+ **`undo` reverses the edit in that one library, and only there.** Engine DJ
497
+ moves playlist changes between connected libraries by itself, so a copy of
498
+ your edit can end up somewhere `undo` cannot reach.
499
+
500
+ Measured 2026-09-01. A track was added to a playlist in the library on the
501
+ computer. Engine DJ was then launched with the USB drive attached, and the
502
+ same playlist on the USB came back with the same track added — the copy
503
+ carrying the very `lastEditTime` this server's `INSERT` had written. The
504
+ `undo` was then run and reversed the edit on the computer. The USB kept it.
505
+
506
+ Nothing was damaged: both libraries stayed sound. But the two had diverged,
507
+ and the `undo` reported success, correctly, because within its own library it
508
+ did exactly what it promised.
509
+
510
+ So: if a second library is connected, look at the `library` field, and undo
511
+ against each library separately. Undoing before Engine DJ next runs avoids
512
+ the problem entirely.
513
+
514
+ Which library a change propagates to, and in which direction, is Engine's own
515
+ business — this project does not model it and will not guess at it.
308
516
 
309
517
  ### Restoring a snapshot
310
518
 
311
519
  `backup_path` is not an undo. It is a copy of the **whole** `m.db` from
312
520
  before the session's first write, so putting it back reverts the entire
313
521
  library to that moment: every play count, import, cue, beatgrid and rating
314
- Engine DJ has written since is discarded along with the playlist you wanted
315
- gone. Reach for it only if the library itself is damaged — the case where
316
- `create_playlist` comes back with `detail: "committed_unverified"`.
317
-
318
- **To undo a playlist, delete it in Engine DJ.** Engine's own delete trigger
319
- repairs the playlist chain and cascades the entries away, which is exactly
320
- what removing it should do and is not something restoring a snapshot does
321
- better.
522
+ Engine DJ has written since is discarded along with the one edit you wanted
523
+ gone. Reach for it only if the library itself is damaged — the case where a
524
+ write comes back with `detail: "committed_unverified"`.
525
+
526
+ Snapshots live in `~/.engine-dj-mcp/backups/`, ten per library. Only a name
527
+ ending in `.db` is a snapshot. A file ending in `.partial-<number>` — with or
528
+ without `-journal` after it is a copy still being written, or one whose
529
+ process died before it finished: **never restore one of those**. A copy is
530
+ renamed to its `.db` name only once it is complete, and an abandoned one is
531
+ cleared the next time that library is snapshotted.
532
+
533
+ **To undo a playlist you created, delete it in Engine DJ.** Engine's own
534
+ delete trigger repairs the playlist chain and cascades the entries away,
535
+ which is exactly what removing it should do and is not something restoring
536
+ a snapshot does better. **To undo an edit to an existing playlist, replay
537
+ the `undo` the edit returned instead** — it names the precise
538
+ `add_tracks_to_playlist`, `remove_tracks_from_playlist` or
539
+ `reorder_playlist` call that puts the playlist back exactly as it was,
540
+ without touching anything else Engine DJ has recorded since.
322
541
 
323
542
  `run_sql` accepts arbitrary SQL, but only the first statement is ever
324
543
  executed, and `VACUUM`, `ATTACH` and `DETACH` are rejected outright, so a
@@ -327,7 +546,7 @@ chained or exfiltrating statement cannot slip past the read-only connection.
327
546
  If Engine DJ was closed uncleanly and left an unrecovered journal, this
328
547
  server will not open the library to "fix" it, with or without
329
548
  `--allow-writes` — rolling a journal forward is a repair on someone else's
330
- file, and `create_playlist` refuses such a library outright rather than
549
+ file, and every write tool refuses such a library outright rather than
331
550
  letting SQLite do it on the way in. It reports `library_needs_recovery` and
332
551
  asks you to launch Engine DJ once so it can recover its own library.
333
552
 
@@ -374,9 +593,9 @@ still test for the blob: `beatData` has no "written but empty" state.
374
593
 
375
594
  **It writes nothing but playlists, and only when you ask for it.** Without
376
595
  `--allow-writes` the library is opened read-only at the OS level and there is
377
- no tool that could write. With the flag, `create_playlist` adds playlists
378
- and that is the whole list. Not a cue, not a tag, not a rating, and not even
379
- the recovery of a journal Engine DJ left behind.
596
+ no tool that could write. With the flag, the four write tools add, edit and
597
+ reorder playlists — and that is the whole list. Not a cue, not a tag, not a
598
+ rating, and not even the recovery of a journal Engine DJ left behind.
380
599
 
381
600
  **It does not read play history.** `Track.timeLastPlayed` answers "what have I
382
601
  not played in six months?", but the separate Engine history database —
@@ -392,11 +611,12 @@ flag — a folder is simply a playlist that other playlists sit under — so
392
611
  `is_folder` means "has child lists". A folder you have emptied is
393
612
  indistinguishable from a playlist with no tracks.
394
613
 
395
- **Playlists can be created, not edited.** With `--allow-writes` a new
396
- playlist can be added; there is no reordering, renaming, deleting, or adding
397
- a track to a playlist that already exists, and no set lists or suggested
398
- transitions. It answers questions about the collection and writes down the
399
- answer if you ask; the mixing is yours.
614
+ **A playlist's tracks can be edited; the playlist itself cannot.** With
615
+ `--allow-writes` a new playlist can be created, and an existing one can have
616
+ tracks added, removed or reordered but not renamed, deleted, moved between
617
+ folders, or turned into a folder itself, and there are no set lists or
618
+ suggested transitions. It answers questions about the collection and writes
619
+ down the answer if you ask; the mixing is yours.
400
620
 
401
621
  **Schema 3.0.0 through 3.0.2 only.** Older and newer libraries are listed with
402
622
  their version and reported as unsupported rather than read on a guess.
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track"];
1
+ export declare const ERROR_CODES: readonly ["library_busy", "library_not_found", "library_unreadable", "unsupported_schema", "query_timeout", "query_process_crashed", "index_stale", "decode_failed", "invalid_argument", "library_needs_recovery", "playlist_exists", "unknown_track", "duplicate_track", "playlist_chain_damaged", "playlist_not_found", "invalid_position", "ambiguous_library"];
2
2
  export type ErrorCode = (typeof ERROR_CODES)[number];
3
3
  export interface EngineError {
4
4
  error: ErrorCode;
package/dist/errors.js CHANGED
@@ -27,6 +27,18 @@ export const ERROR_CODES = [
27
27
  "playlist_exists",
28
28
  "unknown_track",
29
29
  "duplicate_track",
30
+ // Editing an existing playlist. playlist_chain_damaged is the one that
31
+ // matters: a chain with a cycle or a severed link cannot be edited without
32
+ // making it worse, and the edit would not notice.
33
+ "playlist_chain_damaged",
34
+ "playlist_not_found",
35
+ "invalid_position",
36
+ // No `library` was passed and the default rule names no single winner --
37
+ // two supported libraries hold the same, highest track count. Its own code
38
+ // rather than invalid_argument because the useful client response is
39
+ // specific: ask which drive, then retry with `library` set. Only writes
40
+ // raise it; see library-select.ts for why reads still choose.
41
+ "ambiguous_library",
30
42
  ];
31
43
  export function err(error, message, extra = {}) {
32
44
  return { error, message, ...extra };
@@ -34,6 +34,42 @@ export declare const LibraryArg: z.ZodOptional<z.ZodString>;
34
34
  * supported library" would otherwise collapse into.
35
35
  */
36
36
  export declare function pickDefaultLibrary(libs: readonly LibraryInfo[]): LibraryInfo | null;
37
+ /**
38
+ * The supported libraries tied for the default pick -- more than one holding
39
+ * the same, highest track count. Empty when the default is unambiguous, which
40
+ * includes the single-library case every DJ starts from.
41
+ *
42
+ * A tie is not an exotic shape: it is what a USB drive and its copy on the
43
+ * computer produce, and they tie *because* one is a copy of the other.
44
+ * Measured 2026-09-01, both real libraries reported 257 tracks.
45
+ *
46
+ * For a read, either answer is very nearly the same answer -- the two are
47
+ * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
48
+ * name a library it does not care about. For a write the choice decides which
49
+ * physical disk changes, and one of them is the drive the DJ performs from.
50
+ * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
51
+ * check this first.
52
+ *
53
+ * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
54
+ * one can never be chosen while a supported library exists, so it is not a
55
+ * competing candidate and must not make a write refuse.
56
+ */
57
+ export declare function defaultLibraryTies(libs: readonly LibraryInfo[]): LibraryInfo[];
58
+ /**
59
+ * The refusal for an ambiguous default on a write.
60
+ *
61
+ * Every candidate is named, with its track count, because the caller has to
62
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
63
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
64
+ *
65
+ * The list goes in `message`, never in `detail`. This is only ever returned
66
+ * from a write tool, and on that path `detail` carries exactly
67
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
68
+ * decide whether its library changed. Prose there would break that read for
69
+ * the one error whose answer is least in doubt: nothing was opened, let alone
70
+ * written.
71
+ */
72
+ export declare function ambiguousLibrary(tied: readonly LibraryInfo[]): EngineError;
37
73
  /**
38
74
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
39
75
  * path. Returns null when it matches neither -- the caller decides what
@@ -14,7 +14,10 @@ import { expandHome, redactPath } from "./paths.js";
14
14
  */
15
15
  export const LIBRARY_ARG_DESCRIPTION = "Which library to use: either the uuid or the path reported by list_libraries " +
16
16
  "(the reported ~/... form is accepted, as is the absolute path). Omit it to use " +
17
- "the supported library holding the most tracks.";
17
+ "the supported library holding the most tracks. If two supported libraries hold " +
18
+ "the same most tracks -- what a USB drive and its copy on the computer produce -- " +
19
+ "a read still picks one, but a WRITE refuses with ambiguous_library listing both, " +
20
+ "since the choice decides which disk changes; ask the user which, then pass it here.";
18
21
  export const LibraryArg = z.string().min(1).optional().describe(LIBRARY_ARG_DESCRIPTION);
19
22
  /**
20
23
  * The default when no `library` was given: the supported library with the
@@ -47,6 +50,55 @@ export function pickDefaultLibrary(libs) {
47
50
  }
48
51
  return best ?? libs[0] ?? null;
49
52
  }
53
+ /**
54
+ * The supported libraries tied for the default pick -- more than one holding
55
+ * the same, highest track count. Empty when the default is unambiguous, which
56
+ * includes the single-library case every DJ starts from.
57
+ *
58
+ * A tie is not an exotic shape: it is what a USB drive and its copy on the
59
+ * computer produce, and they tie *because* one is a copy of the other.
60
+ * Measured 2026-09-01, both real libraries reported 257 tracks.
61
+ *
62
+ * For a read, either answer is very nearly the same answer -- the two are
63
+ * copies -- so `pickDefaultLibrary` keeps choosing, and a read never has to
64
+ * name a library it does not care about. For a write the choice decides which
65
+ * physical disk changes, and one of them is the drive the DJ performs from.
66
+ * Hence: reads choose, writes refuse. Callers wanting the strict behaviour
67
+ * check this first.
68
+ *
69
+ * Skips unsupported libraries for the same reason `pickDefaultLibrary` does:
70
+ * one can never be chosen while a supported library exists, so it is not a
71
+ * competing candidate and must not make a write refuse.
72
+ */
73
+ export function defaultLibraryTies(libs) {
74
+ const supported = libs.filter((l) => l.supported);
75
+ if (supported.length < 2)
76
+ return [];
77
+ const best = Math.max(...supported.map((l) => l.trackCount ?? -1));
78
+ const tied = supported.filter((l) => (l.trackCount ?? -1) === best);
79
+ return tied.length > 1 ? tied : [];
80
+ }
81
+ /**
82
+ * The refusal for an ambiguous default on a write.
83
+ *
84
+ * Every candidate is named, with its track count, because the caller has to
85
+ * pick one and cannot do that from "it was ambiguous" -- the same reason
86
+ * get_playlist_tracks lists candidates for an ambiguous playlist name.
87
+ *
88
+ * The list goes in `message`, never in `detail`. This is only ever returned
89
+ * from a write tool, and on that path `detail` carries exactly
90
+ * `"not_committed"` or `"committed_unverified"` -- a client reads it to
91
+ * decide whether its library changed. Prose there would break that read for
92
+ * the one error whose answer is least in doubt: nothing was opened, let alone
93
+ * written.
94
+ */
95
+ export function ambiguousLibrary(tied) {
96
+ const list = tied.map((l) => `${l.uuid} -- ${redactPath(l.path)} (${l.trackCount} tracks)`).join("; ");
97
+ return err("ambiguous_library", `More than one library holds the most tracks, so there is no default to write to: ${list}. ` +
98
+ `Nothing was written. ASK which one to write to, then retry with \`library\` set -- ` +
99
+ `do not choose for them. These are usually a USB drive and its copy on the computer, ` +
100
+ `and one of them may be the drive they perform from.`, { detail: "not_committed" });
101
+ }
50
102
  /**
51
103
  * Resolves a caller-supplied `library` value: uuid first, then filesystem
52
104
  * path. Returns null when it matches neither -- the caller decides what
package/dist/paths.d.ts CHANGED
@@ -19,6 +19,17 @@ export declare function libraryCandidates(root: string): string[];
19
19
  * shipped to a model provider, so the home prefix is folded to `~` by default.
20
20
  */
21
21
  export declare function redactPath(p: string): string;
22
+ /**
23
+ * redactPath for a URI. A uri carries its path after a scheme and usually
24
+ * percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
25
+ * as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
26
+ * on one, and a "redacted" uri would leak exactly what redaction is there to
27
+ * hide. Folds every occurrence, raw or encoded, either case of hex digit since
28
+ * encoders differ. Only a whole path component: the home directory must be
29
+ * followed by a separator or the end, so a sibling account whose name merely
30
+ * starts the same is left alone.
31
+ */
32
+ export declare function redactUri(u: string): string;
22
33
  /**
23
34
  * The inverse of redactPath, for values coming back *in*. Every library path
24
35
  * this server reports has been through redactPath, so the most obvious way
package/dist/paths.js CHANGED
@@ -34,6 +34,26 @@ export function redactPath(p) {
34
34
  const home = homedir();
35
35
  return p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
36
36
  }
37
+ /**
38
+ * redactPath for a URI. A uri carries its path after a scheme and usually
39
+ * percent-encoded -- the home directory shows up as `%2FUsers%2F<name>`, not
40
+ * as a leading `/Users/<name>` -- so redactPath's prefix check would never fire
41
+ * on one, and a "redacted" uri would leak exactly what redaction is there to
42
+ * hide. Folds every occurrence, raw or encoded, either case of hex digit since
43
+ * encoders differ. Only a whole path component: the home directory must be
44
+ * followed by a separator or the end, so a sibling account whose name merely
45
+ * starts the same is left alone.
46
+ */
47
+ export function redactUri(u) {
48
+ const home = homedir();
49
+ if (!home || home === "/")
50
+ return u;
51
+ const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52
+ const raw = new RegExp(`${esc(home)}(?=/|$)`, "g");
53
+ const encoded = esc(encodeURIComponent(home)).replace(/%([0-9A-F])([0-9A-F])/g, (_, a, b) => `%[${a}${a.toLowerCase()}][${b}${b.toLowerCase()}]`);
54
+ const enc = new RegExp(`${encoded}(?=%2[Ff]|$)`, "g");
55
+ return u.replace(raw, "~").replace(enc, "~");
56
+ }
37
57
  /**
38
58
  * The inverse of redactPath, for values coming back *in*. Every library path
39
59
  * this server reports has been through redactPath, so the most obvious way
@@ -1,4 +1,4 @@
1
- import { type EngineError } from "./errors.js";
1
+ import { type EngineError, type ErrorCode } from "./errors.js";
2
2
  import type { QueryProcess } from "./proc/query-client.js";
3
3
  /**
4
4
  * Engine stores both playlist order and playlist-entry order as singly
@@ -191,6 +191,16 @@ export interface PlaylistSelector {
191
191
  export interface SelectorNames {
192
192
  id: string;
193
193
  name: string;
194
+ /**
195
+ * The code for "this library has no such playlist", when the caller has a
196
+ * more accurate one than the default `invalid_argument`. The write tools
197
+ * pass `playlist_not_found`, which their own store functions already
198
+ * return for an id that reaches them -- without this, that code was
199
+ * unreachable through MCP, because resolution runs first and every miss
200
+ * came back as invalid_argument. Ambiguity is never reported through this:
201
+ * a name matching several playlists really is a problem with the argument.
202
+ */
203
+ notFound?: ErrorCode;
194
204
  }
195
205
  export interface ResolvedPlaylist {
196
206
  playlist: PlaylistItem;
@@ -205,10 +215,14 @@ export interface ResolvedPlaylist {
205
215
  * whole function exists to prevent. The error names every candidate with its
206
216
  * id and its full path, so the retry is a copy-paste rather than a guess.
207
217
  *
208
- * Deliberately reuses `invalid_argument` rather than adding an error code:
209
- * the taxonomy is closed (see errors.ts), and "the playlist you named is not
210
- * in this library" is a problem with the argument, reported the same way an
211
- * unknown field name is -- with the recognised values in `detail`.
218
+ * Reuses `invalid_argument` by default rather than adding an error code: the
219
+ * taxonomy is closed (see errors.ts), and for a reader "the playlist you
220
+ * named is not in this library" is a problem with the argument, reported the
221
+ * same way an unknown field name is -- with the recognised values in
222
+ * `detail`. A caller that already owns a more accurate code for that one
223
+ * case passes it as `names.notFound`; the write tools do, so their documented
224
+ * `playlist_not_found` is what a client actually sees. Ambiguity is never
225
+ * reported through it.
212
226
  */
213
227
  export declare function resolvePlaylist(qp: QueryProcess, sel: PlaylistSelector, names?: SelectorNames): Promise<ResolvedPlaylist | EngineError>;
214
228
  /** One entry of a playlist, in playlist order. */