@cnkang/qdrant-memory-launcher 0.0.0-stage → 0.1.0-rc.2
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 +85 -2
- package/THIRD_PARTY_NOTICES.md +17 -0
- package/bootstrap.mjs +196 -0
- package/bridge.mjs +134 -0
- package/launcher.mjs +38 -0
- package/package.json +11 -4
- package/provision.mjs +254 -0
- package/requirements.in +2 -0
- package/requirements.lock +1418 -0
- package/runtime.json +9 -0
- package/tools.json +6 -0
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
|
-
#
|
|
1
|
+
# Managed Qdrant Memory launcher
|
|
2
2
|
|
|
3
|
-
|
|
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.2
|
|
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 you enable 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-rc.2",
|
|
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
|
+
}
|