@cnkang/qdrant-memory-launcher 0.0.0-stage → 0.1.0-rc.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kang Liu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # Managed Qdrant Memory launcher
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The launcher installs a private Python environment, Runtime 0.3.0 and a
4
+ verified multilingual CPU embedding model. WorkBuddy and DeepSeek Harness use
5
+ separate embedded memory profiles. Existing Hermes installations keep their
6
+ configuration and data.
7
+
8
+ This is a release candidate. Marketplace installation and desktop acceptance
9
+ are tracked separately for each operating system.
10
+
11
+ ## Install and prepare
12
+
13
+ For a client that already supplies Node.js 20 or newer:
14
+
15
+ ```sh
16
+ npm install --global @cnkang/qdrant-memory-launcher@0.1.0-rc.1
17
+ qdrant-memory-launcher prepare dsh
18
+ qdrant-memory-launcher mcp dsh
19
+ ```
20
+
21
+ Preparation needs network access and may take several minutes. It downloads
22
+ private Python dependencies and about 252 MB of model artifacts. No system
23
+ Python, uv/uvx, Ollama or Qdrant Server is required. The local preset needs no
24
+ API keys. DSH must finish preparation before enabling the MCP connection;
25
+ its temporary MCP probe never runs installation.
26
+
27
+ WorkBuddy's managed Connector declares Node.js 20 and requires WorkBuddy
28
+ 5.0.0 or newer. It runs `mcp workbuddy --auto-setup`. Installing that connection
29
+ authorizes downloads and creation of its private profile.
30
+
31
+ ## First connection and recovery
32
+
33
+ The four tools appear while preparation runs. A connection indicator means the
34
+ MCP transport is available; it does not mean the memory backend is ready.
35
+ During preparation, tool calls report that no operation occurred. Wait before
36
+ asking the agent to save a fact. Writes are never queued.
37
+
38
+ After an ordinary network interruption, check the connection and available disk
39
+ space, then disconnect and reconnect WorkBuddy to retry. DSH users should run
40
+ `qdrant-memory-launcher prepare dsh` again before reconnecting. A failed setup
41
+ does not reset memory data. Preparation does not retry downloads indefinitely.
42
+
43
+ If a save, update or delete was interrupted after dispatch, search for the
44
+ affected memory and check its ID before repeating it. Reconnection never
45
+ automatically repeats a write.
46
+
47
+ A busy-profile error means another process owns that profile. Close that
48
+ connection normally. Never remove data locks to force a second writer.
49
+
50
+ ## Storage and upgrades
51
+
52
+ The default root is `qdrant-memory-managed` under:
53
+
54
+ | System | Data directory |
55
+ | --- | --- |
56
+ | macOS | `~/Library/Application Support` |
57
+ | Windows | `%LOCALAPPDATA%` |
58
+ | Linux | `$XDG_DATA_HOME` or `~/.local/share` |
59
+
60
+ Program environments live under `programs/`, models under `models/`, and
61
+ host data under `profiles/workbuddy/local/` or `profiles/dsh/local/`.
62
+ The optional `QDRANT_MEMORY_MANAGED_ROOT` must be an absolute path.
63
+ Program versions and dependency locks select separate installation directories.
64
+ Existing profiles retain their model-cache settings during an upgrade.
65
+
66
+ Disconnect the client before upgrading or using maintenance commands.
67
+ Uninstalling the npm package removes the launcher; it does not remove profiles
68
+ or models. Back up the profile before a rollback and retain the same embedding
69
+ pipeline. Do not copy a running embedded store or automatically migrate it.
70
+
71
+ After an unexpected OS shutdown or force-killed installation, a provisioning
72
+ lock may require operator assistance. Confirm that the installer and its child
73
+ processes have stopped before inspecting only that program's
74
+ `.provision.lock`. Do not remove profile files or Runtime data locks.
75
+
76
+ ## Package integrity
77
+
78
+ The launcher contains no third-party JavaScript dependencies or downloaded
79
+ models. It verifies bootstrap and public Runtime wheel bytes before execution.
80
+ All Python dependencies are pinned with hashes and installed as wheels, with
81
+ no source builds. Runtime verifies the model revision and artifacts.
82
+
83
+ `runtime.json`, `requirements.lock` and `bootstrap.mjs` record the exact
84
+ versions and integrity checks. See [third-party notices](THIRD_PARTY_NOTICES.md).
85
+ Installer subprocesses receive a restricted environment without service keys.
86
+ MCP stdout carries only protocol messages.
@@ -0,0 +1,17 @@
1
+ # Third-party notices
2
+
3
+ The launcher is MIT-licensed. It contains no third-party JavaScript dependencies.
4
+ It downloads the following components into a private installation directory:
5
+
6
+ | Component | License |
7
+ | --- | --- |
8
+ | uv | Apache-2.0 OR MIT |
9
+ | Python (python-build-standalone) | PSF-2.0 |
10
+ | Qdrant Memory Runtime | MIT |
11
+ | CPU embedding model, Qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q | Apache-2.0 |
12
+
13
+ Versions, download locations and artifact hashes are recorded in
14
+ `bootstrap.mjs`, `runtime.json` and `requirements.lock`. Runtime verifies the
15
+ model revision and artifact hashes. Downloaded dependencies retain their
16
+ upstream licenses in the private Python environment. This package does not
17
+ contain Python, uv, model weights, user profiles or memory data.
package/bootstrap.mjs ADDED
@@ -0,0 +1,196 @@
1
+ /** Prerequisite PoC: verified managed Python using only Node and OS facilities.
2
+ * This is not the production WorkBuddy launcher and does not initialize memory.
3
+ */
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { mkdtemp, mkdir, realpath, writeFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join, relative, isAbsolute } from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { inflateRawSync } from 'node:zlib';
11
+
12
+ export const UV_VERSION = '0.13.1';
13
+ export const PYTHON_VERSION = '3.11.15';
14
+ // Official PyPI uv wheels. The entire artifact is checked before ZIP parsing.
15
+ export const BOOTSTRAPS = {
16
+ 'darwin-arm64': {
17
+ url: 'https://files.pythonhosted.org/packages/3b/31/fad7fd2ac81954d02d33765bf8535eb320fcc2d71f2603a5c1ba0b13b8ef/uv-0.13.1-py3-none-macosx_11_0_arm64.whl',
18
+ sha256: 'cfa719fbbd23f26713394dea2692b7f146aa1b5713ce5e776e878701214ea52c', size: 14391237,
19
+ },
20
+ 'darwin-x64': {
21
+ url: 'https://files.pythonhosted.org/packages/db/3d/6ac43d7a15be1d589aae1e15289507f5ccebeac14f28c37882058f2474a3/uv-0.13.1-py3-none-macosx_10_12_x86_64.whl',
22
+ sha256: '7e60223e3e9636a3734d6c11b3552aeed987c40f1ca2497a546af58f2f883d56', size: 17663254,
23
+ },
24
+ 'linux-x64': {
25
+ url: 'https://files.pythonhosted.org/packages/14/0b/6602a613533602e7a083e908c4375f9b0e78a94ba2e21c29b21c41a0a611/uv-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl',
26
+ sha256: '696dcf6d807d85da978f4db8472da81dccc1254b18b52cbd1367c4d923c421e0', size: 17279385,
27
+ },
28
+ 'win32-x64': {
29
+ url: 'https://files.pythonhosted.org/packages/2a/8e/d23278bb327d2a75dc1d033c81b7fe23bbb28e363e02e7f306abd047924a/uv-0.13.1-py3-none-win_amd64.whl',
30
+ sha256: '4f693cd4feb52f08f30474c4612f24be2050472609be65b9960b635f286da284', size: 15404994,
31
+ },
32
+ };
33
+
34
+ export function verifyArtifact(bytes, spec) {
35
+ if (bytes.length !== spec.size || createHash('sha256').update(bytes).digest('hex') !== spec.sha256) {
36
+ throw new Error('Bootstrap artifact integrity mismatch');
37
+ }
38
+ return bytes;
39
+ }
40
+
41
+ function bounds(bytes, offset, size) {
42
+ if (!Number.isSafeInteger(offset) || offset < 0 || size < 0 || offset + size > bytes.length) {
43
+ throw new Error('Invalid ZIP bounds');
44
+ }
45
+ }
46
+
47
+ export function extractExecutable(bytes, expectedName) {
48
+ // Read one regular file into memory. No archive paths are written to disk.
49
+ let end = -1;
50
+ for (let cursor = bytes.length - 22; cursor >= Math.max(0, bytes.length - 65557); cursor--) {
51
+ if (bytes.readUInt32LE(cursor) === 0x06054b50) { end = cursor; break; }
52
+ }
53
+ if (end < 0) throw new Error('ZIP end record missing');
54
+ const count = bytes.readUInt16LE(end + 10);
55
+ const centralSize = bytes.readUInt32LE(end + 12);
56
+ let cursor = bytes.readUInt32LE(end + 16);
57
+ bounds(bytes, cursor, centralSize);
58
+ if (!count || count > 1000 || bytes.readUInt16LE(end + 4) || bytes.readUInt16LE(end + 6)
59
+ || bytes.readUInt16LE(end + 8) !== count || cursor + centralSize !== end) {
60
+ throw new Error('Unsupported ZIP layout');
61
+ }
62
+ let executable;
63
+ for (let index = 0; index < count; index++) {
64
+ bounds(bytes, cursor, 46);
65
+ if (bytes.readUInt32LE(cursor) !== 0x02014b50) throw new Error('Invalid ZIP directory');
66
+ const flags = bytes.readUInt16LE(cursor + 8);
67
+ const method = bytes.readUInt16LE(cursor + 10);
68
+ const compressed = bytes.readUInt32LE(cursor + 20);
69
+ const expanded = bytes.readUInt32LE(cursor + 24);
70
+ const nameSize = bytes.readUInt16LE(cursor + 28);
71
+ const extraSize = bytes.readUInt16LE(cursor + 30);
72
+ const commentSize = bytes.readUInt16LE(cursor + 32);
73
+ const mode = bytes.readUInt32LE(cursor + 38) >>> 16;
74
+ const localOffset = bytes.readUInt32LE(cursor + 42);
75
+ bounds(bytes, cursor + 46, nameSize + extraSize + commentSize);
76
+ const name = bytes.subarray(cursor + 46, cursor + 46 + nameSize).toString('utf8');
77
+ if (name.startsWith('/') || /^[A-Za-z]:/.test(name) || name.includes('\\') || name.includes('\0')
78
+ || name.split('/').includes('..') || (mode & 0xf000) === 0xa000 || flags & 1) {
79
+ throw new Error('Unsafe ZIP member');
80
+ }
81
+ if (name === expectedName) {
82
+ if (executable || ![0, 0x8000].includes(mode & 0xf000) || ![0, 8].includes(method)
83
+ || expanded > 64 * 1024 * 1024) throw new Error('Invalid executable entry');
84
+ bounds(bytes, localOffset, 30);
85
+ if (bytes.readUInt32LE(localOffset) !== 0x04034b50
86
+ || bytes.readUInt16LE(localOffset + 6) !== flags
87
+ || bytes.readUInt16LE(localOffset + 8) !== method) throw new Error('Invalid ZIP local header');
88
+ const localNameSize = bytes.readUInt16LE(localOffset + 26);
89
+ const localExtraSize = bytes.readUInt16LE(localOffset + 28);
90
+ bounds(bytes, localOffset + 30, localNameSize + localExtraSize + compressed);
91
+ if (bytes.subarray(localOffset + 30, localOffset + 30 + localNameSize).toString('utf8') !== name) {
92
+ throw new Error('ZIP local path mismatch');
93
+ }
94
+ const start = localOffset + 30 + localNameSize + localExtraSize;
95
+ const content = bytes.subarray(start, start + compressed);
96
+ executable = method === 0 ? content : inflateRawSync(content, { maxOutputLength: 64 * 1024 * 1024 });
97
+ if (executable.length !== expanded) throw new Error('Invalid executable size');
98
+ }
99
+ cursor += 46 + nameSize + extraSize + commentSize;
100
+ }
101
+ if (!executable) throw new Error('Bootstrap executable missing');
102
+ return executable;
103
+ }
104
+
105
+ export function anySignal(signals) {
106
+ // Node 20.0-20.2 lack AbortSignal.any; this keeps the declared >=20 floor valid.
107
+ if (signals.length === 1) return signals[0];
108
+ const controller = new AbortController();
109
+ for (const source of signals) {
110
+ if (source.aborted) { controller.abort(source.reason); return controller.signal; }
111
+ source.addEventListener('abort', () => controller.abort(source.reason), { once: true });
112
+ }
113
+ return controller.signal;
114
+ }
115
+
116
+ export async function download(spec, signal) {
117
+ const timeout = AbortSignal.timeout(120000);
118
+ const response = await fetch(spec.url, { redirect: 'error', signal: signal ? anySignal([signal, timeout]) : timeout });
119
+ if (!response.ok) throw new Error('Bootstrap download failed');
120
+ const blocks = [];
121
+ let length = 0;
122
+ for await (const chunk of response.body) {
123
+ length += chunk.length;
124
+ if (length > spec.size) throw new Error('Bootstrap download exceeded its pinned size');
125
+ blocks.push(chunk);
126
+ }
127
+ return verifyArtifact(Buffer.concat(blocks), spec);
128
+ }
129
+
130
+ function failureCategory(result) {
131
+ // Inspect subprocess details only in memory; expose allowlisted labels, never stderr.
132
+ const diagnostic = `${result.error?.code ?? ''}\n${result.stderr ?? ''}`;
133
+ return [
134
+ ['timeout', /ETIMEDOUT|timed? out/i],
135
+ ['certificate', /certificate|UnknownIssuer|\bTLS\b|\bSSL\b/i],
136
+ ['permission', /EACCES|EPERM|permission denied|access is denied/i],
137
+ ['platform', /ENOEXEC|ENOENT|exec format|unsupported platform|no download found/i],
138
+ ['network', /ENOTFOUND|ECONN|EAI_AGAIN|network|connection|DNS|lookup address/i],
139
+ ].find(([, pattern]) => pattern.test(diagnostic))?.[0] ?? 'subprocess';
140
+ }
141
+
142
+ export function command(executable, args, env, timeout) {
143
+ const result = spawnSync(executable, args, { env, timeout, encoding: 'utf8', windowsHide: true });
144
+ if (result.status !== 0 || result.error) {
145
+ const error = new Error('Managed Python bootstrap command failed');
146
+ error.stage = args[0] === 'python' ? `python-${args[1]}` : 'python-execute';
147
+ error.exitCode = result.status;
148
+ error.category = failureCategory(result);
149
+ throw error;
150
+ }
151
+ return result.stdout.trim();
152
+ }
153
+
154
+ export async function probeManagedPython() {
155
+ const spec = BOOTSTRAPS[`${process.platform}-${process.arch}`];
156
+ if (!spec) throw new Error('This platform has no verified bootstrap artifact');
157
+ const started = performance.now();
158
+ const root = await realpath(await mkdtemp(join(tmpdir(), 'qdrant-managed-python-')));
159
+ const emptyPath = join(root, 'empty-path');
160
+ await mkdir(emptyPath);
161
+ const uv = join(root, process.platform === 'win32' ? 'uv.exe' : 'uv');
162
+ const name = `uv-${UV_VERSION}.data/scripts/${process.platform === 'win32' ? 'uv.exe' : 'uv'}`;
163
+ const executable = extractExecutable(await download(spec), name);
164
+ await writeFile(uv, executable, { flag: 'wx', mode: 0o700 });
165
+ const env = {
166
+ PATH: emptyPath, UV_CACHE_DIR: join(root, 'cache'), UV_PYTHON_INSTALL_DIR: join(root, 'python'),
167
+ UV_PYTHON_PREFERENCE: 'only-managed', UV_NO_CONFIG: '1',
168
+ };
169
+ for (const key of ['SYSTEMROOT', 'WINDIR', 'TEMP', 'TMP']) {
170
+ if (process.env[key]) env[key] = process.env[key];
171
+ }
172
+ command(uv, ['python', 'install', PYTHON_VERSION, '--no-bin'], env, 180000);
173
+ const python = command(uv, ['python', 'find', '--system', PYTHON_VERSION], env, 10000);
174
+ const inside = relative(root, python);
175
+ if (!inside || inside.startsWith('..') || isAbsolute(inside)) throw new Error('Python escaped the fresh managed root');
176
+ const version = command(python, ['-I', '-c', 'import sys; print(sys.version.split()[0])'], env, 10000);
177
+ if (version !== PYTHON_VERSION) throw new Error('Unexpected Python version');
178
+ return { platform: process.platform, architecture: process.arch, bootstrap_sha256_verified: true,
179
+ system_tools_on_path: false, fresh_managed_python: true, python_version: version,
180
+ elapsed_seconds: Number(((performance.now() - started) / 1000).toFixed(3)) };
181
+ }
182
+
183
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
184
+ if (process.argv.length !== 3 || process.argv[2] !== '--probe') {
185
+ console.error('Usage: node integrations/managed/bootstrap.mjs --probe');
186
+ process.exitCode = 2;
187
+ } else {
188
+ try { console.log(JSON.stringify(await probeManagedPython())); }
189
+ catch (error) {
190
+ console.error(JSON.stringify({ error: 'Managed Python probe failed; no memory profile was modified.',
191
+ stage: error.stage || 'download-or-verification', exit_code: error.exitCode ?? null,
192
+ category: error.category || 'bootstrap' }));
193
+ process.exitCode = 1;
194
+ }
195
+ }
196
+ }
package/bridge.mjs ADDED
@@ -0,0 +1,134 @@
1
+ /** Small stdio readiness boundary; Runtime remains the only tool implementation. */
2
+ import { spawn } from 'node:child_process';
3
+ import { createInterface } from 'node:readline';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { isDeepStrictEqual } from 'node:util';
6
+
7
+ export const tools = JSON.parse(await readFile(new URL('./tools.json', import.meta.url), 'utf8'));
8
+ const { version } = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8'));
9
+ const launcherVersion = version;
10
+ const failure = text => ({ content: [{ type: 'text', text }], isError: true });
11
+
12
+ export async function bridge(startBackend, input = process.stdin, output = process.stdout) {
13
+ const abort = new AbortController();
14
+ const pending = new Map();
15
+ let backend, ready = false, closed = false, sequence = 0, initialized = false;
16
+ let state = 'Preparing memory. No operation was performed; retry when preparation finishes.';
17
+ const send = message => { if (!closed) output.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n'); };
18
+ const wire = message => backend.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...message }) + '\n');
19
+ let finish;
20
+ const done = new Promise(resolve => { finish = resolve; });
21
+ const shutdown = () => {
22
+ if (closed) return;
23
+ closed = true;
24
+ abort.abort();
25
+ if (backend && backend.exitCode === null && backend.signalCode === null) {
26
+ backend.stdin.end();
27
+ // Allow Runtime to close storage/lease normally before SDK-style escalation.
28
+ const term = setTimeout(() => backend.kill('SIGTERM'), 3000);
29
+ const kill = setTimeout(() => backend.kill('SIGKILL'), 6000);
30
+ backend.once('close', () => { clearTimeout(term); clearTimeout(kill); });
31
+ }
32
+ finish();
33
+ };
34
+ const lines = createInterface({ input, crlfDelay: Infinity });
35
+ const onSignal = () => { lines.close(); shutdown(); };
36
+ process.once('SIGTERM', onSignal);
37
+ process.once('SIGINT', onSignal);
38
+ lines.on('close', shutdown);
39
+ lines.on('line', line => {
40
+ if (closed) return;
41
+ let message;
42
+ try { message = JSON.parse(line); } catch { send({ id: null, error: { code: -32700, message: 'Parse error' } }); return; }
43
+ if (!message || typeof message !== 'object' || Array.isArray(message) || message.jsonrpc !== '2.0') {
44
+ send({ id: null, error: { code: -32600, message: 'Invalid request' } }); return;
45
+ }
46
+ const hasId = Object.hasOwn(message, 'id');
47
+ if (!hasId) {
48
+ if (ready && message.method === 'notifications/cancelled') {
49
+ const entry = [...pending].find(([, id]) => id === message.params?.requestId);
50
+ if (entry) wire({ method: message.method, params: { ...message.params, requestId: entry[0] } });
51
+ }
52
+ return;
53
+ }
54
+ const { id, method } = message;
55
+ if ((typeof id !== 'string' && (typeof id !== 'number' || !Number.isSafeInteger(id)))
56
+ || typeof method !== 'string') {
57
+ send({ id: null, error: { code: -32600, message: 'Invalid request' } });
58
+ return;
59
+ }
60
+ if (method === 'initialize') {
61
+ initialized = true;
62
+ const version = message.params?.protocolVersion;
63
+ send({ id, result: { protocolVersion: ['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'].includes(version) ? version : '2025-11-25',
64
+ capabilities: { tools: {} }, serverInfo: { name: 'qdrant-memory-managed', version: launcherVersion } } });
65
+ } else if (method === 'ping') send({ id, result: {} });
66
+ else if (!initialized) send({ id, error: { code: -32000, message: 'Initialize first' } });
67
+ else if (method === 'tools/list') send({ id, result: { tools } });
68
+ else if (method === 'tools/call') {
69
+ if (!ready) send({ id, result: failure(state) });
70
+ else {
71
+ const internal = ++sequence;
72
+ pending.set(internal, id);
73
+ wire({ id: internal, method, params: message.params });
74
+ }
75
+ } else send({ id, error: { code: -32601, message: 'Method not found' } });
76
+ });
77
+ void (async () => {
78
+ try {
79
+ const command = await startBackend(abort.signal);
80
+ if (closed) return;
81
+ backend = spawn(command.command, command.args, { env: command.env, windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
82
+ const failed = () => {
83
+ ready = false;
84
+ state = 'Memory backend stopped. No automatic write retry was performed; reconnect and search before retrying a write.';
85
+ for (const id of pending.values()) send({ id, result: failure(state) });
86
+ pending.clear();
87
+ // Closing the transport lets the host's own reconnect/backoff policy act.
88
+ // Never leave a live bridge that masks a dead Runtime indefinitely.
89
+ lines.close();
90
+ shutdown();
91
+ };
92
+ backend.on('error', failed);
93
+ backend.on('close', failed);
94
+ backend.stdin.on('error', failed);
95
+ const responses = createInterface({ input: backend.stdout, crlfDelay: Infinity });
96
+ responses.on('line', line => {
97
+ let message;
98
+ try { message = JSON.parse(line); } catch { failed(); backend.kill(); return; }
99
+ if (!message || typeof message !== 'object' || Array.isArray(message)
100
+ || message.jsonrpc !== '2.0') { failed(); return; }
101
+ // Runtime notifications have no response ID. Only a valid response may
102
+ // complete an initialization or a dispatched operation.
103
+ if (!Object.hasOwn(message, 'id')) {
104
+ if (typeof message.method !== 'string' || Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error')) failed();
105
+ return;
106
+ }
107
+ if (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error')) {
108
+ failed(); return;
109
+ }
110
+ if (message.id === 'init') {
111
+ if (!message.result) { failed(); return; }
112
+ wire({ method: 'notifications/initialized' });
113
+ wire({ id: 'catalogue', method: 'tools/list', params: {} });
114
+ } else if (message.id === 'catalogue') {
115
+ if (!isDeepStrictEqual(message.result?.tools, tools)) { failed(); backend.kill(); return; }
116
+ ready = true;
117
+ } else if (pending.has(message.id)) {
118
+ const id = pending.get(message.id);
119
+ pending.delete(message.id);
120
+ send({ ...message, id });
121
+ } else failed();
122
+ });
123
+ backend.once('spawn', () => wire({ id: 'init', method: 'initialize', params: {
124
+ protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'managed-launcher', version } } }));
125
+ } catch (error) {
126
+ const stage = ['inspect-installation', 'download-bootstrap', 'install-python', 'download-runtime', 'install-build-tools', 'install-runtime', 'setup-profile'].includes(error.stage)
127
+ ? ` (${error.stage})` : '';
128
+ state = `Memory preparation failed${stage}. No operation was performed. Check network and disk access, then disconnect and reconnect to retry preparation. DSH requires its prepare step before reconnecting.`;
129
+ }
130
+ })();
131
+ await done;
132
+ process.removeListener('SIGTERM', onSignal);
133
+ process.removeListener('SIGINT', onSignal);
134
+ }
package/launcher.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from 'node:url';
3
+ import { realpath } from 'node:fs/promises';
4
+ import { bridge } from './bridge.mjs';
5
+ import { locations, installed, prepare } from './provision.mjs';
6
+
7
+ export async function main(args) {
8
+ const [mode, host, ...rest] = args;
9
+ if (!['prepare', 'mcp'].includes(mode) || !host || rest.some(x => x !== '--auto-setup')
10
+ || (rest.length && (mode !== 'mcp' || host !== 'workbuddy')) || rest.length > 1) {
11
+ throw new Error('Usage: launcher.mjs prepare|mcp workbuddy|dsh [--auto-setup (WorkBuddy only)]');
12
+ }
13
+ const paths = locations(host);
14
+ if (mode === 'prepare') {
15
+ const controller = new AbortController();
16
+ const stop = () => controller.abort();
17
+ process.once('SIGTERM', stop);
18
+ process.once('SIGINT', stop);
19
+ try { await prepare(paths, host, controller.signal); }
20
+ finally { process.removeListener('SIGTERM', stop); process.removeListener('SIGINT', stop); }
21
+ console.log(JSON.stringify({ ready: true, host }));
22
+ } else {
23
+ await bridge(async signal => ({
24
+ command: rest.includes('--auto-setup') ? await prepare(paths, host, signal) : await installed(paths),
25
+ args: ['--home', paths.profile, 'mcp'],
26
+ env: { ...process.env, PYTHONNOUSERSITE: '1', PYTHONPATH: '', PYTHONHOME: '' },
27
+ }));
28
+ }
29
+ }
30
+
31
+ if (process.argv[1] && pathToFileURL(await realpath(process.argv[1])).href === import.meta.url) {
32
+ main(process.argv.slice(2)).catch(error => {
33
+ // Keep the message bounded: only the internal stage label may be added.
34
+ const stage = error && typeof error.stage === 'string' && /^[a-z][a-z-]*$/.test(error.stage) ? ` Failed stage: ${error.stage}.` : '';
35
+ console.error(`Qdrant Memory launcher failed. Check command, preparation, network, disk space and profile ownership. No profile was reset.${stage}`);
36
+ process.exitCode = 1;
37
+ });
38
+ }
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "@cnkang/qdrant-memory-launcher",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-rc.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Private Python and CPU memory runtime for MCP clients",
7
+ "engines": {"node": ">=20"},
8
+ "bin": {"qdrant-memory-launcher": "launcher.mjs"},
9
+ "files": ["launcher.mjs", "provision.mjs", "bridge.mjs", "bootstrap.mjs", "tools.json", "runtime.json", "requirements.in", "requirements.lock", "README.md", "LICENSE", "THIRD_PARTY_NOTICES.md"],
10
+ "license": "MIT",
11
+ "repository": {"type": "git", "url": "git+https://github.com/cnkang/qdrant-memory-mcp.git", "directory": "integrations/managed"},
12
+ "publishConfig": {"access": "public", "registry": "https://registry.npmjs.org"}
13
+ }