@msvesper/vesper-link 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +44 -0
- package/README.md +26 -0
- package/dist/atomic-json.d.ts +3 -0
- package/dist/atomic-json.js +47 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +170 -0
- package/dist/collectors/claude.d.ts +5 -0
- package/dist/collectors/claude.js +42 -0
- package/dist/collectors/codex.d.ts +5 -0
- package/dist/collectors/codex.js +65 -0
- package/dist/collectors/copilot.d.ts +5 -0
- package/dist/collectors/copilot.js +64 -0
- package/dist/collectors/index.d.ts +4 -0
- package/dist/collectors/index.js +3 -0
- package/dist/collectors/types.d.ts +16 -0
- package/dist/collectors/types.js +1 -0
- package/dist/companion.d.ts +12 -0
- package/dist/companion.js +62 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +41 -0
- package/dist/contracts.d.ts +83 -0
- package/dist/contracts.js +144 -0
- package/dist/diagnostics.d.ts +37 -0
- package/dist/diagnostics.js +64 -0
- package/dist/fs-utils.d.ts +5 -0
- package/dist/fs-utils.js +69 -0
- package/dist/hook-report.d.ts +8 -0
- package/dist/hook-report.js +21 -0
- package/dist/http-client.d.ts +25 -0
- package/dist/http-client.js +228 -0
- package/dist/install-args.d.ts +7 -0
- package/dist/install-args.js +35 -0
- package/dist/installation.d.ts +59 -0
- package/dist/installation.js +524 -0
- package/dist/installer.d.ts +59 -0
- package/dist/installer.js +214 -0
- package/dist/lock.d.ts +5 -0
- package/dist/lock.js +57 -0
- package/dist/managed-runtime.d.ts +33 -0
- package/dist/managed-runtime.js +130 -0
- package/dist/memory-mcp.d.ts +27 -0
- package/dist/memory-mcp.js +155 -0
- package/dist/operational-log.d.ts +17 -0
- package/dist/operational-log.js +48 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +36 -0
- package/dist/pending.d.ts +7 -0
- package/dist/pending.js +35 -0
- package/dist/process-runner.d.ts +11 -0
- package/dist/process-runner.js +21 -0
- package/dist/provider-integrations.d.ts +42 -0
- package/dist/provider-integrations.js +339 -0
- package/dist/queue.d.ts +19 -0
- package/dist/queue.js +129 -0
- package/dist/scheduler.d.ts +51 -0
- package/dist/scheduler.js +176 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +12 -0
- package/dist/source.d.ts +11 -0
- package/dist/source.js +45 -0
- package/dist/state.d.ts +11 -0
- package/dist/state.js +37 -0
- package/dist/sync.d.ts +18 -0
- package/dist/sync.js +247 -0
- package/dist/transport.d.ts +27 -0
- package/dist/transport.js +400 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { normalizeHost } from './config.js';
|
|
4
|
+
import { getCapabilities, getHealth, SystemicUploadError } from './http-client.js';
|
|
5
|
+
import { installManagedRuntime } from './installation.js';
|
|
6
|
+
import { acquireSyncLock } from './lock.js';
|
|
7
|
+
import { prepareManagedRuntime, validateInstallPrerequisites, validateManagedRuntime, } from './managed-runtime.js';
|
|
8
|
+
import { NodeProcessRunner } from './process-runner.js';
|
|
9
|
+
import { acquireRemoteTransport, consumeBootstrapAuthKey } from './transport.js';
|
|
10
|
+
import { VESPER_LINK_VERSION } from './version.js';
|
|
11
|
+
export class WaitingForTagError extends Error {
|
|
12
|
+
progress;
|
|
13
|
+
code = 'waiting_for_tag';
|
|
14
|
+
constructor(progress) {
|
|
15
|
+
const tag = progress.expectedRole ? 'tag:vesper-' + progress.expectedRole : 'exactly one Vesper capability tag';
|
|
16
|
+
super('Remote node ' + (progress.nodeName ?? 'unknown') + ' in ' + (progress.tailnetName ?? 'unknown')
|
|
17
|
+
+ ' is enrolled but not authorized; assign ' + tag + ' and rerun install');
|
|
18
|
+
this.progress = progress;
|
|
19
|
+
this.name = 'WaitingForTagError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class AutomaticEnrollmentConflictError extends Error {
|
|
23
|
+
code = 'automatic_enrollment_existing_identity';
|
|
24
|
+
constructor(progress) {
|
|
25
|
+
const tag = progress.expectedRole ? `tag:vesper-${progress.expectedRole}` : 'the requested Vesper capability tag';
|
|
26
|
+
super(`This device already has a Tailscale identity that is not authorized for Memory Link. `
|
|
27
|
+
+ `Assign ${tag} to ${progress.nodeName ?? 'the existing device'} in Tailscale, `
|
|
28
|
+
+ `or run npx --yes @msvesper/vesper-link@${VESPER_LINK_VERSION} reset-identity --confirm RESET `
|
|
29
|
+
+ 'and then run a freshly copied connection command.');
|
|
30
|
+
this.name = 'AutomaticEnrollmentConflictError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const defaultDependencies = {
|
|
34
|
+
validatePrerequisites: validateInstallPrerequisites,
|
|
35
|
+
prepareRuntime: prepareManagedRuntime,
|
|
36
|
+
validateRuntime: validateManagedRuntime,
|
|
37
|
+
acquireTransport: (options) => acquireRemoteTransport(options),
|
|
38
|
+
getHealth,
|
|
39
|
+
getCapabilities,
|
|
40
|
+
installManaged: installManagedRuntime,
|
|
41
|
+
triggerFirstSync: defaultTriggerFirstSync,
|
|
42
|
+
};
|
|
43
|
+
async function defaultTriggerFirstSync(paths) {
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn(process.execPath, [paths.launcherPath, 'sync-once'], {
|
|
46
|
+
detached: true, windowsHide: true, stdio: 'ignore',
|
|
47
|
+
});
|
|
48
|
+
child.once('error', reject);
|
|
49
|
+
child.once('spawn', () => {
|
|
50
|
+
child.unref();
|
|
51
|
+
resolve();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export async function installVesperLink(options) {
|
|
56
|
+
const dependencies = options.dependencies ?? defaultDependencies;
|
|
57
|
+
const runner = options.runner ?? new NodeProcessRunner();
|
|
58
|
+
let bootstrapAuthKey = consumeBootstrapAuthKey(options.bootstrapAuthKey);
|
|
59
|
+
options.bootstrapAuthKey = undefined;
|
|
60
|
+
const automaticEnrollment = bootstrapAuthKey !== undefined;
|
|
61
|
+
const host = normalizeHost(options.host);
|
|
62
|
+
const companion = await dependencies.validatePrerequisites({ hostUrl: host, runner });
|
|
63
|
+
const releaseCandidateLock = await acquireSyncLock(options.paths.lockPath);
|
|
64
|
+
let runtime;
|
|
65
|
+
try {
|
|
66
|
+
runtime = await dependencies.prepareRuntime({ paths: options.paths, companion });
|
|
67
|
+
await dependencies.validateRuntime(runtime);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await releaseCandidateLock();
|
|
71
|
+
}
|
|
72
|
+
const progress = {
|
|
73
|
+
schemaVersion: 1, state: 'enrolling', host, expectedRole: options.expectedRole, updatedAt: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
await writeProgress(options.paths, progress);
|
|
76
|
+
let transport;
|
|
77
|
+
try {
|
|
78
|
+
transport = await dependencies.acquireTransport({
|
|
79
|
+
paths: options.paths, hostUrl: host,
|
|
80
|
+
requiredCompanion: runtime.companion,
|
|
81
|
+
bootstrapAuthKey,
|
|
82
|
+
onNeedsLogin: (url) => {
|
|
83
|
+
options.onNeedsLogin?.(url);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
bootstrapAuthKey = undefined;
|
|
89
|
+
}
|
|
90
|
+
let capabilities;
|
|
91
|
+
let reportedWaiting = false;
|
|
92
|
+
try {
|
|
93
|
+
progress.tailnetName = transport.identity.tailnetName;
|
|
94
|
+
progress.nodeName = transport.identity.nodeName;
|
|
95
|
+
progress.ipv4 = transport.identity.ipv4;
|
|
96
|
+
while (capabilities === undefined) {
|
|
97
|
+
try {
|
|
98
|
+
capabilities = await dependencies.getCapabilities(transport.hostUrl, transport.fetch);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (!(error instanceof SystemicUploadError) || error.httpStatus !== 403) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
if (automaticEnrollment) {
|
|
106
|
+
throw new AutomaticEnrollmentConflictError(progress);
|
|
107
|
+
}
|
|
108
|
+
progress.state = 'waiting_for_tag';
|
|
109
|
+
progress.updatedAt = new Date().toISOString();
|
|
110
|
+
await writeProgress(options.paths, progress);
|
|
111
|
+
if (!reportedWaiting) {
|
|
112
|
+
reportedWaiting = true;
|
|
113
|
+
options.onWaitingForTag?.(progress);
|
|
114
|
+
}
|
|
115
|
+
if (options.pollAuthorization === false) {
|
|
116
|
+
throw new WaitingForTagError(progress);
|
|
117
|
+
}
|
|
118
|
+
await waitForPoll(options.pollIntervalMs ?? 5_000, options.signal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!capabilities.canUploadSessions) {
|
|
122
|
+
throw new Error('The Vesper host does not allow session upload for this node');
|
|
123
|
+
}
|
|
124
|
+
if (options.expectedRole && capabilities.sourceTrust !== options.expectedRole) {
|
|
125
|
+
throw new Error('Expected role ' + options.expectedRole + ' but live capabilities report ' + capabilities.sourceTrust);
|
|
126
|
+
}
|
|
127
|
+
if (capabilities.sourceTrust === 'trusted' && (!capabilities.canSearchMemory || !capabilities.canAddFact)) {
|
|
128
|
+
throw new Error('Trusted capabilities are incomplete');
|
|
129
|
+
}
|
|
130
|
+
if (capabilities.sourceTrust === 'uploader' && (capabilities.canSearchMemory || capabilities.canAddFact)) {
|
|
131
|
+
throw new Error('Uploader capabilities unexpectedly include Memory access');
|
|
132
|
+
}
|
|
133
|
+
const health = await dependencies.getHealth(transport.hostUrl, transport.fetch);
|
|
134
|
+
if (!health.archiveReady) {
|
|
135
|
+
throw new Error('Remote Memory archive is not ready');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await transport.release();
|
|
140
|
+
}
|
|
141
|
+
const releaseInstallLock = await acquireSyncLock(options.paths.lockPath);
|
|
142
|
+
let installed;
|
|
143
|
+
try {
|
|
144
|
+
await dependencies.validateRuntime(runtime);
|
|
145
|
+
const candidateTransport = await dependencies.acquireTransport({
|
|
146
|
+
paths: options.paths, hostUrl: host, requiredCompanion: runtime.companion,
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
const [candidateHealth, candidateCapabilities] = await Promise.all([
|
|
150
|
+
dependencies.getHealth(candidateTransport.hostUrl, candidateTransport.fetch),
|
|
151
|
+
dependencies.getCapabilities(candidateTransport.hostUrl, candidateTransport.fetch),
|
|
152
|
+
]);
|
|
153
|
+
if (!candidateHealth.archiveReady || candidateCapabilities.sourceTrust !== capabilities.sourceTrust
|
|
154
|
+
|| !candidateCapabilities.canUploadSessions) {
|
|
155
|
+
throw new Error('Candidate doctor detected changed remote capabilities');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await candidateTransport.release();
|
|
160
|
+
}
|
|
161
|
+
installed = await dependencies.installManaged({
|
|
162
|
+
paths: options.paths, runtime, host, capabilities, runner,
|
|
163
|
+
postInstallDoctor: async () => {
|
|
164
|
+
await dependencies.validateRuntime(runtime);
|
|
165
|
+
const postTransport = await dependencies.acquireTransport({
|
|
166
|
+
paths: options.paths, hostUrl: host, requiredCompanion: runtime.companion,
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
const [health, currentCapabilities] = await Promise.all([
|
|
170
|
+
dependencies.getHealth(postTransport.hostUrl, postTransport.fetch),
|
|
171
|
+
dependencies.getCapabilities(postTransport.hostUrl, postTransport.fetch),
|
|
172
|
+
]);
|
|
173
|
+
if (!health.archiveReady || currentCapabilities.sourceTrust !== capabilities.sourceTrust) {
|
|
174
|
+
throw new Error('Post-install doctor detected changed remote capabilities');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await postTransport.release();
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
await releaseInstallLock();
|
|
185
|
+
}
|
|
186
|
+
await fs.rm(options.paths.installProgressPath, { force: true });
|
|
187
|
+
// The first ordinary scan is itself the backfill queue. It is intentionally
|
|
188
|
+
// outside the local install transaction and detached so install does not wait
|
|
189
|
+
// for up to 2,000 uploads to finish.
|
|
190
|
+
await dependencies.triggerFirstSync(options.paths);
|
|
191
|
+
return { state: installed, firstSyncStarted: true };
|
|
192
|
+
}
|
|
193
|
+
async function waitForPoll(milliseconds, signal) {
|
|
194
|
+
if (signal?.aborted) {
|
|
195
|
+
throw signal.reason ?? new Error('Installation cancelled');
|
|
196
|
+
}
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
const complete = () => {
|
|
199
|
+
signal?.removeEventListener('abort', abort);
|
|
200
|
+
resolve();
|
|
201
|
+
};
|
|
202
|
+
const timer = setTimeout(complete, milliseconds);
|
|
203
|
+
const abort = () => {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
signal?.removeEventListener('abort', abort);
|
|
206
|
+
reject(signal?.reason ?? new Error('Installation cancelled'));
|
|
207
|
+
};
|
|
208
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async function writeProgress(paths, progress) {
|
|
212
|
+
const { writeJsonAtomic } = await import('./atomic-json.js');
|
|
213
|
+
await writeJsonAtomic(paths.installProgressPath, progress);
|
|
214
|
+
}
|
package/dist/lock.d.ts
ADDED
package/dist/lock.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
function isProcessAlive(pid) {
|
|
4
|
+
try {
|
|
5
|
+
process.kill(pid, 0);
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async function readLock(lockPath) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(await fs.readFile(lockPath, 'utf8'));
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Cannot read sync lock ${lockPath}: ${String(error)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function acquireSyncLock(lockPath) {
|
|
24
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
25
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
26
|
+
try {
|
|
27
|
+
const handle = await fs.open(lockPath, 'wx');
|
|
28
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
29
|
+
await handle.close();
|
|
30
|
+
return async () => {
|
|
31
|
+
const current = await readLock(lockPath);
|
|
32
|
+
if (current?.pid === process.pid) {
|
|
33
|
+
await fs.rm(lockPath, { force: true });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'EEXIST') {
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
const existing = await readLock(lockPath);
|
|
42
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
43
|
+
throw new SyncAlreadyRunningError(existing.pid);
|
|
44
|
+
}
|
|
45
|
+
await fs.rm(lockPath, { force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Failed to acquire sync lock: ${lockPath}`);
|
|
49
|
+
}
|
|
50
|
+
export class SyncAlreadyRunningError extends Error {
|
|
51
|
+
ownerPid;
|
|
52
|
+
constructor(ownerPid) {
|
|
53
|
+
super(`Another vesper-link sync is already running (pid ${ownerPid})`);
|
|
54
|
+
this.ownerPid = ownerPid;
|
|
55
|
+
this.name = 'SyncAlreadyRunningError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type CompanionInfo } from './companion.js';
|
|
2
|
+
import type { LinkPaths } from './paths.js';
|
|
3
|
+
import { type ProcessRunner } from './process-runner.js';
|
|
4
|
+
export interface ManagedRuntime {
|
|
5
|
+
version: string;
|
|
6
|
+
runtimePath: string;
|
|
7
|
+
packagePath: string;
|
|
8
|
+
cliPath: string;
|
|
9
|
+
companion: CompanionInfo;
|
|
10
|
+
}
|
|
11
|
+
export interface CurrentRuntimePointer {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
version: string;
|
|
14
|
+
runtimePath: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function stableLauncherSource(): string;
|
|
17
|
+
export declare function validateInstallPrerequisites(options: {
|
|
18
|
+
hostUrl: string;
|
|
19
|
+
runner?: ProcessRunner;
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
architecture?: string;
|
|
22
|
+
nodeVersion?: string;
|
|
23
|
+
resolveSourceCompanion?: () => Promise<CompanionInfo>;
|
|
24
|
+
probeCompanion?: (companion: CompanionInfo, environment: NodeJS.ProcessEnv) => Promise<void>;
|
|
25
|
+
}): Promise<CompanionInfo>;
|
|
26
|
+
export declare function prepareManagedRuntime(options: {
|
|
27
|
+
paths: LinkPaths;
|
|
28
|
+
companion: CompanionInfo;
|
|
29
|
+
sourcePackagePath?: string;
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
architecture?: string;
|
|
32
|
+
}): Promise<ManagedRuntime>;
|
|
33
|
+
export declare function validateManagedRuntime(runtime: ManagedRuntime): Promise<void>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { assertCompanionProtocol, resolveCompanion } from './companion.js';
|
|
6
|
+
import { NodeProcessRunner } from './process-runner.js';
|
|
7
|
+
async function metadata(filePath) {
|
|
8
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
function majorVersion(value) {
|
|
11
|
+
const match = /^(\d+)\./.exec(value);
|
|
12
|
+
return match ? Number(match[1]) : 0;
|
|
13
|
+
}
|
|
14
|
+
function protocolProbeEnvironment(environment) {
|
|
15
|
+
const blocked = new Set([
|
|
16
|
+
'vesper_ts_authkey', 'ts_authkey', 'ts_auth_key',
|
|
17
|
+
'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
|
|
18
|
+
]);
|
|
19
|
+
return Object.fromEntries(Object.entries(environment).filter(([key]) => !blocked.has(key.toLowerCase())));
|
|
20
|
+
}
|
|
21
|
+
function npmVersionCommand(platform, environment = process.env) {
|
|
22
|
+
if (platform !== 'win32') {
|
|
23
|
+
return { command: 'npm', args: ['--version'] };
|
|
24
|
+
}
|
|
25
|
+
const commandProcessor = environment.ComSpec ?? environment.COMSPEC;
|
|
26
|
+
if (!commandProcessor) {
|
|
27
|
+
throw new Error('Windows command processor is unavailable');
|
|
28
|
+
}
|
|
29
|
+
// Node 24 rejects direct spawn of .cmd files with EINVAL. This fixed command
|
|
30
|
+
// contains no user input, so invoke npm through the Windows command processor.
|
|
31
|
+
return { command: commandProcessor, args: ['/d', '/s', '/c', 'npm.cmd --version'] };
|
|
32
|
+
}
|
|
33
|
+
async function copyDirectory(source, destination) {
|
|
34
|
+
await fs.cp(source, destination, { recursive: true, force: false, errorOnExist: true });
|
|
35
|
+
}
|
|
36
|
+
export function stableLauncherSource() {
|
|
37
|
+
return `#!/usr/bin/env node
|
|
38
|
+
import fs from 'node:fs/promises';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
const stateDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const current = JSON.parse(await fs.readFile(path.join(stateDir, 'current.json'), 'utf8'));
|
|
43
|
+
if (current.schemaVersion !== 1 || typeof current.runtimePath !== 'string') {
|
|
44
|
+
throw new Error('vesper-link current runtime pointer is malformed');
|
|
45
|
+
}
|
|
46
|
+
const runtimeRoot = path.join(stateDir, 'runtime');
|
|
47
|
+
const relative = path.relative(runtimeRoot, current.runtimePath);
|
|
48
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
49
|
+
throw new Error('vesper-link current runtime escaped the managed runtime root');
|
|
50
|
+
}
|
|
51
|
+
await import(pathToFileURL(path.join(current.runtimePath, 'package', 'dist', 'cli.js')).href);
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
export async function validateInstallPrerequisites(options) {
|
|
55
|
+
// URL validation is intentionally delegated to the same strict parser used by config.
|
|
56
|
+
const { normalizeHost } = await import('./config.js');
|
|
57
|
+
normalizeHost(options.hostUrl);
|
|
58
|
+
const platform = options.platform ?? process.platform;
|
|
59
|
+
const architecture = options.architecture ?? process.arch;
|
|
60
|
+
if (!((platform === 'win32' || platform === 'linux') && architecture === 'x64')) {
|
|
61
|
+
throw new Error(`Unsupported vesper-link platform: ${platform}-${architecture}`);
|
|
62
|
+
}
|
|
63
|
+
const nodeVersion = options.nodeVersion ?? process.versions.node;
|
|
64
|
+
if (majorVersion(nodeVersion) < 20) {
|
|
65
|
+
throw new Error(`Node.js 20 or newer is required; found ${nodeVersion}`);
|
|
66
|
+
}
|
|
67
|
+
const runner = options.runner ?? new NodeProcessRunner();
|
|
68
|
+
const npmProbe = npmVersionCommand(platform);
|
|
69
|
+
const npm = await runner.run(npmProbe.command, npmProbe.args);
|
|
70
|
+
if (npm.code !== 0 || majorVersion(npm.stdout.trim()) < 9) {
|
|
71
|
+
throw new Error(`A working npm 9 or newer is required: ${npm.stderr.trim() || npm.stdout.trim()}`);
|
|
72
|
+
}
|
|
73
|
+
const companion = await (options.resolveSourceCompanion ?? (() => resolveCompanion(platform, architecture)))();
|
|
74
|
+
await (options.probeCompanion ?? assertCompanionProtocol)(companion, protocolProbeEnvironment(process.env));
|
|
75
|
+
return companion;
|
|
76
|
+
}
|
|
77
|
+
export async function prepareManagedRuntime(options) {
|
|
78
|
+
const sourcePackagePath = options.sourcePackagePath
|
|
79
|
+
?? path.dirname(fileURLToPath(new URL('../package.json', import.meta.url)));
|
|
80
|
+
const packageMetadata = await metadata(path.join(sourcePackagePath, 'package.json'));
|
|
81
|
+
if (packageMetadata.name !== '@msvesper/vesper-link' || typeof packageMetadata.version !== 'string') {
|
|
82
|
+
throw new Error('Cannot identify the source vesper-link package');
|
|
83
|
+
}
|
|
84
|
+
const version = packageMetadata.version;
|
|
85
|
+
const runtimePath = path.join(options.paths.runtimeDir, version);
|
|
86
|
+
const packagePath = path.join(runtimePath, 'package');
|
|
87
|
+
const cliPath = path.join(packagePath, 'dist', 'cli.js');
|
|
88
|
+
const existingMetadata = await metadata(path.join(packagePath, 'package.json')).catch((error) => {
|
|
89
|
+
if (error.code === 'ENOENT') {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
});
|
|
94
|
+
if (!existingMetadata) {
|
|
95
|
+
await fs.mkdir(options.paths.runtimeDir, { recursive: true });
|
|
96
|
+
const temporary = path.join(options.paths.runtimeDir, `.${version}.${process.pid}.${Date.now()}.candidate`);
|
|
97
|
+
try {
|
|
98
|
+
await fs.mkdir(path.join(temporary, 'package'), { recursive: true });
|
|
99
|
+
await Promise.all([
|
|
100
|
+
copyDirectory(path.join(sourcePackagePath, 'dist'), path.join(temporary, 'package', 'dist')),
|
|
101
|
+
fs.copyFile(path.join(sourcePackagePath, 'package.json'), path.join(temporary, 'package', 'package.json')),
|
|
102
|
+
]);
|
|
103
|
+
const companionPackagePath = path.dirname(path.dirname(options.companion.executable));
|
|
104
|
+
const destination = path.join(temporary, 'package', 'node_modules', ...options.companion.packageName.split('/'));
|
|
105
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
106
|
+
await copyDirectory(companionPackagePath, destination);
|
|
107
|
+
await fs.rename(temporary, runtimePath);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await fs.rm(temporary, { recursive: true, force: true }).catch(() => undefined);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (existingMetadata.name !== '@msvesper/vesper-link' || existingMetadata.version !== version) {
|
|
114
|
+
throw new Error(`Managed runtime ${runtimePath} contains a different package`);
|
|
115
|
+
}
|
|
116
|
+
await fs.access(cliPath);
|
|
117
|
+
const candidatePackageJson = path.join(packagePath, 'package.json');
|
|
118
|
+
const candidateRequire = createRequire(candidatePackageJson);
|
|
119
|
+
const companion = await resolveCompanion(options.platform ?? process.platform, options.architecture ?? process.arch, (specifier) => candidateRequire.resolve(specifier), candidatePackageJson);
|
|
120
|
+
await assertCompanionProtocol(companion, protocolProbeEnvironment(process.env));
|
|
121
|
+
return { version, runtimePath, packagePath, cliPath, companion };
|
|
122
|
+
}
|
|
123
|
+
export async function validateManagedRuntime(runtime) {
|
|
124
|
+
const packageMetadata = await metadata(path.join(runtime.packagePath, 'package.json'));
|
|
125
|
+
if (packageMetadata.name !== '@msvesper/vesper-link' || packageMetadata.version !== runtime.version) {
|
|
126
|
+
throw new Error('Managed runtime package identity changed after preparation');
|
|
127
|
+
}
|
|
128
|
+
await fs.access(runtime.cliPath);
|
|
129
|
+
await assertCompanionProtocol(runtime.companion, protocolProbeEnvironment(process.env));
|
|
130
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { addFact, searchMemory } from './http-client.js';
|
|
2
|
+
import type { LinkPaths } from './paths.js';
|
|
3
|
+
import { type RemoteTransport } from './transport.js';
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc: '2.0';
|
|
6
|
+
id?: string | number | null;
|
|
7
|
+
method: string;
|
|
8
|
+
params?: unknown;
|
|
9
|
+
}
|
|
10
|
+
interface McpDependencies {
|
|
11
|
+
acquireTransport(paths: LinkPaths, hostUrl: string): Promise<RemoteTransport>;
|
|
12
|
+
search(hostUrl: string, query: string, limit: number, fetchImpl: typeof fetch): ReturnType<typeof searchMemory>;
|
|
13
|
+
add(hostUrl: string, fact: string, fetchImpl: typeof fetch): ReturnType<typeof addFact>;
|
|
14
|
+
}
|
|
15
|
+
export declare class MemoryMcpSession {
|
|
16
|
+
private readonly paths;
|
|
17
|
+
private readonly dependencies;
|
|
18
|
+
private transport;
|
|
19
|
+
private hostUrl;
|
|
20
|
+
constructor(paths: LinkPaths, dependencies?: McpDependencies);
|
|
21
|
+
handle(request: JsonRpcRequest): Promise<unknown>;
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
private remote;
|
|
24
|
+
private callTool;
|
|
25
|
+
}
|
|
26
|
+
export declare function runMemoryMcp(paths: LinkPaths): Promise<void>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as readline from 'node:readline';
|
|
2
|
+
import { readConfig } from './config.js';
|
|
3
|
+
import { addFact, searchMemory } from './http-client.js';
|
|
4
|
+
import { readInstalledState } from './installation.js';
|
|
5
|
+
import { acquireRemoteTransport } from './transport.js';
|
|
6
|
+
import { VESPER_LINK_VERSION } from './version.js';
|
|
7
|
+
const MCP_PROTOCOL_VERSION = '2025-03-26';
|
|
8
|
+
const defaultDependencies = {
|
|
9
|
+
acquireTransport: (paths, hostUrl) => acquireRemoteTransport({ paths, hostUrl }),
|
|
10
|
+
search: searchMemory,
|
|
11
|
+
add: addFact,
|
|
12
|
+
};
|
|
13
|
+
function objectValue(value) {
|
|
14
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
15
|
+
}
|
|
16
|
+
function tools() {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
name: 'search_memory',
|
|
20
|
+
description: 'Search the canonical Vesper Memory for prior preferences, decisions, facts, and context.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object', additionalProperties: false, required: ['query'],
|
|
23
|
+
properties: {
|
|
24
|
+
query: { type: 'string', description: 'What to search for.' },
|
|
25
|
+
limit: { type: 'integer', minimum: 1, maximum: 10, default: 5 },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'add_fact',
|
|
31
|
+
description: 'Save one fact to Vesper Memory only when the user explicitly asks to remember it.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object', additionalProperties: false, required: ['fact'],
|
|
34
|
+
properties: { fact: { type: 'string', description: 'The fact the user explicitly asked to remember.' } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
export class MemoryMcpSession {
|
|
40
|
+
paths;
|
|
41
|
+
dependencies;
|
|
42
|
+
transport;
|
|
43
|
+
hostUrl;
|
|
44
|
+
constructor(paths, dependencies = defaultDependencies) {
|
|
45
|
+
this.paths = paths;
|
|
46
|
+
this.dependencies = dependencies;
|
|
47
|
+
}
|
|
48
|
+
async handle(request) {
|
|
49
|
+
if (request.method === 'initialize') {
|
|
50
|
+
return {
|
|
51
|
+
protocolVersion: MCP_PROTOCOL_VERSION, capabilities: { tools: { listChanged: false } },
|
|
52
|
+
serverInfo: { name: 'vesper-memory', version: VESPER_LINK_VERSION },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (request.method === 'ping') {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
if (request.method === 'tools/list') {
|
|
59
|
+
return { tools: tools() };
|
|
60
|
+
}
|
|
61
|
+
if (request.method === 'tools/call') {
|
|
62
|
+
return this.callTool(objectValue(request.params));
|
|
63
|
+
}
|
|
64
|
+
throw Object.assign(new Error(`Method not found: ${request.method}`), { rpcCode: -32601 });
|
|
65
|
+
}
|
|
66
|
+
async close() {
|
|
67
|
+
const transport = this.transport;
|
|
68
|
+
this.transport = undefined;
|
|
69
|
+
if (transport) {
|
|
70
|
+
await transport.release();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async remote() {
|
|
74
|
+
if ((await readInstalledState(this.paths))?.paused) {
|
|
75
|
+
await this.close();
|
|
76
|
+
throw new Error('vesper-link is paused; run resume before using Remote Memory');
|
|
77
|
+
}
|
|
78
|
+
if (!this.transport) {
|
|
79
|
+
const config = await readConfig(this.paths.configPath);
|
|
80
|
+
this.hostUrl = config.host;
|
|
81
|
+
this.transport = await this.dependencies.acquireTransport(this.paths, config.host);
|
|
82
|
+
}
|
|
83
|
+
return this.transport;
|
|
84
|
+
}
|
|
85
|
+
async callTool(params) {
|
|
86
|
+
const name = params.name;
|
|
87
|
+
const args = objectValue(params.arguments);
|
|
88
|
+
if (name === 'search_memory') {
|
|
89
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
90
|
+
const limit = args.limit === undefined ? 5 : args.limit;
|
|
91
|
+
if (!query || !Number.isInteger(limit) || limit < 1 || limit > 10) {
|
|
92
|
+
throw Object.assign(new Error('search_memory requires a non-empty query and limit from 1 to 10'), { rpcCode: -32602 });
|
|
93
|
+
}
|
|
94
|
+
const transport = await this.remote();
|
|
95
|
+
const hostUrl = this.hostUrl;
|
|
96
|
+
const response = await this.dependencies.search(hostUrl, query, limit, transport.fetch);
|
|
97
|
+
return { content: [{ type: 'text', text: JSON.stringify(response.results) }] };
|
|
98
|
+
}
|
|
99
|
+
if (name === 'add_fact') {
|
|
100
|
+
const fact = typeof args.fact === 'string' ? args.fact.trim() : '';
|
|
101
|
+
if (!fact) {
|
|
102
|
+
throw Object.assign(new Error('add_fact requires a non-empty fact'), { rpcCode: -32602 });
|
|
103
|
+
}
|
|
104
|
+
const transport = await this.remote();
|
|
105
|
+
const hostUrl = this.hostUrl;
|
|
106
|
+
const response = await this.dependencies.add(hostUrl, fact, transport.fetch);
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: 'text', text: response.status === 'written'
|
|
109
|
+
? `Remembered (${response.schemaType}, ${response.id}).`
|
|
110
|
+
: `Already present (${response.schemaType}, ${response.id}).` }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
throw Object.assign(new Error(`Unknown tool: ${String(name)}`), { rpcCode: -32602 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function runMemoryMcp(paths) {
|
|
117
|
+
const session = new MemoryMcpSession(paths);
|
|
118
|
+
const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
119
|
+
try {
|
|
120
|
+
for await (const line of input) {
|
|
121
|
+
if (!line.trim()) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
let request;
|
|
125
|
+
try {
|
|
126
|
+
request = JSON.parse(line);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } })}\n`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
|
|
133
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id ?? null, error: { code: -32600, message: 'Invalid request' } })}\n`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (request.id === undefined) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await session.handle(request);
|
|
141
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const code = typeof error.rpcCode === 'number'
|
|
145
|
+
? error.rpcCode : -32000;
|
|
146
|
+
process.stdout.write(`${JSON.stringify({
|
|
147
|
+
jsonrpc: '2.0', id: request.id, error: { code, message: error instanceof Error ? error.message : String(error) },
|
|
148
|
+
})}\n`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await session.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface SyncLogRecord {
|
|
2
|
+
timestamp: string;
|
|
3
|
+
event: 'sync_completed' | 'sync_failed';
|
|
4
|
+
discovered?: number;
|
|
5
|
+
queued?: number;
|
|
6
|
+
uploaded?: number;
|
|
7
|
+
unchanged?: number;
|
|
8
|
+
failed?: number;
|
|
9
|
+
collectorIssueCount?: number;
|
|
10
|
+
stoppedBy?: string;
|
|
11
|
+
errorCode?: string;
|
|
12
|
+
errorType?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function appendSyncLog(logsDir: string, record: SyncLogRecord, options?: {
|
|
15
|
+
maxBytes?: number;
|
|
16
|
+
fileCount?: number;
|
|
17
|
+
}): Promise<void>;
|