memtrace-skills 1.1.12 → 1.2.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/dist/commands/install.js +4 -0
- package/dist/rail-hook.d.ts +1 -0
- package/dist/rail-hook.js +105 -0
- package/dist/rail-install.js +4 -0
- package/dist/rail-lifecycle.d.ts +1 -0
- package/dist/rail-lifecycle.js +87 -0
- package/dist/rail-runtime.d.ts +26 -0
- package/dist/rail-runtime.js +112 -0
- package/dist/transformers/claude.d.ts +11 -19
- package/dist/transformers/claude.js +66 -64
- package/dist/transformers/opencode.js +5 -0
- package/dist/transformers/rail-hooks.d.ts +1 -1
- package/dist/transformers/rail-hooks.js +31 -12
- package/package.json +2 -2
package/dist/commands/install.js
CHANGED
|
@@ -4,6 +4,7 @@ import { loadSkills } from '../skills.js';
|
|
|
4
4
|
import { ALL_TRANSFORMERS, findTransformer } from '../transformers/index.js';
|
|
5
5
|
import { commandExists, execCommand } from '../utils.js';
|
|
6
6
|
import { canPrompt, promptForChoices } from './picker.js';
|
|
7
|
+
import { persistHookOptOut } from '../rail-runtime.js';
|
|
7
8
|
export async function resolveMemtraceBinary() {
|
|
8
9
|
if (await commandExists('memtrace'))
|
|
9
10
|
return 'memtrace';
|
|
@@ -31,6 +32,9 @@ function selectTransformers(options) {
|
|
|
31
32
|
return picks;
|
|
32
33
|
}
|
|
33
34
|
export async function runInstall(options) {
|
|
35
|
+
if (options.scope === 'global' && process.env.MEMTRACE_INSTALL_NO_HOOKS === '1') {
|
|
36
|
+
persistHookOptOut();
|
|
37
|
+
}
|
|
34
38
|
console.log('\n Memtrace Skills Installer\n');
|
|
35
39
|
// Interactive prompt only when TTY is available and user didn't pre-specify
|
|
36
40
|
if (!options.yes && (!options.only || options.only.length === 0) && canPrompt()) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Cached host hooks stop here when Memtrace is absent. No output and no network. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { railIsActive, runtimeForCwd, pidAlive } from './rail-runtime.js';
|
|
5
|
+
async function main() {
|
|
6
|
+
if (process.env.MEMTRACE_RAIL?.trim().toLowerCase() === 'off'
|
|
7
|
+
|| process.env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow'
|
|
8
|
+
|| !railIsActive())
|
|
9
|
+
return;
|
|
10
|
+
let input = '';
|
|
11
|
+
for await (const chunk of process.stdin) {
|
|
12
|
+
input += chunk;
|
|
13
|
+
if (input.length > 2_000_000)
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const payload = JSON.parse(input);
|
|
17
|
+
const cwd = payload.cwd || payload.workspace_roots?.[0] || process.cwd();
|
|
18
|
+
if (typeof cwd !== 'string')
|
|
19
|
+
return;
|
|
20
|
+
const runtime = runtimeForCwd(cwd);
|
|
21
|
+
if (!runtime)
|
|
22
|
+
return;
|
|
23
|
+
const hostIndex = process.argv.indexOf('--host');
|
|
24
|
+
const host = hostIndex >= 0 ? process.argv[hostIndex + 1] : 'generic';
|
|
25
|
+
const jsonMode = process.argv.includes('--json');
|
|
26
|
+
// JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
|
|
27
|
+
// Other hosts use the in-process daemon router with no native binary spawn.
|
|
28
|
+
if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
|
|
29
|
+
const query = new URLSearchParams({ host, mode: process.env.MEMTRACE_RAIL || '',
|
|
30
|
+
escape: process.env.MEMTRACE_RAW_SEARCH || '' });
|
|
31
|
+
const body = await new Promise(resolve => {
|
|
32
|
+
let finished = false;
|
|
33
|
+
const finish = (value = '') => {
|
|
34
|
+
if (finished)
|
|
35
|
+
return;
|
|
36
|
+
finished = true;
|
|
37
|
+
clearTimeout(deadline);
|
|
38
|
+
clearInterval(monitor);
|
|
39
|
+
resolve(value);
|
|
40
|
+
};
|
|
41
|
+
const request = http.request({ hostname: '127.0.0.1', port: runtime.port,
|
|
42
|
+
method: 'POST', path: `/api/rail/hook?${query}`,
|
|
43
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(input) },
|
|
44
|
+
}, response => {
|
|
45
|
+
let value = '';
|
|
46
|
+
response.on('data', chunk => {
|
|
47
|
+
value += chunk;
|
|
48
|
+
if (value.length > 2_000_000) {
|
|
49
|
+
finish();
|
|
50
|
+
request.destroy();
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
response.on('error', () => finish());
|
|
54
|
+
response.on('end', () => finish(response.statusCode === 200 ? value : ''));
|
|
55
|
+
});
|
|
56
|
+
const deadline = setTimeout(() => { finish(); request.destroy(); }, 5500);
|
|
57
|
+
const monitor = setInterval(() => {
|
|
58
|
+
if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)) {
|
|
59
|
+
finish();
|
|
60
|
+
request.destroy();
|
|
61
|
+
}
|
|
62
|
+
}, 50);
|
|
63
|
+
request.on('error', () => finish());
|
|
64
|
+
request.end(input);
|
|
65
|
+
});
|
|
66
|
+
if (body && runtimeForCwd(cwd)) {
|
|
67
|
+
JSON.parse(body); // never emit malformed endpoint output into an agent
|
|
68
|
+
process.stdout.write(body);
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
|
|
73
|
+
cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
74
|
+
env: { ...process.env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
|
|
75
|
+
});
|
|
76
|
+
let output = '';
|
|
77
|
+
let stderr = '';
|
|
78
|
+
child.stdout.on('data', chunk => { if (output.length < 2_000_000)
|
|
79
|
+
output += chunk; });
|
|
80
|
+
child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
|
|
81
|
+
stderr += chunk; });
|
|
82
|
+
child.stdin.on('error', () => { });
|
|
83
|
+
child.stdin.end(input);
|
|
84
|
+
const timer = setTimeout(() => child.kill(), 5500);
|
|
85
|
+
// If the daemon exits while a request is in flight, discard the response.
|
|
86
|
+
const monitor = setInterval(() => {
|
|
87
|
+
if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid))
|
|
88
|
+
child.kill();
|
|
89
|
+
}, 50);
|
|
90
|
+
const code = await new Promise(resolve => {
|
|
91
|
+
child.once('error', () => resolve(null));
|
|
92
|
+
child.once('close', resolve);
|
|
93
|
+
});
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
clearInterval(monitor);
|
|
96
|
+
if (!runtimeForCwd(cwd))
|
|
97
|
+
return;
|
|
98
|
+
if (code === 0)
|
|
99
|
+
process.stdout.write(output);
|
|
100
|
+
else if (host === 'windsurf' && code === 2) {
|
|
101
|
+
process.stderr.write(stderr);
|
|
102
|
+
process.exitCode = 2;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
main().catch(() => { });
|
package/dist/rail-install.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import os from 'os';
|
|
8
8
|
import path from 'path';
|
|
9
|
+
import { railIsActive } from './rail-runtime.js';
|
|
9
10
|
import { registerRailHookInSettingsAt, removeRailHookFromSettingsAt, } from './transformers/claude.js';
|
|
10
11
|
import { geminiSettingsPath } from './transformers/gemini.js';
|
|
11
12
|
import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, } from './transformers/rail-hooks.js';
|
|
@@ -27,6 +28,9 @@ function cursorHooksPath(ctx) {
|
|
|
27
28
|
* Idempotent. Does not install skills or MCP — hooks only.
|
|
28
29
|
*/
|
|
29
30
|
export function installRailHooks(ctx) {
|
|
31
|
+
if (!railIsActive()) {
|
|
32
|
+
return uninstallRailHooks(ctx).map(result => ({ ...result, skipped: 'Memtrace is not active; no hook registered' }));
|
|
33
|
+
}
|
|
30
34
|
const bin = ctx.memtraceBinary?.trim() ?? '';
|
|
31
35
|
if (!bin) {
|
|
32
36
|
throw new Error('memtrace binary path is required to install Rail hooks');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** The daemon owns this process through stdin; EOF also covers SIGKILL/crashes. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { installRailHooks, uninstallRailHooks } from './rail-install.js';
|
|
6
|
+
import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive } from './rail-runtime.js';
|
|
7
|
+
import { writeJsonAtomic } from './fs-safe.js';
|
|
8
|
+
const arg = (name) => {
|
|
9
|
+
const index = process.argv.indexOf(name);
|
|
10
|
+
return index >= 0 ? process.argv[index + 1] : '';
|
|
11
|
+
};
|
|
12
|
+
const pid = Number(arg('--pid'));
|
|
13
|
+
const port = Number(arg('--port'));
|
|
14
|
+
const binary = arg('--binary');
|
|
15
|
+
const cwd = arg('--cwd');
|
|
16
|
+
const routeBinary = arg('--route-binary') || undefined;
|
|
17
|
+
async function main() {
|
|
18
|
+
if (process.argv.includes('--sync')) {
|
|
19
|
+
const live = activeRailRuntimes();
|
|
20
|
+
const ctx = { scope: 'global', cwd: process.cwd(),
|
|
21
|
+
memtraceBinary: live[0]?.binary || '', skipMcp: true };
|
|
22
|
+
if (live.length)
|
|
23
|
+
installRailHooks(ctx);
|
|
24
|
+
else
|
|
25
|
+
uninstallRailHooks(ctx);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!pidAlive(pid) || !path.isAbsolute(binary || '') || !path.isAbsolute(cwd || '')
|
|
29
|
+
|| !Number.isInteger(port) || port < 1 || port > 65535)
|
|
30
|
+
return;
|
|
31
|
+
const ctx = { scope: 'global', cwd, memtraceBinary: binary, skipMcp: true };
|
|
32
|
+
const directory = runtimeDirectory();
|
|
33
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
34
|
+
const lease = path.join(directory, `${pid}-${randomUUID()}.json`);
|
|
35
|
+
let done = false;
|
|
36
|
+
let wired;
|
|
37
|
+
const reconcile = () => {
|
|
38
|
+
if (done)
|
|
39
|
+
return;
|
|
40
|
+
if (!pidAlive(pid)) {
|
|
41
|
+
stop();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
|
|
45
|
+
const enabled = !railDisabled();
|
|
46
|
+
if (enabled === wired)
|
|
47
|
+
return;
|
|
48
|
+
if (enabled)
|
|
49
|
+
installRailHooks(ctx);
|
|
50
|
+
else
|
|
51
|
+
uninstallRailHooks(ctx);
|
|
52
|
+
wired = enabled;
|
|
53
|
+
};
|
|
54
|
+
const stop = () => {
|
|
55
|
+
if (done)
|
|
56
|
+
return;
|
|
57
|
+
done = true;
|
|
58
|
+
clearInterval(timer);
|
|
59
|
+
try {
|
|
60
|
+
fs.unlinkSync(lease);
|
|
61
|
+
}
|
|
62
|
+
catch { /* already absent */ }
|
|
63
|
+
// One departing daemon must not disable a different live workspace.
|
|
64
|
+
if (activeRailRuntimes().length === 0)
|
|
65
|
+
uninstallRailHooks(ctx);
|
|
66
|
+
process.stdin.destroy();
|
|
67
|
+
};
|
|
68
|
+
const timer = setInterval(() => {
|
|
69
|
+
try {
|
|
70
|
+
reconcile();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
stop();
|
|
74
|
+
}
|
|
75
|
+
}, 500);
|
|
76
|
+
if (!process.argv.includes('--watch-pid')) {
|
|
77
|
+
process.stdin.on('end', stop);
|
|
78
|
+
// Windows named pipes report a killed writer as ECONNRESET/close.
|
|
79
|
+
process.stdin.on('error', stop);
|
|
80
|
+
process.stdin.on('close', stop);
|
|
81
|
+
process.stdin.resume();
|
|
82
|
+
}
|
|
83
|
+
process.on('SIGINT', stop);
|
|
84
|
+
process.on('SIGTERM', stop);
|
|
85
|
+
reconcile();
|
|
86
|
+
}
|
|
87
|
+
main().catch(() => { process.exitCode = 1; });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface RailRuntime {
|
|
2
|
+
pid: number;
|
|
3
|
+
guardianPid: number;
|
|
4
|
+
updatedAt: number;
|
|
5
|
+
cwd: string;
|
|
6
|
+
binary: string;
|
|
7
|
+
port: number;
|
|
8
|
+
/** Local development override; packaged hooks normally use the daemon HTTP API. */
|
|
9
|
+
routeBinary?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const LEASE_MAX_AGE_MS = 3000;
|
|
12
|
+
export declare const RAIL_GUARD_MARKER = "rail-hook.js";
|
|
13
|
+
export declare function runtimeDirectory(): string;
|
|
14
|
+
export declare function railDisabled(): boolean;
|
|
15
|
+
export declare function persistHookOptOut(): void;
|
|
16
|
+
export declare function pidAlive(pid: number): boolean;
|
|
17
|
+
export declare function activeRailRuntimes(): RailRuntime[];
|
|
18
|
+
export declare function railIsActive(): boolean;
|
|
19
|
+
export declare function runtimeForCwd(cwd: string): RailRuntime | undefined;
|
|
20
|
+
export declare function railHookInvocation(host: string): {
|
|
21
|
+
command: string;
|
|
22
|
+
args: string[];
|
|
23
|
+
};
|
|
24
|
+
/** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
|
|
25
|
+
export declare function railHookCommand(host: string, platform?: NodeJS.Platform): string;
|
|
26
|
+
export declare function isRailCommand(command: string): boolean;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Local daemon leases. Reading these never contacts a server or starts Memtrace. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { writeJsonAtomic } from './fs-safe.js';
|
|
7
|
+
export const LEASE_MAX_AGE_MS = 3000;
|
|
8
|
+
export const RAIL_GUARD_MARKER = 'rail-hook.js';
|
|
9
|
+
export function runtimeDirectory() {
|
|
10
|
+
return process.env.MEMTRACE_RAIL_RUNTIME_DIR?.trim()
|
|
11
|
+
|| path.join(os.homedir(), '.memtrace', 'rail-runtimes');
|
|
12
|
+
}
|
|
13
|
+
export function railDisabled() {
|
|
14
|
+
if (process.env.MEMTRACE_INSTALL_NO_HOOKS === '1')
|
|
15
|
+
return true;
|
|
16
|
+
const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
|
|
17
|
+
|| path.join(os.homedir(), '.memtrace', 'rail.json');
|
|
18
|
+
try {
|
|
19
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
20
|
+
// An explicit durable opt-out wins over an old agent's environment.
|
|
21
|
+
return config.mode === 'off' || config.hooksEnabled === false;
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code !== 'ENOENT';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function persistHookOptOut() {
|
|
28
|
+
const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
|
|
29
|
+
|| path.join(os.homedir(), '.memtrace', 'rail.json');
|
|
30
|
+
let config = {};
|
|
31
|
+
try {
|
|
32
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== 'ENOENT')
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
writeJsonAtomic(configPath, { ...config, hooksEnabled: false });
|
|
39
|
+
}
|
|
40
|
+
export function pidAlive(pid) {
|
|
41
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
process.kill(pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// EPERM means the PID exists but this token cannot signal it (for
|
|
49
|
+
// example an elevated Windows daemon). No signal is sent with signal 0.
|
|
50
|
+
// ESRCH is absence; leases also require a live guardian and fresh heartbeat.
|
|
51
|
+
return error.code === 'EPERM';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function activeRailRuntimes() {
|
|
55
|
+
if (railDisabled())
|
|
56
|
+
return [];
|
|
57
|
+
try {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
return fs.readdirSync(runtimeDirectory()).filter(name => name.endsWith('.json')).flatMap(name => {
|
|
60
|
+
try {
|
|
61
|
+
const runtime = JSON.parse(fs.readFileSync(path.join(runtimeDirectory(), name), 'utf8'));
|
|
62
|
+
const age = now - runtime.updatedAt;
|
|
63
|
+
if (!Number.isFinite(age) || age < -1000 || age > LEASE_MAX_AGE_MS
|
|
64
|
+
|| !pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)
|
|
65
|
+
|| typeof runtime.binary !== 'string' || !path.isAbsolute(runtime.binary)
|
|
66
|
+
|| (runtime.routeBinary !== undefined && !path.isAbsolute(runtime.routeBinary))
|
|
67
|
+
|| typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
|
|
68
|
+
|| !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
|
|
69
|
+
return [];
|
|
70
|
+
return [runtime];
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function railIsActive() { return activeRailRuntimes().length > 0; }
|
|
82
|
+
function normalizedWorkspacePath(directory) {
|
|
83
|
+
let resolved = path.resolve(directory);
|
|
84
|
+
try {
|
|
85
|
+
resolved = fs.realpathSync.native(resolved);
|
|
86
|
+
}
|
|
87
|
+
catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
|
|
88
|
+
const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
|
|
89
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
90
|
+
}
|
|
91
|
+
export function runtimeForCwd(cwd) {
|
|
92
|
+
const target = normalizedWorkspacePath(cwd);
|
|
93
|
+
return activeRailRuntimes()
|
|
94
|
+
.map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
|
|
95
|
+
.filter(({ root }) => target === root || target.startsWith(root + '/'))
|
|
96
|
+
.sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
|
|
97
|
+
}
|
|
98
|
+
export function railHookInvocation(host) {
|
|
99
|
+
return { command: process.execPath,
|
|
100
|
+
args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host] };
|
|
101
|
+
}
|
|
102
|
+
/** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
|
|
103
|
+
export function railHookCommand(host, platform = process.platform) {
|
|
104
|
+
const invocation = railHookInvocation(host);
|
|
105
|
+
const quote = platform === 'win32'
|
|
106
|
+
? (s) => '"' + s.replace(/\\/g, '/').replace(/"/g, '\\"') + '"'
|
|
107
|
+
: (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
|
108
|
+
return [invocation.command, ...invocation.args].map(quote).join(' ');
|
|
109
|
+
}
|
|
110
|
+
export function isRailCommand(command) {
|
|
111
|
+
return command.includes('route --hook') || command.includes(RAIL_GUARD_MARKER);
|
|
112
|
+
}
|
|
@@ -19,33 +19,20 @@ export interface SettingsMutationResult {
|
|
|
19
19
|
* Merge-add the memtrace MCP server into a Claude settings.json file.
|
|
20
20
|
* Never overwrites a malformed file — backs it up and returns registered=false.
|
|
21
21
|
*/
|
|
22
|
-
export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string): SettingsMutationResult;
|
|
22
|
+
export declare function registerMcpInSettingsAt(settingsPath: string, memtraceBinary: string, legacyEnv?: Record<string, string>): SettingsMutationResult;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* (`POST /api/rail/hook`) with one `curl` — ~5ms of process instead of the
|
|
28
|
-
* full memtrace binary (node shim + native pair, ~59MB alive for ~0.5s) per
|
|
29
|
-
* Grep/Glob/Bash call. Fail-open is structural: no daemon (connection
|
|
30
|
-
* refused, bounded by `--connect-timeout`) or an old daemon without the
|
|
31
|
-
* endpoint (`-f` on 404) produces no output, and `|| true` exits 0 — Claude
|
|
32
|
-
* Code treats that as allow-with-no-decision. The full binary is NEVER the
|
|
33
|
-
* fallback. The agent session's `MEMTRACE_RAIL` / `MEMTRACE_RAW_SEARCH` ride
|
|
34
|
-
* along as query params, so per-session mode/escape semantics are identical
|
|
35
|
-
* to the spawn era; `MEMTRACE_RAIL_SEARCH_URL` / `MEMTRACE_UI_PORT` override
|
|
36
|
-
* the endpoint the same way they steered the binary's own daemon lookup.
|
|
37
|
-
*
|
|
38
|
-
* Windows keeps the gen-1 binary spawn: hook commands there do not run under
|
|
39
|
-
* a POSIX shell, so the env-forwarding one-liner has no portable equivalent.
|
|
24
|
+
* Every host enters a local liveness guard before contacting Memtrace. The
|
|
25
|
+
* daemon lifecycle registers/removes this command; a cached registration is
|
|
26
|
+
* silent after a stop or crash. Windows uses exec-form command/args below.
|
|
40
27
|
*/
|
|
41
|
-
export declare function claudeRailHookCommand(
|
|
28
|
+
export declare function claudeRailHookCommand(_memtraceBinary: string, platform?: NodeJS.Platform): string;
|
|
42
29
|
/**
|
|
43
30
|
* Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
|
|
44
31
|
*
|
|
45
32
|
* This is what makes Rail "part of memtrace" rather than a manual install:
|
|
46
33
|
* `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
|
|
47
34
|
* attempt through the Rail router (see {@link claudeRailHookCommand} for the
|
|
48
|
-
* transport).
|
|
35
|
+
* transport). While a daemon is running the hook defaults to **observe** —
|
|
49
36
|
* `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
|
|
50
37
|
* baked into the command so the user can change it via env without
|
|
51
38
|
* re-installing.
|
|
@@ -75,6 +62,11 @@ export interface SettingsCleanupResult {
|
|
|
75
62
|
* installer versions and Claude marketplace schema changes.
|
|
76
63
|
*/
|
|
77
64
|
export declare function removeClaudeSettingsEntriesAt(settingsPath: string): SettingsCleanupResult;
|
|
65
|
+
/**
|
|
66
|
+
* Register the memtrace MCP server in Claude Code's settings.
|
|
67
|
+
* This adds the MCP server config so Claude Code can connect to memtrace tools.
|
|
68
|
+
*/
|
|
69
|
+
export declare function registerMcpServer(memtraceBinaryPath: string): Promise<void>;
|
|
78
70
|
/** `~/.claude.json` — where Claude Code stores user-scope `mcpServers`. */
|
|
79
71
|
export declare function claudeUserConfigFile(): string;
|
|
80
72
|
/**
|
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
|
|
|
5
5
|
import { copyCanonicalPluginAssets, copyCanonicalPluginManifest, readCanonicalPluginVersion, } from '../../plugin-assets/index.js';
|
|
6
6
|
import { execCommand, commandExists } from '../utils.js';
|
|
7
7
|
import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
|
|
8
|
+
import { railIsActive, railHookInvocation, railHookCommand, RAIL_GUARD_MARKER } from '../rail-runtime.js';
|
|
8
9
|
import { LEGACY_MCP_SERVER_NAME, MEMTRACE_MCP_ENV, removeLegacyMcpServer, } from './shared.js';
|
|
9
10
|
const PLUGIN_NAME = 'memtrace-skills';
|
|
10
11
|
const MARKETPLACE_NAME = 'memtrace';
|
|
@@ -74,7 +75,10 @@ async function tryMcpAddJson(memtraceBinary) {
|
|
|
74
75
|
// The legacy server is normally absent. Removal is a best-effort upgrade migration.
|
|
75
76
|
}
|
|
76
77
|
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
|
|
77
|
-
const existingEnv =
|
|
78
|
+
const existingEnv = {
|
|
79
|
+
...readExistingMemtraceEnv(settingsFile),
|
|
80
|
+
...readExistingMemtraceEnv(path.join(os.homedir(), '.claude.json')),
|
|
81
|
+
};
|
|
78
82
|
const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
|
|
79
83
|
const config = JSON.stringify({
|
|
80
84
|
command: memtraceBinary,
|
|
@@ -181,7 +185,7 @@ async function tryClaudeCliInstall() {
|
|
|
181
185
|
* Merge-add the memtrace MCP server into a Claude settings.json file.
|
|
182
186
|
* Never overwrites a malformed file — backs it up and returns registered=false.
|
|
183
187
|
*/
|
|
184
|
-
export function registerMcpInSettingsAt(settingsPath, memtraceBinary) {
|
|
188
|
+
export function registerMcpInSettingsAt(settingsPath, memtraceBinary, legacyEnv = {}) {
|
|
185
189
|
const { value, corrupted, backupPath } = safeReadJson(settingsPath);
|
|
186
190
|
if (corrupted) {
|
|
187
191
|
console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped MCP registration for Claude.`);
|
|
@@ -198,7 +202,7 @@ export function registerMcpInSettingsAt(settingsPath, memtraceBinary) {
|
|
|
198
202
|
const existingEnv = (isRecord(existing) && isRecord(existing.env))
|
|
199
203
|
? existing.env
|
|
200
204
|
: {};
|
|
201
|
-
const mergedEnv = { ...existingEnv, ...MEMTRACE_MCP_ENV };
|
|
205
|
+
const mergedEnv = { ...legacyEnv, ...existingEnv, ...MEMTRACE_MCP_ENV };
|
|
202
206
|
// Hosts defer MCP tool definitions behind ToolSearch by default, so an
|
|
203
207
|
// unmarked server reaches the model as bare tool names while built-in
|
|
204
208
|
// Grep/Glob/Read arrive with full schemas — a routing bias no instruction
|
|
@@ -231,29 +235,12 @@ const LEGACY_HOOK_SCRIPT_BASENAMES = [
|
|
|
231
235
|
'posttool-mcp-telemetry.sh',
|
|
232
236
|
];
|
|
233
237
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* (`POST /api/rail/hook`) with one `curl` — ~5ms of process instead of the
|
|
238
|
-
* full memtrace binary (node shim + native pair, ~59MB alive for ~0.5s) per
|
|
239
|
-
* Grep/Glob/Bash call. Fail-open is structural: no daemon (connection
|
|
240
|
-
* refused, bounded by `--connect-timeout`) or an old daemon without the
|
|
241
|
-
* endpoint (`-f` on 404) produces no output, and `|| true` exits 0 — Claude
|
|
242
|
-
* Code treats that as allow-with-no-decision. The full binary is NEVER the
|
|
243
|
-
* fallback. The agent session's `MEMTRACE_RAIL` / `MEMTRACE_RAW_SEARCH` ride
|
|
244
|
-
* along as query params, so per-session mode/escape semantics are identical
|
|
245
|
-
* to the spawn era; `MEMTRACE_RAIL_SEARCH_URL` / `MEMTRACE_UI_PORT` override
|
|
246
|
-
* the endpoint the same way they steered the binary's own daemon lookup.
|
|
247
|
-
*
|
|
248
|
-
* Windows keeps the gen-1 binary spawn: hook commands there do not run under
|
|
249
|
-
* a POSIX shell, so the env-forwarding one-liner has no portable equivalent.
|
|
238
|
+
* Every host enters a local liveness guard before contacting Memtrace. The
|
|
239
|
+
* daemon lifecycle registers/removes this command; a cached registration is
|
|
240
|
+
* silent after a stop or crash. Windows uses exec-form command/args below.
|
|
250
241
|
*/
|
|
251
|
-
export function claudeRailHookCommand(
|
|
252
|
-
|
|
253
|
-
return `${memtraceBinary} route --hook`;
|
|
254
|
-
const endpoint = '${MEMTRACE_RAIL_SEARCH_URL:-http://127.0.0.1:${MEMTRACE_UI_PORT:-3030}}';
|
|
255
|
-
const query = 'host=claude-code&mode=${MEMTRACE_RAIL:-}&escape=${MEMTRACE_RAW_SEARCH:-}';
|
|
256
|
-
return `curl -sf --connect-timeout 0.05 --max-time 2 --data-binary @- "${endpoint}/api/rail/hook?${query}" 2>/dev/null || true`;
|
|
242
|
+
export function claudeRailHookCommand(_memtraceBinary, platform = process.platform) {
|
|
243
|
+
return railHookCommand('claude-code', platform);
|
|
257
244
|
}
|
|
258
245
|
/**
|
|
259
246
|
* Register the Memtrace Rail PreToolUse hook in a Claude settings.json.
|
|
@@ -261,7 +248,7 @@ export function claudeRailHookCommand(memtraceBinary, platform = process.platfor
|
|
|
261
248
|
* This is what makes Rail "part of memtrace" rather than a manual install:
|
|
262
249
|
* `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
|
|
263
250
|
* attempt through the Rail router (see {@link claudeRailHookCommand} for the
|
|
264
|
-
* transport).
|
|
251
|
+
* transport). While a daemon is running the hook defaults to **observe** —
|
|
265
252
|
* `MEMTRACE_RAIL=nudge|rail|strict` opts into more. Mode is intentionally NOT
|
|
266
253
|
* baked into the command so the user can change it via env without
|
|
267
254
|
* re-installing.
|
|
@@ -271,6 +258,10 @@ export function claudeRailHookCommand(memtraceBinary, platform = process.platfor
|
|
|
271
258
|
* a malformed file.
|
|
272
259
|
*/
|
|
273
260
|
export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platform = process.platform) {
|
|
261
|
+
if (!railIsActive()) {
|
|
262
|
+
removeRailHookFromSettingsAt(settingsPath);
|
|
263
|
+
return { registered: false };
|
|
264
|
+
}
|
|
274
265
|
const { value, corrupted, backupPath } = safeReadJson(settingsPath);
|
|
275
266
|
if (corrupted) {
|
|
276
267
|
console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook registration.`);
|
|
@@ -289,7 +280,9 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
|
|
|
289
280
|
hooks: [
|
|
290
281
|
{
|
|
291
282
|
type: 'command',
|
|
292
|
-
|
|
283
|
+
...(platform === 'win32'
|
|
284
|
+
? railHookInvocation('claude-code')
|
|
285
|
+
: { command: claudeRailHookCommand(memtraceBinary, platform) }),
|
|
293
286
|
},
|
|
294
287
|
],
|
|
295
288
|
});
|
|
@@ -299,7 +292,7 @@ export function registerRailHookInSettingsAt(settingsPath, memtraceBinary, platf
|
|
|
299
292
|
}
|
|
300
293
|
/** True when a PreToolUse entry is a memtrace-owned Rail hook (any generation). */
|
|
301
294
|
function isRailHookEntry(entry) {
|
|
302
|
-
return hookCommands(entry).some((command) => RAIL_HOOK_MARKERS.some((marker) => command.includes(marker)));
|
|
295
|
+
return hookCommands(entry).some((command) => command.includes(RAIL_GUARD_MARKER) || RAIL_HOOK_MARKERS.some((marker) => command.includes(marker)));
|
|
303
296
|
}
|
|
304
297
|
function isLegacyMemtraceHookEntry(entry) {
|
|
305
298
|
return hookCommands(entry).some((command) => (LEGACY_HOOK_SCRIPT_BASENAMES.some((basename) => command.includes(basename))));
|
|
@@ -317,7 +310,8 @@ function hookCommands(entry) {
|
|
|
317
310
|
if (Array.isArray(entry.hooks)) {
|
|
318
311
|
for (const hook of entry.hooks) {
|
|
319
312
|
if (isRecord(hook) && typeof hook.command === 'string') {
|
|
320
|
-
|
|
313
|
+
const args = Array.isArray(hook.args) ? hook.args.filter((arg) => typeof arg === 'string') : [];
|
|
314
|
+
commands.push([hook.command, ...args].join(' '));
|
|
321
315
|
}
|
|
322
316
|
}
|
|
323
317
|
}
|
|
@@ -329,11 +323,23 @@ function pruneMemtraceOwnedHookEntries(hooks) {
|
|
|
329
323
|
const entries = hooks[key];
|
|
330
324
|
if (!Array.isArray(entries))
|
|
331
325
|
continue;
|
|
332
|
-
const preserved = entries.
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
326
|
+
const preserved = entries.flatMap((entry) => {
|
|
327
|
+
if (isRecord(entry) && Array.isArray(entry.hooks)) {
|
|
328
|
+
const remaining = entry.hooks.filter(hook => !isMemtraceOwnedHookEntry({ hooks: [hook] }));
|
|
329
|
+
if (remaining.length === entry.hooks.length)
|
|
330
|
+
return [entry];
|
|
331
|
+
changed = true;
|
|
332
|
+
return remaining.length ? [{ ...entry, hooks: remaining }] : [];
|
|
333
|
+
}
|
|
334
|
+
if (!isMemtraceOwnedHookEntry(entry))
|
|
335
|
+
return [entry];
|
|
336
|
+
changed = true;
|
|
337
|
+
return [];
|
|
338
|
+
});
|
|
339
|
+
if (preserved.length === 0)
|
|
340
|
+
delete hooks[key];
|
|
341
|
+
else
|
|
342
|
+
hooks[key] = preserved;
|
|
337
343
|
}
|
|
338
344
|
return changed;
|
|
339
345
|
}
|
|
@@ -354,19 +360,13 @@ export function removeRailHookFromSettingsAt(settingsPath) {
|
|
|
354
360
|
if (!value)
|
|
355
361
|
return { changed: false };
|
|
356
362
|
const settings = value;
|
|
357
|
-
if (!isRecord(settings.hooks)
|
|
363
|
+
if (!isRecord(settings.hooks)) {
|
|
358
364
|
return { changed: false };
|
|
359
365
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
366
|
+
// Retire legacy advisory/telemetry hooks too: they must not wake an agent
|
|
367
|
+
// while Memtrace is stopped, and the running Rail integration replaces them.
|
|
368
|
+
if (!pruneMemtraceOwnedHookEntries(settings.hooks))
|
|
363
369
|
return { changed: false };
|
|
364
|
-
if (after.length === 0) {
|
|
365
|
-
delete settings.hooks.PreToolUse;
|
|
366
|
-
}
|
|
367
|
-
else {
|
|
368
|
-
settings.hooks.PreToolUse = after;
|
|
369
|
-
}
|
|
370
370
|
if (Object.keys(settings.hooks).length === 0)
|
|
371
371
|
delete settings.hooks;
|
|
372
372
|
writeJsonAtomic(settingsPath, settings);
|
|
@@ -482,12 +482,12 @@ function removeClaudeInstalledPluginMetadata() {
|
|
|
482
482
|
* Register the memtrace MCP server in Claude Code's settings.
|
|
483
483
|
* This adds the MCP server config so Claude Code can connect to memtrace tools.
|
|
484
484
|
*/
|
|
485
|
-
async function registerMcpServer(memtraceBinaryPath) {
|
|
485
|
+
export async function registerMcpServer(memtraceBinaryPath) {
|
|
486
486
|
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
|
|
487
|
-
// ─── memtrace-rail ───
|
|
487
|
+
// ─── memtrace-rail ─── Reconcile the daemon-owned discovery hook so it
|
|
488
488
|
// ships as part of `memtrace install` (not a manual step). Hooks live in
|
|
489
489
|
// settings.json regardless of which MCP-registration strategy wins, and the
|
|
490
|
-
// hook
|
|
490
|
+
// hook is absent while stopped, and observes while active until the user opts
|
|
491
491
|
// into MEMTRACE_RAIL=nudge|rail|strict. Failure here must not block MCP
|
|
492
492
|
// registration.
|
|
493
493
|
// `memtrace install --no-hooks` (memtrace-public #25) propagates here via
|
|
@@ -500,20 +500,18 @@ async function registerMcpServer(memtraceBinaryPath) {
|
|
|
500
500
|
console.warn(`memtrace: failed to register Rail hook: ${e.message}`);
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
|
+
else {
|
|
504
|
+
removeRailHookFromSettingsAt(settingsFile);
|
|
505
|
+
}
|
|
503
506
|
// Strategy 1 (preferred): claude CLI's own add-json path
|
|
504
507
|
const viaCli = await tryMcpAddJson(memtraceBinaryPath);
|
|
505
508
|
if (viaCli)
|
|
506
509
|
return;
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
// settings.json — skills loaded, every `mcp__memtrace__*` tool was absent,
|
|
513
|
-
// and `memtrace doctor` still reported the registration as healthy. Write
|
|
514
|
-
// the file the CLI actually reads, and keep settings.json for older
|
|
515
|
-
// tooling that still looks there.
|
|
516
|
-
registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath);
|
|
510
|
+
// Desktop-only installs have no `claude` CLI. Claude Code reads user MCP
|
|
511
|
+
// registrations from ~/.claude.json, not ~/.claude/settings.json (hooks).
|
|
512
|
+
// Migrate legacy env values without overriding the authoritative user entry.
|
|
513
|
+
registerMcpInSettingsAt(claudeUserConfigFile(), memtraceBinaryPath, readExistingMemtraceEnv(settingsFile));
|
|
514
|
+
// Retain staging's mirror for older tooling after migrating the user entry.
|
|
517
515
|
registerMcpInSettingsAt(settingsFile, memtraceBinaryPath);
|
|
518
516
|
}
|
|
519
517
|
/** `~/.claude.json` — where Claude Code stores user-scope `mcpServers`. */
|
|
@@ -613,13 +611,17 @@ export async function uninstallClaudePlugin() {
|
|
|
613
611
|
if (fs.existsSync(cacheRoot)) {
|
|
614
612
|
fs.rmSync(cacheRoot, { recursive: true });
|
|
615
613
|
}
|
|
616
|
-
//
|
|
617
|
-
const settingsFile
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
614
|
+
// Hooks/plugin settings and user MCP registrations are separate files.
|
|
615
|
+
for (const settingsFile of [
|
|
616
|
+
path.join(os.homedir(), '.claude', 'settings.json'),
|
|
617
|
+
path.join(os.homedir(), '.claude.json'),
|
|
618
|
+
]) {
|
|
619
|
+
if (fs.existsSync(settingsFile)) {
|
|
620
|
+
try {
|
|
621
|
+
removeClaudeSettingsEntriesAt(settingsFile);
|
|
622
|
+
}
|
|
623
|
+
catch { /* */ }
|
|
621
624
|
}
|
|
622
|
-
catch { /* */ }
|
|
623
625
|
}
|
|
624
626
|
removeClaudeMarketplaceCacheDirs();
|
|
625
627
|
removeClaudeInstalledPluginMetadata();
|
|
@@ -676,7 +678,7 @@ export const claudeTransformer = {
|
|
|
676
678
|
agent: 'claude',
|
|
677
679
|
skillsWritten: skillCount,
|
|
678
680
|
skillsDir: path.join(os.homedir(), '.claude', 'skills'),
|
|
679
|
-
mcpConfigPath: path.join(os.homedir(), '.claude
|
|
681
|
+
mcpConfigPath: path.join(os.homedir(), '.claude.json'),
|
|
680
682
|
mcpRegistered: !ctx.skipMcp,
|
|
681
683
|
warnings: [],
|
|
682
684
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import { railIsActive } from '../rail-runtime.js';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { registerOpenCodeMcpAt, removeMemtraceSkills, removeOpenCodeMcpAt, writeSkills, } from './shared.js';
|
|
4
5
|
import { openCodeConfigDir, openCodeRailPluginPaths, openCodePluginSource, } from './rail-hooks.js';
|
|
@@ -32,6 +33,10 @@ export const opencodeTransformer = {
|
|
|
32
33
|
try {
|
|
33
34
|
const src = openCodePluginSource(ctx.memtraceBinary);
|
|
34
35
|
for (const pluginPath of openCodeRailPluginPaths(ctx)) {
|
|
36
|
+
if (!railIsActive()) {
|
|
37
|
+
fs.rmSync(pluginPath, { force: true });
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
35
40
|
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
36
41
|
fs.writeFileSync(pluginPath, src);
|
|
37
42
|
}
|
|
@@ -4,7 +4,7 @@ export declare const RAIL_HOOK_MARKER = "route --hook";
|
|
|
4
4
|
export declare const CLAUDE_MATCHER = "Grep|Glob|Bash";
|
|
5
5
|
export declare const CODEX_MATCHER = "Bash";
|
|
6
6
|
export declare const GEMINI_MATCHER = "run_shell_command|read_file|read_many_files|glob|search_file_content";
|
|
7
|
-
export declare function railCommand(
|
|
7
|
+
export declare function railCommand(_binary: string, host: string): string;
|
|
8
8
|
export interface OpenCodePathContext {
|
|
9
9
|
scope: 'global' | 'local';
|
|
10
10
|
cwd: string;
|
|
@@ -19,14 +19,15 @@ import fs from 'fs';
|
|
|
19
19
|
import os from 'os';
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import { safeReadJson, writeJsonAtomic } from '../fs-safe.js';
|
|
22
|
+
import { railIsActive, railHookCommand, railHookInvocation, isRailCommand } from '../rail-runtime.js';
|
|
22
23
|
/** Substring identifying a memtrace-owned Rail hook entry. */
|
|
23
24
|
export const RAIL_HOOK_MARKER = 'route --hook';
|
|
24
25
|
/** The discovery tools each host surfaces. */
|
|
25
26
|
export const CLAUDE_MATCHER = 'Grep|Glob|Bash';
|
|
26
27
|
export const CODEX_MATCHER = 'Bash';
|
|
27
28
|
export const GEMINI_MATCHER = 'run_shell_command|read_file|read_many_files|glob|search_file_content';
|
|
28
|
-
export function railCommand(
|
|
29
|
-
return
|
|
29
|
+
export function railCommand(_binary, host) {
|
|
30
|
+
return railHookCommand(host);
|
|
30
31
|
}
|
|
31
32
|
function trimmed(value) {
|
|
32
33
|
const clean = value?.trim();
|
|
@@ -84,7 +85,7 @@ function isRecord(v) {
|
|
|
84
85
|
export function isRailHookEntry(entry) {
|
|
85
86
|
if (!isRecord(entry) || !Array.isArray(entry.hooks))
|
|
86
87
|
return false;
|
|
87
|
-
return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && h.command
|
|
88
|
+
return entry.hooks.some((h) => isRecord(h) && typeof h.command === 'string' && isRailCommand(h.command));
|
|
88
89
|
}
|
|
89
90
|
/**
|
|
90
91
|
* Register a Claude-style settings.json hook (Codex `PreToolUse`, Gemini
|
|
@@ -92,6 +93,10 @@ export function isRailHookEntry(entry) {
|
|
|
92
93
|
* malformed file.
|
|
93
94
|
*/
|
|
94
95
|
export function registerSettingsHook(settingsPath, binary, host, eventName, matcher, extraSettings) {
|
|
96
|
+
if (!railIsActive()) {
|
|
97
|
+
removeSettingsHook(settingsPath, eventName);
|
|
98
|
+
return { registered: false };
|
|
99
|
+
}
|
|
95
100
|
const { value, corrupted, backupPath } = safeReadJson(settingsPath);
|
|
96
101
|
if (corrupted) {
|
|
97
102
|
console.warn(`memtrace: ${settingsPath} is malformed; backed up to ${backupPath}. Skipped Rail hook.`);
|
|
@@ -150,6 +155,10 @@ export function removeSettingsHook(settingsPath, eventName) {
|
|
|
150
155
|
* both shell execution (where rg/grep/find run) and file reads.
|
|
151
156
|
*/
|
|
152
157
|
export function registerCursorRailHook(hooksPath, binary) {
|
|
158
|
+
if (!railIsActive()) {
|
|
159
|
+
removeCursorRailHook(hooksPath);
|
|
160
|
+
return { registered: false };
|
|
161
|
+
}
|
|
153
162
|
const { value, corrupted, backupPath } = safeReadJson(hooksPath);
|
|
154
163
|
if (corrupted) {
|
|
155
164
|
console.warn(`memtrace: ${hooksPath} is malformed; backed up to ${backupPath}. Skipped Cursor Rail hook.`);
|
|
@@ -164,7 +173,7 @@ export function registerCursorRailHook(hooksPath, binary) {
|
|
|
164
173
|
// hooking it would spawn a router subprocess on every file read for no benefit.
|
|
165
174
|
const event = 'beforeShellExecution';
|
|
166
175
|
const existing = Array.isArray(cfg.hooks[event]) ? cfg.hooks[event] : [];
|
|
167
|
-
const preserved = existing.filter((e) => !(isRecord(e) && typeof e.command === 'string' && e.command
|
|
176
|
+
const preserved = existing.filter((e) => !(isRecord(e) && typeof e.command === 'string' && isRailCommand(e.command)));
|
|
168
177
|
preserved.push({ command: cmd });
|
|
169
178
|
cfg.hooks[event] = preserved;
|
|
170
179
|
writeJsonAtomic(hooksPath, cfg);
|
|
@@ -180,6 +189,10 @@ export function windsurfHooksPath() {
|
|
|
180
189
|
* `show_output: true` so nudge context on stdout is visible in Cascade.
|
|
181
190
|
*/
|
|
182
191
|
export function registerWindsurfRailHook(hooksPath, binary) {
|
|
192
|
+
if (!railIsActive()) {
|
|
193
|
+
removeWindsurfRailHook(hooksPath);
|
|
194
|
+
return { registered: false };
|
|
195
|
+
}
|
|
183
196
|
const { value, corrupted, backupPath } = safeReadJson(hooksPath);
|
|
184
197
|
if (corrupted) {
|
|
185
198
|
console.warn(`memtrace: ${hooksPath} is malformed; backed up to ${backupPath}. Skipped Windsurf Rail hook.`);
|
|
@@ -192,7 +205,7 @@ export function registerWindsurfRailHook(hooksPath, binary) {
|
|
|
192
205
|
const existing = Array.isArray(cfg.hooks[event]) ? cfg.hooks[event] : [];
|
|
193
206
|
const preserved = existing.filter((e) => !(isRecord(e) &&
|
|
194
207
|
typeof e.command === 'string' &&
|
|
195
|
-
e.command
|
|
208
|
+
isRailCommand(e.command)));
|
|
196
209
|
preserved.push({ command: cmd, show_output: true });
|
|
197
210
|
cfg.hooks[event] = preserved;
|
|
198
211
|
writeJsonAtomic(hooksPath, cfg);
|
|
@@ -211,7 +224,7 @@ export function removeWindsurfRailHook(hooksPath) {
|
|
|
211
224
|
const before = cfg.hooks.pre_run_command;
|
|
212
225
|
const after = before.filter((e) => !(isRecord(e) &&
|
|
213
226
|
typeof e.command === 'string' &&
|
|
214
|
-
e.command
|
|
227
|
+
isRailCommand(e.command)));
|
|
215
228
|
if (after.length === before.length)
|
|
216
229
|
return { changed: false };
|
|
217
230
|
if (after.length === 0)
|
|
@@ -232,6 +245,10 @@ export function vscodeCopilotRailHookPath() {
|
|
|
232
245
|
* Writes a dedicated file under `~/.copilot/hooks/` (loaded by default in recent VS Code).
|
|
233
246
|
*/
|
|
234
247
|
export function registerVsCodeRailHook(hookFilePath, binary) {
|
|
248
|
+
if (!railIsActive()) {
|
|
249
|
+
removeVsCodeRailHook(hookFilePath);
|
|
250
|
+
return { registered: false };
|
|
251
|
+
}
|
|
235
252
|
const { value, corrupted, backupPath } = safeReadJson(hookFilePath);
|
|
236
253
|
if (corrupted) {
|
|
237
254
|
console.warn(`memtrace: ${hookFilePath} is malformed; backed up to ${backupPath}. Skipped VS Code Rail hook.`);
|
|
@@ -242,7 +259,7 @@ export function registerVsCodeRailHook(hookFilePath, binary) {
|
|
|
242
259
|
const pre = Array.isArray(existing.hooks?.PreToolUse) ? existing.hooks.PreToolUse : [];
|
|
243
260
|
const preserved = pre.filter((e) => !(isRecord(e) &&
|
|
244
261
|
typeof e.command === 'string' &&
|
|
245
|
-
e.command
|
|
262
|
+
isRailCommand(e.command)));
|
|
246
263
|
preserved.push({ type: 'command', command: cmd, timeout: 30 });
|
|
247
264
|
writeJsonAtomic(hookFilePath, {
|
|
248
265
|
hooks: {
|
|
@@ -263,7 +280,7 @@ export function removeVsCodeRailHook(hookFilePath) {
|
|
|
263
280
|
const before = cfg.hooks.PreToolUse;
|
|
264
281
|
const after = before.filter((e) => !(isRecord(e) &&
|
|
265
282
|
typeof e.command === 'string' &&
|
|
266
|
-
e.command
|
|
283
|
+
isRailCommand(e.command)));
|
|
267
284
|
if (after.length === before.length)
|
|
268
285
|
return { changed: false };
|
|
269
286
|
if (after.length === 0) {
|
|
@@ -292,7 +309,7 @@ export function removeCursorRailHook(hooksPath) {
|
|
|
292
309
|
if (!Array.isArray(cfg.hooks[event]))
|
|
293
310
|
continue;
|
|
294
311
|
const before = cfg.hooks[event];
|
|
295
|
-
const after = before.filter((e) => !(isRecord(e) && typeof e.command === 'string' && e.command
|
|
312
|
+
const after = before.filter((e) => !(isRecord(e) && typeof e.command === 'string' && isRailCommand(e.command)));
|
|
296
313
|
if (after.length !== before.length) {
|
|
297
314
|
changed = true;
|
|
298
315
|
if (after.length === 0)
|
|
@@ -316,12 +333,14 @@ export function removeCursorRailHook(hooksPath) {
|
|
|
316
333
|
* the Memtrace result as the error the agent sees.
|
|
317
334
|
*/
|
|
318
335
|
export function openCodePluginSource(binary) {
|
|
336
|
+
const hook = railHookInvocation('open-code');
|
|
319
337
|
return `// AUTO-GENERATED by memtrace install — Memtrace Rail discovery hook.
|
|
320
338
|
// Routes OpenCode tool calls through \`${binary} route\`. Defaults to observe
|
|
321
339
|
// (no-op) unless MEMTRACE_RAIL=nudge|rail|strict. Remove via 'memtrace uninstall'.
|
|
322
340
|
import { execFileSync } from 'node:child_process';
|
|
323
341
|
|
|
324
|
-
const BIN = ${JSON.stringify(
|
|
342
|
+
const BIN = ${JSON.stringify(hook.command)};
|
|
343
|
+
const ARGS = ${JSON.stringify([...hook.args, '--json'])};
|
|
325
344
|
|
|
326
345
|
export const MemtraceRail = async ({ project, client, $ }) => ({
|
|
327
346
|
'tool.execute.before': async (input, output) => {
|
|
@@ -335,8 +354,8 @@ export const MemtraceRail = async ({ project, client, $ }) => ({
|
|
|
335
354
|
input: output?.args || {},
|
|
336
355
|
session_id: input?.sessionID || 'opencode',
|
|
337
356
|
};
|
|
338
|
-
const out = execFileSync(BIN,
|
|
339
|
-
input: JSON.stringify(attempt), timeout:
|
|
357
|
+
const out = execFileSync(BIN, ARGS, {
|
|
358
|
+
input: JSON.stringify(attempt), timeout: 6000, encoding: 'utf8', windowsHide: true,
|
|
340
359
|
});
|
|
341
360
|
const decision = JSON.parse(out);
|
|
342
361
|
if (decision.action === 'suppress_raw_with_result') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memtrace-skills",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Memtrace skills for AI coding agents — codebase exploration, temporal evolution, impact analysis, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"prebuild": "node scripts/copy-skills.js",
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"prepublishOnly": "npm run build",
|
|
20
|
-
"test": "vitest run",
|
|
20
|
+
"test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs",
|
|
21
21
|
"test:watch": "vitest"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|