engine-dj-mcp 0.9.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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/blobs/index.d.ts +337 -0
- package/dist/blobs/index.js +483 -0
- package/dist/blobs/qcompress.d.ts +44 -0
- package/dist/blobs/qcompress.js +146 -0
- package/dist/discovery.d.ts +36 -0
- package/dist/discovery.js +111 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +49 -0
- package/dist/guard.d.ts +31 -0
- package/dist/guard.js +236 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +13 -0
- package/dist/library-select.d.ts +63 -0
- package/dist/library-select.js +97 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.js +34 -0
- package/dist/probe.d.ts +7 -0
- package/dist/probe.js +20 -0
- package/dist/proc/query-client.d.ts +36 -0
- package/dist/proc/query-client.js +249 -0
- package/dist/proc/query-worker.d.ts +1 -0
- package/dist/proc/query-worker.js +72 -0
- package/dist/semantics.d.ts +43 -0
- package/dist/semantics.js +95 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +439 -0
- package/dist/sidecar/build.d.ts +19 -0
- package/dist/sidecar/build.js +85 -0
- package/dist/sidecar/schema.d.ts +25 -0
- package/dist/sidecar/schema.js +36 -0
- package/dist/store/connections.d.ts +28 -0
- package/dist/store/connections.js +116 -0
- package/dist/store/index-manager.d.ts +29 -0
- package/dist/store/index-manager.js +187 -0
- package/dist/tools/audit.d.ts +15 -0
- package/dist/tools/audit.js +148 -0
- package/dist/tools/libraries.d.ts +40 -0
- package/dist/tools/libraries.js +30 -0
- package/dist/tools/performance.d.ts +15 -0
- package/dist/tools/performance.js +47 -0
- package/dist/tools/refresh.d.ts +8 -0
- package/dist/tools/refresh.js +3 -0
- package/dist/tools/search.d.ts +60 -0
- package/dist/tools/search.js +328 -0
- package/dist/tools/sql.d.ts +14 -0
- package/dist/tools/sql.js +21 -0
- package/dist/tools/tracks.d.ts +12 -0
- package/dist/tools/tracks.js +49 -0
- package/package.json +53 -0
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function sidecarDir(uuid: string): string;
|
|
2
|
+
/** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
|
|
3
|
+
export declare function absTrackPath(mdbPath: string, relative: string): string;
|
|
4
|
+
/** Candidate locations of `m.db` beneath a filesystem root. */
|
|
5
|
+
export declare function libraryCandidates(root: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Absolute library paths carry the user's account name. Search results are
|
|
8
|
+
* shipped to a model provider, so the home prefix is folded to `~` by default.
|
|
9
|
+
*/
|
|
10
|
+
export declare function redactPath(p: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* The inverse of redactPath, for values coming back *in*. Every library path
|
|
13
|
+
* this server reports has been through redactPath, so the most obvious way
|
|
14
|
+
* to name a library -- copy the `path` list_libraries just printed -- hands
|
|
15
|
+
* back a `~/...` string that exists on no filesystem. Anything without a
|
|
16
|
+
* leading `~` is returned untouched, so an absolute path stays absolute.
|
|
17
|
+
*/
|
|
18
|
+
export declare function expandHome(p: string): string;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
export function sidecarDir(uuid) {
|
|
4
|
+
return join(homedir(), ".engine-dj-mcp", uuid);
|
|
5
|
+
}
|
|
6
|
+
/** Engine stores Track.path relative to the `Engine Library` folder, usually with `..`. */
|
|
7
|
+
export function absTrackPath(mdbPath, relative) {
|
|
8
|
+
const engineLibrary = dirname(dirname(mdbPath)); // .../Engine Library/Database2/m.db
|
|
9
|
+
return resolve(engineLibrary, relative);
|
|
10
|
+
}
|
|
11
|
+
/** Candidate locations of `m.db` beneath a filesystem root. */
|
|
12
|
+
export function libraryCandidates(root) {
|
|
13
|
+
return [join(root, "Engine Library", "Database2", "m.db")];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Absolute library paths carry the user's account name. Search results are
|
|
17
|
+
* shipped to a model provider, so the home prefix is folded to `~` by default.
|
|
18
|
+
*/
|
|
19
|
+
export function redactPath(p) {
|
|
20
|
+
const home = homedir();
|
|
21
|
+
return p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The inverse of redactPath, for values coming back *in*. Every library path
|
|
25
|
+
* this server reports has been through redactPath, so the most obvious way
|
|
26
|
+
* to name a library -- copy the `path` list_libraries just printed -- hands
|
|
27
|
+
* back a `~/...` string that exists on no filesystem. Anything without a
|
|
28
|
+
* leading `~` is returned untouched, so an absolute path stays absolute.
|
|
29
|
+
*/
|
|
30
|
+
export function expandHome(p) {
|
|
31
|
+
if (p === "~")
|
|
32
|
+
return homedir();
|
|
33
|
+
return p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
|
|
34
|
+
}
|
package/dist/probe.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQLite file-change counter: 4 bytes big-endian at offset 24 of the
|
|
3
|
+
* database header. It increments on every write transaction, is part of the
|
|
4
|
+
* on-disk format, and survives process restarts — unlike PRAGMA data_version,
|
|
5
|
+
* which only tracks changes within the life of one connection.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readChangeCounter(dbPath: string): number;
|
package/dist/probe.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* The SQLite file-change counter: 4 bytes big-endian at offset 24 of the
|
|
4
|
+
* database header. It increments on every write transaction, is part of the
|
|
5
|
+
* on-disk format, and survives process restarts — unlike PRAGMA data_version,
|
|
6
|
+
* which only tracks changes within the life of one connection.
|
|
7
|
+
*/
|
|
8
|
+
export function readChangeCounter(dbPath) {
|
|
9
|
+
const fd = openSync(dbPath, "r");
|
|
10
|
+
try {
|
|
11
|
+
const buf = Buffer.alloc(28);
|
|
12
|
+
const read = readSync(fd, buf, 0, 28, 0);
|
|
13
|
+
if (read < 28)
|
|
14
|
+
throw new Error(`${dbPath}: file too short to be a SQLite database`);
|
|
15
|
+
return buf.readUInt32BE(24);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
closeSync(fd);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type EngineError } from "../errors.js";
|
|
2
|
+
export interface QueryResult {
|
|
3
|
+
columns: string[];
|
|
4
|
+
rows: unknown[][];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Runs every library query in a child process. node:sqlite exposes no
|
|
8
|
+
* interrupt() and DatabaseSync blocks the event loop; worker.terminate()
|
|
9
|
+
* waits for the synchronous native call to return, so a killable process is
|
|
10
|
+
* the only timeout that actually works. It also isolates a crash in the
|
|
11
|
+
* experimental node:sqlite binding from the MCP server itself.
|
|
12
|
+
*/
|
|
13
|
+
export declare class QueryProcess {
|
|
14
|
+
#private;
|
|
15
|
+
private readonly mdbPath;
|
|
16
|
+
private sidecar;
|
|
17
|
+
private readonly timeoutMs;
|
|
18
|
+
constructor(mdbPath: string, sidecar: string | null, timeoutMs?: number);
|
|
19
|
+
run(sql: string, params?: unknown[]): Promise<QueryResult | EngineError>;
|
|
20
|
+
/**
|
|
21
|
+
* True when an index is actually ATTACHed as `side` on the live
|
|
22
|
+
* connection. Every tool's SQL joins `side.track_derived`, so a caller
|
|
23
|
+
* that ignores this and queries anyway gets a raw SQLite
|
|
24
|
+
* "no such table: side.track_derived" rather than a structured error --
|
|
25
|
+
* which is precisely what happened on a first run against a busy library.
|
|
26
|
+
*/
|
|
27
|
+
get hasSidecar(): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Returns whether the attach actually took. A sidecar that cannot be
|
|
30
|
+
* attached (missing, corrupt, unreadable) must not leave `hasSidecar`
|
|
31
|
+
* claiming an index is available, and must not be handed to the next
|
|
32
|
+
* #spawn as an argv the worker would then fail to open.
|
|
33
|
+
*/
|
|
34
|
+
setSidecar(path: string): Promise<boolean>;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// src/proc/query-client.ts
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join, sep } from "node:path";
|
|
5
|
+
import { err, EngineErrorException } from "../errors.js";
|
|
6
|
+
/**
|
|
7
|
+
* Tests run against src/ under vitest, whose TypeScript transform does not
|
|
8
|
+
* rewrite import specifiers — a forked query-worker.ts could never resolve
|
|
9
|
+
* its own "../store/connections.js" import, since only connections.ts exists
|
|
10
|
+
* under src/. package.json's pretest always runs `npm run build` first, so
|
|
11
|
+
* the compiled worker exists at dist/proc/query-worker.js; resolve to that
|
|
12
|
+
* compiled file from either location rather than forking TypeScript source.
|
|
13
|
+
*/
|
|
14
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const WORKER = HERE.includes(`${sep}src${sep}proc`)
|
|
16
|
+
? join(HERE, "..", "..", "dist", "proc", "query-worker.js")
|
|
17
|
+
: join(HERE, "query-worker.js");
|
|
18
|
+
/**
|
|
19
|
+
* Extra attempts after the first, per the spec's "busy_timeout = 3000 plus
|
|
20
|
+
* three attempts with jitter". Engine's own write transactions are usually
|
|
21
|
+
* short, so a lock that outlasts one 3 s busy_timeout often clears well
|
|
22
|
+
* inside the next -- this converts a refusal into an answer more often than
|
|
23
|
+
* not. It is bounded on purpose: an unbounded retry against an Engine DJ
|
|
24
|
+
* that is mid-import would hang the tool call instead of reporting
|
|
25
|
+
* library_busy with a retry_after_ms the model can act on.
|
|
26
|
+
*/
|
|
27
|
+
const BUSY_RETRIES = 2;
|
|
28
|
+
/**
|
|
29
|
+
* Jitter, not a fixed delay: several tools retrying in lockstep would
|
|
30
|
+
* otherwise re-collide on the same lock every time. Short (50-100 ms, then
|
|
31
|
+
* 100-200 ms) because the real waiting already happened inside SQLite's own
|
|
32
|
+
* busy_timeout; this only staggers the next attempt.
|
|
33
|
+
*/
|
|
34
|
+
function busyBackoffMs(attempt) {
|
|
35
|
+
const base = 50 * 2 ** attempt;
|
|
36
|
+
return base + Math.random() * base;
|
|
37
|
+
}
|
|
38
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
/** Counterpart to encodeValue in the worker: rebuild BLOBs framed as base64. */
|
|
40
|
+
function decodeValue(v) {
|
|
41
|
+
if (v && typeof v === "object" && typeof v.__blob === "string") {
|
|
42
|
+
return Buffer.from(v.__blob, "base64");
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Runs every library query in a child process. node:sqlite exposes no
|
|
48
|
+
* interrupt() and DatabaseSync blocks the event loop; worker.terminate()
|
|
49
|
+
* waits for the synchronous native call to return, so a killable process is
|
|
50
|
+
* the only timeout that actually works. It also isolates a crash in the
|
|
51
|
+
* experimental node:sqlite binding from the MCP server itself.
|
|
52
|
+
*/
|
|
53
|
+
export class QueryProcess {
|
|
54
|
+
mdbPath;
|
|
55
|
+
sidecar;
|
|
56
|
+
timeoutMs;
|
|
57
|
+
#child = null;
|
|
58
|
+
#ready = null;
|
|
59
|
+
#seq = 0;
|
|
60
|
+
constructor(mdbPath, sidecar, timeoutMs = 10_000) {
|
|
61
|
+
this.mdbPath = mdbPath;
|
|
62
|
+
this.sidecar = sidecar;
|
|
63
|
+
this.timeoutMs = timeoutMs;
|
|
64
|
+
}
|
|
65
|
+
#spawn() {
|
|
66
|
+
const child = fork(WORKER, [this.mdbPath, this.sidecar ?? "-"], {
|
|
67
|
+
stdio: ["ignore", "ignore", "inherit", "ipc"],
|
|
68
|
+
});
|
|
69
|
+
this.#child = child;
|
|
70
|
+
child.once("exit", () => {
|
|
71
|
+
if (this.#child === child) {
|
|
72
|
+
this.#child = null;
|
|
73
|
+
this.#ready = null;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const onMessage = (m) => {
|
|
78
|
+
cleanup();
|
|
79
|
+
if (m && m.ready === false) {
|
|
80
|
+
// The worker forwards the structured error when it has one, so a
|
|
81
|
+
// hot journal arrives here as library_needs_recovery rather than
|
|
82
|
+
// as a string this side has to re-diagnose.
|
|
83
|
+
reject(m.engineError
|
|
84
|
+
? new EngineErrorException(m.engineError)
|
|
85
|
+
: new Error(m.message ?? "query worker failed to start"));
|
|
86
|
+
}
|
|
87
|
+
else
|
|
88
|
+
resolve(child);
|
|
89
|
+
};
|
|
90
|
+
const onExit = (code, signal) => {
|
|
91
|
+
cleanup();
|
|
92
|
+
reject(new Error(`query worker exited before it was ready (code=${code}, signal=${signal})`));
|
|
93
|
+
};
|
|
94
|
+
const onError = (e) => {
|
|
95
|
+
cleanup();
|
|
96
|
+
reject(e);
|
|
97
|
+
};
|
|
98
|
+
const cleanup = () => {
|
|
99
|
+
child.off("message", onMessage);
|
|
100
|
+
child.off("exit", onExit);
|
|
101
|
+
child.off("error", onError);
|
|
102
|
+
};
|
|
103
|
+
child.once("message", onMessage);
|
|
104
|
+
child.once("exit", onExit);
|
|
105
|
+
child.once("error", onError);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Memoizes the in-flight spawn: the promise is stored before anything is
|
|
110
|
+
* awaited, so concurrent run() calls that both observe no live child await
|
|
111
|
+
* the same spawn instead of each forking (and leaking) their own child.
|
|
112
|
+
*/
|
|
113
|
+
async #ensure() {
|
|
114
|
+
if (!this.#ready)
|
|
115
|
+
this.#ready = this.#spawn();
|
|
116
|
+
try {
|
|
117
|
+
return await this.#ready;
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
this.#ready = null;
|
|
121
|
+
this.#child = null;
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
#kill() {
|
|
126
|
+
this.#child?.kill("SIGKILL");
|
|
127
|
+
this.#child = null;
|
|
128
|
+
this.#ready = null;
|
|
129
|
+
}
|
|
130
|
+
async #send(payload) {
|
|
131
|
+
let child;
|
|
132
|
+
try {
|
|
133
|
+
child = await this.#ensure();
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
// The worker forwards its structured error across IPC, so a hot
|
|
137
|
+
// journal is already library_needs_recovery by the time it lands
|
|
138
|
+
// here. This used to re-stat the journal file to work out what the
|
|
139
|
+
// worker had already determined -- a second, independent diagnosis
|
|
140
|
+
// that could disagree with the first.
|
|
141
|
+
if (e instanceof EngineErrorException)
|
|
142
|
+
return e.engineError;
|
|
143
|
+
return err("query_process_crashed", "Could not start the query process", { detail: String(e) });
|
|
144
|
+
}
|
|
145
|
+
const id = ++this.#seq;
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const onMessage = (m) => {
|
|
148
|
+
if (m?.id === id) {
|
|
149
|
+
cleanup();
|
|
150
|
+
resolve(m);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const onExit = () => {
|
|
154
|
+
cleanup();
|
|
155
|
+
resolve(err("query_process_crashed", "The query process exited; it has been restarted"));
|
|
156
|
+
};
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
cleanup();
|
|
159
|
+
this.#kill();
|
|
160
|
+
resolve(err("query_timeout", `Query exceeded ${this.timeoutMs} ms and was terminated`));
|
|
161
|
+
}, this.timeoutMs);
|
|
162
|
+
const cleanup = () => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
child.off("message", onMessage);
|
|
165
|
+
child.off("exit", onExit);
|
|
166
|
+
};
|
|
167
|
+
child.on("message", onMessage);
|
|
168
|
+
child.once("exit", onExit);
|
|
169
|
+
child.send({ id, ...payload });
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The plan for a query that had to be killed. EXPLAIN QUERY PLAN does not
|
|
174
|
+
* execute anything (measured at ~0.02 ms), so it cannot itself hang, and
|
|
175
|
+
* it is the one piece of information that tells a model *why* its query
|
|
176
|
+
* was too slow -- "SCAN Track" against "SEARCH Track USING INDEX" is
|
|
177
|
+
* actionable in a way that "exceeded 10000 ms" is not.
|
|
178
|
+
*
|
|
179
|
+
* Best effort: the previous child was SIGKILLed, so this respawns, and
|
|
180
|
+
* anything unexplainable (a PRAGMA, a syntax error) simply yields no plan
|
|
181
|
+
* rather than replacing the timeout with a second failure.
|
|
182
|
+
*/
|
|
183
|
+
async #queryPlan(sql, params) {
|
|
184
|
+
try {
|
|
185
|
+
const m = await this.#send({ kind: "query", sql: `EXPLAIN QUERY PLAN ${sql}`, params });
|
|
186
|
+
if (!m || "error" in m || !m.ok || !Array.isArray(m.rows) || !m.rows.length)
|
|
187
|
+
return undefined;
|
|
188
|
+
// EXPLAIN QUERY PLAN's last column is the human-readable `detail`.
|
|
189
|
+
return m.rows
|
|
190
|
+
.map((row) => String(row[row.length - 1]))
|
|
191
|
+
.join("; ");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async run(sql, params = []) {
|
|
198
|
+
for (let attempt = 0;; attempt++) {
|
|
199
|
+
const m = await this.#send({ kind: "query", sql, params });
|
|
200
|
+
if ("error" in m) {
|
|
201
|
+
if (m.error === "query_timeout") {
|
|
202
|
+
const plan = await this.#queryPlan(sql, params);
|
|
203
|
+
return plan ? { ...m, detail: plan } : m;
|
|
204
|
+
}
|
|
205
|
+
return m;
|
|
206
|
+
}
|
|
207
|
+
if (!m.ok) {
|
|
208
|
+
if (!/database is locked|busy/i.test(m.message)) {
|
|
209
|
+
return err("invalid_argument", "The SQL query failed", { detail: m.message });
|
|
210
|
+
}
|
|
211
|
+
if (attempt < BUSY_RETRIES) {
|
|
212
|
+
await sleep(busyBackoffMs(attempt));
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
return err("library_busy", "Engine DJ is writing to the library right now", {
|
|
216
|
+
retry_after_ms: 5000,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return { columns: m.columns, rows: m.rows.map((row) => row.map(decodeValue)) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* True when an index is actually ATTACHed as `side` on the live
|
|
224
|
+
* connection. Every tool's SQL joins `side.track_derived`, so a caller
|
|
225
|
+
* that ignores this and queries anyway gets a raw SQLite
|
|
226
|
+
* "no such table: side.track_derived" rather than a structured error --
|
|
227
|
+
* which is precisely what happened on a first run against a busy library.
|
|
228
|
+
*/
|
|
229
|
+
get hasSidecar() {
|
|
230
|
+
return this.sidecar !== null;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Returns whether the attach actually took. A sidecar that cannot be
|
|
234
|
+
* attached (missing, corrupt, unreadable) must not leave `hasSidecar`
|
|
235
|
+
* claiming an index is available, and must not be handed to the next
|
|
236
|
+
* #spawn as an argv the worker would then fail to open.
|
|
237
|
+
*/
|
|
238
|
+
async setSidecar(path) {
|
|
239
|
+
this.sidecar = path;
|
|
240
|
+
const m = await this.#send({ kind: "sidecar", path });
|
|
241
|
+
const ok = !("error" in m) && m?.ok === true;
|
|
242
|
+
if (!ok)
|
|
243
|
+
this.sidecar = null;
|
|
244
|
+
return ok;
|
|
245
|
+
}
|
|
246
|
+
dispose() {
|
|
247
|
+
this.#kill();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/proc/query-worker.ts
|
|
2
|
+
//
|
|
3
|
+
// Forked by QueryProcess (query-client.ts), one process per library. Runs as
|
|
4
|
+
// plain compiled JS — see query-client.ts for why the TypeScript source is
|
|
5
|
+
// never forked directly.
|
|
6
|
+
import { openQueryConnection, reattachSidecar } from "../store/connections.js";
|
|
7
|
+
import { EngineErrorException } from "../errors.js";
|
|
8
|
+
/**
|
|
9
|
+
* IPC serialises with JSON, and node:sqlite returns BLOBs as Uint8Array,
|
|
10
|
+
* which JSON degrades to {"0":1,"1":2,...}. Every blob must be framed
|
|
11
|
+
* explicitly or PerformanceData decoding on the other side receives garbage.
|
|
12
|
+
*/
|
|
13
|
+
function encodeValue(v) {
|
|
14
|
+
return v instanceof Uint8Array ? { __blob: Buffer.from(v).toString("base64") } : v;
|
|
15
|
+
}
|
|
16
|
+
const [, , mdbPath, sidecarArg] = process.argv;
|
|
17
|
+
let sidecar = sidecarArg && sidecarArg !== "-" ? sidecarArg : null;
|
|
18
|
+
let db;
|
|
19
|
+
let opened = false;
|
|
20
|
+
try {
|
|
21
|
+
db = openQueryConnection(mdbPath, sidecar);
|
|
22
|
+
opened = true;
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
// openQueryConnection throws (e.g. a hot journal); report it back instead
|
|
26
|
+
// of crashing with a bare stack trace. When it threw a structured
|
|
27
|
+
// EngineError, forward the whole thing rather than just its message: the
|
|
28
|
+
// parent used to re-derive the condition by re-stat'ing the journal file,
|
|
29
|
+
// which could disagree with what this process actually saw. process.send()
|
|
30
|
+
// is asynchronous, so exit only from its callback -- calling process.exit()
|
|
31
|
+
// right after send() can drop the message before it reaches the pipe.
|
|
32
|
+
const message = e.message;
|
|
33
|
+
const engineError = e instanceof EngineErrorException ? e.engineError : undefined;
|
|
34
|
+
if (process.send) {
|
|
35
|
+
process.send({ ready: false, message, engineError }, () => process.exit(1));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Only wire up the request loop once the connection actually opened; the
|
|
42
|
+
// failure branch above owns reporting and exiting on its own.
|
|
43
|
+
if (opened) {
|
|
44
|
+
process.on("message", (req) => {
|
|
45
|
+
try {
|
|
46
|
+
if (req.kind === "sidecar") {
|
|
47
|
+
sidecar = req.path;
|
|
48
|
+
reattachSidecar(db, sidecar);
|
|
49
|
+
process.send({ id: req.id, ok: true });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const stmt = db.prepare(req.sql);
|
|
53
|
+
stmt.setReadBigInts(false);
|
|
54
|
+
// columns() reports the result column names from the prepared statement
|
|
55
|
+
// itself (respecting AS aliases), so a query that matches zero rows
|
|
56
|
+
// still reports real names -- Object.keys(rows[0]) has nothing to key
|
|
57
|
+
// off when there are no rows, and silently degrades to [].
|
|
58
|
+
const columns = stmt.columns().map((c) => c.name);
|
|
59
|
+
const rows = stmt.all(...(req.params ?? []));
|
|
60
|
+
process.send({
|
|
61
|
+
id: req.id,
|
|
62
|
+
ok: true,
|
|
63
|
+
columns,
|
|
64
|
+
rows: rows.map((r) => columns.map((c) => encodeValue(r[c]))),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
process.send({ id: req.id, ok: false, message: e.message });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
process.send({ ready: true });
|
|
72
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
2
|
+
/**
|
|
3
|
+
* Engine's own conversion, taken from the application binary:
|
|
4
|
+
* CASE key WHEN -1 THEN NULL ELSE (key + 15 - 2 * (key % 2)) % 24 END
|
|
5
|
+
* The result is a wheel index: even `key` gives mode B, odd gives A, and the
|
|
6
|
+
* wheel number is floor(index / 2) + 1. key=0 is C major, so 8B — the
|
|
7
|
+
* standard Camelot anchor. That anchor is confirmed against Engine DJ's own
|
|
8
|
+
* display, not assumed: the track stored as key=20 shows as 6B in Engine,
|
|
9
|
+
* which is exactly what this formula produces ((20 + 15) % 24 = 11, wheel
|
|
10
|
+
* number 6, mode B). A wrongly-anchored wheel would put that track on a
|
|
11
|
+
* different number.
|
|
12
|
+
*/
|
|
13
|
+
export declare function camelotIndex(key: number | null): number | null;
|
|
14
|
+
export declare function camelot(key: number | null): string | null;
|
|
15
|
+
export declare function keyName(key: number | null): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* `bpm` is stored at face value (102 means 102 BPM), not times one hundred as
|
|
18
|
+
* in rekordbox. That times-100 rule was carried into this project from
|
|
19
|
+
* rekordbox documentation by mistake; measured against a real Engine DJ 5.0
|
|
20
|
+
* library (schema 3.0.2, history database) every non-null `bpm` among 24
|
|
21
|
+
* analysed tracks agrees with `bpmAnalyzed` to within 0.68 — e.g. id=6
|
|
22
|
+
* bpm=128 bpmAnalyzed=128, id=7 bpm=129 bpmAnalyzed=129 — not 12800/12900.
|
|
23
|
+
*/
|
|
24
|
+
export declare function tempo(bpmAnalyzed: number | null, bpm: number | null): number | null;
|
|
25
|
+
export declare function parseCamelot(label: string): {
|
|
26
|
+
number: number;
|
|
27
|
+
mode: "A" | "B";
|
|
28
|
+
} | null;
|
|
29
|
+
/** Same number in the other mode, plus one step either way in the same mode. */
|
|
30
|
+
export declare function camelotNeighbours(label: string): string[];
|
|
31
|
+
export declare function keyDistance(a: string, b: string): number | null;
|
|
32
|
+
/**
|
|
33
|
+
* SQL-callable versions of all five functions the spec lists. These are an
|
|
34
|
+
* escape hatch for run_sql; filtering by key or tempo in a WHERE clause
|
|
35
|
+
* should use the indexed sidecar columns in side.track_derived, because a JS
|
|
36
|
+
* callback runs per row and defeats indexes.
|
|
37
|
+
*
|
|
38
|
+
* `mdbPath` is what makes `abs_path` possible: Track.path is relative to the
|
|
39
|
+
* `Engine Library` folder (and usually starts with `..`), so resolving it
|
|
40
|
+
* needs to know where m.db lives. It is a parameter rather than a lookup so
|
|
41
|
+
* a connection can never resolve paths against the wrong library.
|
|
42
|
+
*/
|
|
43
|
+
export declare function registerFunctions(db: DatabaseSync, mdbPath: string): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { absTrackPath, redactPath } from "./paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* Engine's own conversion, taken from the application binary:
|
|
4
|
+
* CASE key WHEN -1 THEN NULL ELSE (key + 15 - 2 * (key % 2)) % 24 END
|
|
5
|
+
* The result is a wheel index: even `key` gives mode B, odd gives A, and the
|
|
6
|
+
* wheel number is floor(index / 2) + 1. key=0 is C major, so 8B — the
|
|
7
|
+
* standard Camelot anchor. That anchor is confirmed against Engine DJ's own
|
|
8
|
+
* display, not assumed: the track stored as key=20 shows as 6B in Engine,
|
|
9
|
+
* which is exactly what this formula produces ((20 + 15) % 24 = 11, wheel
|
|
10
|
+
* number 6, mode B). A wrongly-anchored wheel would put that track on a
|
|
11
|
+
* different number.
|
|
12
|
+
*/
|
|
13
|
+
export function camelotIndex(key) {
|
|
14
|
+
if (key === null || key === undefined || !Number.isFinite(key) || key < 0 || key > 23)
|
|
15
|
+
return null;
|
|
16
|
+
return (key + 15 - 2 * (key % 2)) % 24;
|
|
17
|
+
}
|
|
18
|
+
export function camelot(key) {
|
|
19
|
+
const v = camelotIndex(key);
|
|
20
|
+
if (v === null)
|
|
21
|
+
return null;
|
|
22
|
+
return `${Math.floor(v / 2) + 1}${v % 2 === 1 ? "B" : "A"}`;
|
|
23
|
+
}
|
|
24
|
+
const NAMES_B = ["B", "F#", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E"];
|
|
25
|
+
const NAMES_A = ["Abm", "Ebm", "Bbm", "Fm", "Cm", "Gm", "Dm", "Am", "Em", "Bm", "F#m", "Dbm"];
|
|
26
|
+
export function keyName(key) {
|
|
27
|
+
const label = camelot(key);
|
|
28
|
+
if (!label)
|
|
29
|
+
return null;
|
|
30
|
+
const { number, mode } = parseCamelot(label);
|
|
31
|
+
return mode === "B" ? NAMES_B[number - 1] : NAMES_A[number - 1];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `bpm` is stored at face value (102 means 102 BPM), not times one hundred as
|
|
35
|
+
* in rekordbox. That times-100 rule was carried into this project from
|
|
36
|
+
* rekordbox documentation by mistake; measured against a real Engine DJ 5.0
|
|
37
|
+
* library (schema 3.0.2, history database) every non-null `bpm` among 24
|
|
38
|
+
* analysed tracks agrees with `bpmAnalyzed` to within 0.68 — e.g. id=6
|
|
39
|
+
* bpm=128 bpmAnalyzed=128, id=7 bpm=129 bpmAnalyzed=129 — not 12800/12900.
|
|
40
|
+
*/
|
|
41
|
+
export function tempo(bpmAnalyzed, bpm) {
|
|
42
|
+
if (bpmAnalyzed !== null && bpmAnalyzed !== undefined && bpmAnalyzed > 0)
|
|
43
|
+
return bpmAnalyzed;
|
|
44
|
+
if (bpm !== null && bpm !== undefined && bpm > 0)
|
|
45
|
+
return bpm;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function parseCamelot(label) {
|
|
49
|
+
const m = /^([1-9]|1[0-2])([AB])$/.exec(label.trim().toUpperCase());
|
|
50
|
+
if (!m)
|
|
51
|
+
return null;
|
|
52
|
+
return { number: Number(m[1]), mode: m[2] };
|
|
53
|
+
}
|
|
54
|
+
/** Same number in the other mode, plus one step either way in the same mode. */
|
|
55
|
+
export function camelotNeighbours(label) {
|
|
56
|
+
const p = parseCamelot(label);
|
|
57
|
+
if (!p)
|
|
58
|
+
return [];
|
|
59
|
+
const wrap = (n) => ((n - 1 + 12) % 12) + 1;
|
|
60
|
+
return [
|
|
61
|
+
`${p.number}${p.mode}`,
|
|
62
|
+
`${p.number}${p.mode === "A" ? "B" : "A"}`,
|
|
63
|
+
`${wrap(p.number - 1)}${p.mode}`,
|
|
64
|
+
`${wrap(p.number + 1)}${p.mode}`,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
export function keyDistance(a, b) {
|
|
68
|
+
const pa = parseCamelot(a), pb = parseCamelot(b);
|
|
69
|
+
if (!pa || !pb)
|
|
70
|
+
return null;
|
|
71
|
+
const raw = Math.abs(pa.number - pb.number);
|
|
72
|
+
return Math.min(raw, 12 - raw);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* SQL-callable versions of all five functions the spec lists. These are an
|
|
76
|
+
* escape hatch for run_sql; filtering by key or tempo in a WHERE clause
|
|
77
|
+
* should use the indexed sidecar columns in side.track_derived, because a JS
|
|
78
|
+
* callback runs per row and defeats indexes.
|
|
79
|
+
*
|
|
80
|
+
* `mdbPath` is what makes `abs_path` possible: Track.path is relative to the
|
|
81
|
+
* `Engine Library` folder (and usually starts with `..`), so resolving it
|
|
82
|
+
* needs to know where m.db lives. It is a parameter rather than a lookup so
|
|
83
|
+
* a connection can never resolve paths against the wrong library.
|
|
84
|
+
*/
|
|
85
|
+
export function registerFunctions(db, mdbPath) {
|
|
86
|
+
const opts = { deterministic: true };
|
|
87
|
+
db.function("camelot", opts, (key) => camelot(key === null ? null : Number(key)));
|
|
88
|
+
db.function("key_name", opts, (key) => keyName(key === null ? null : Number(key)));
|
|
89
|
+
db.function("tempo", opts, (a, b) => tempo(a === null ? null : Number(a), b === null ? null : Number(b)));
|
|
90
|
+
db.function("key_distance", opts, (a, b) => a === null || b === null ? null : keyDistance(String(a), String(b)));
|
|
91
|
+
// Redacted, like every other absolute path this project hands back: the
|
|
92
|
+
// result of this function goes to a model provider through run_sql, and
|
|
93
|
+
// the home prefix carries the user's account name.
|
|
94
|
+
db.function("abs_path", opts, (p) => p === null || p === undefined ? null : redactPath(absTrackPath(mdbPath, String(p))));
|
|
95
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* discoverLibraries() reports only libraries it could actually read, by
|
|
4
|
+
* design (a permissions error on one candidate must not blank out every
|
|
5
|
+
* other one). That means a hot journal on the *only* library on this
|
|
6
|
+
* machine looks identical to no library existing at all -- both come back
|
|
7
|
+
* as an empty list, verified: opening it raises "attempt to write a
|
|
8
|
+
* readonly database", which readLibraryInfo currently folds into
|
|
9
|
+
* unsupported_schema and then drops entirely.
|
|
10
|
+
*
|
|
11
|
+
* This walks the same candidate paths independently, purely to tell those
|
|
12
|
+
* two cases apart, so `ready()` below can report library_needs_recovery
|
|
13
|
+
* instead of the misleading library_not_found -- never to open the file:
|
|
14
|
+
* recovering a hot journal requires a write, and this project never writes
|
|
15
|
+
* to the user's library, even to heal it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findHotJournalCandidate(roots: string[]): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* An McpServer that also owns one forked query process per library it has
|
|
20
|
+
* been asked to touch, and can therefore be shut down rather than merely
|
|
21
|
+
* disconnected. Nothing else in this server holds an OS resource, so
|
|
22
|
+
* `dispose()` is the whole of it.
|
|
23
|
+
*/
|
|
24
|
+
export type EngineDjMcpServer = McpServer & {
|
|
25
|
+
/** Kills every query child this server started. Idempotent, and also run by close(). */
|
|
26
|
+
dispose(): void;
|
|
27
|
+
};
|
|
28
|
+
export declare function createServer(opts?: {
|
|
29
|
+
roots?: string[];
|
|
30
|
+
sidecarBaseDir?: string;
|
|
31
|
+
}): Promise<EngineDjMcpServer>;
|