memtrace-skills 1.2.9 → 1.2.11
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/managed-skills.d.ts +4 -0
- package/dist/managed-skills.js +95 -0
- package/dist/rail-hook.d.ts +7 -1
- package/dist/rail-hook.js +177 -57
- package/dist/rail-install.js +3 -6
- package/dist/rail-lifecycle.js +6 -3
- package/dist/rail-runtime.d.ts +38 -3
- package/dist/rail-runtime.js +88 -7
- package/dist/tool-names.js +6 -0
- package/dist/transformers/claude.d.ts +16 -4
- package/dist/transformers/claude.js +69 -82
- package/dist/transformers/codex.js +5 -20
- package/dist/transformers/cursor.js +5 -20
- package/dist/transformers/kiro.js +5 -14
- package/dist/transformers/opencode.js +6 -6
- package/dist/transformers/pi.js +5 -5
- package/dist/transformers/rail-hooks.d.ts +6 -1
- package/dist/transformers/rail-hooks.js +72 -13
- package/dist/transformers/shared.d.ts +2 -1
- package/dist/transformers/shared.js +28 -23
- package/dist/transformers/vscode.js +3 -1
- package/package.json +2 -2
- package/plugins/memtrace-skills/references/mcp-parameters.md +14 -3
- package/plugins/memtrace-skills/skills/memtrace-codebase-exploration/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-daily/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-first/SKILL.md +55 -24
- package/plugins/memtrace-skills/skills/memtrace-graph/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-guided-refactor/SKILL.md +88 -0
- package/plugins/memtrace-skills/skills/memtrace-impact/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-index/SKILL.md +21 -0
- package/plugins/memtrace-skills/skills/memtrace-preflight/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-quality/SKILL.md +16 -2
- package/plugins/memtrace-skills/skills/memtrace-refactoring-guide/SKILL.md +4 -0
- package/plugins/memtrace-skills/skills/memtrace-search/SKILL.md +1 -0
- package/plugins/memtrace-skills/skills/memtrace-session-continuity/SKILL.md +4 -0
- package/skills/commands/memtrace-daily.md +4 -0
- package/skills/commands/memtrace-graph.md +4 -0
- package/skills/commands/memtrace-impact.md +4 -0
- package/skills/commands/memtrace-index.md +22 -0
- package/skills/commands/memtrace-preflight.md +4 -0
- package/skills/commands/memtrace-quality.md +19 -2
- package/skills/commands/memtrace-search.md +1 -0
- package/skills/managed-stock-sha256.txt +364 -0
- package/skills/workflows/memtrace-codebase-exploration.md +4 -0
- package/skills/workflows/memtrace-first.md +55 -24
- package/skills/workflows/memtrace-guided-refactor.md +89 -0
- package/skills/workflows/memtrace-refactoring-guide.md +4 -0
- package/skills/workflows/memtrace-session-continuity.md +4 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const RECEIPT_SUFFIX = ".memtrace-managed.sha256";
|
|
2
|
+
export declare function skillDigest(content: string): string;
|
|
3
|
+
export declare function writeManagedSkill(rootDir: string, name: string, content: string, steering?: boolean): boolean;
|
|
4
|
+
export declare function removeManagedSkills(rootDir: string, steering?: boolean): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
// A name is not ownership. Receipts bind the exact last-installed bytes; the
|
|
6
|
+
// stock catalogue admits upgrades from installers that predate receipts.
|
|
7
|
+
const stockFile = fileURLToPath(new URL('../skills/managed-stock-sha256.txt', import.meta.url));
|
|
8
|
+
const stock = new Set(fs.readFileSync(stockFile, 'utf8').split(/\r?\n/).filter(line => !line.startsWith('#')));
|
|
9
|
+
export const RECEIPT_SUFFIX = '.memtrace-managed.sha256';
|
|
10
|
+
export function skillDigest(content) {
|
|
11
|
+
return createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
function safeFile(rootDir, relative) {
|
|
14
|
+
const parts = relative.split(/[\\/]/);
|
|
15
|
+
if (parts.some(part => !part || part === '.' || part === '..'))
|
|
16
|
+
return;
|
|
17
|
+
let current = path.resolve(rootDir);
|
|
18
|
+
if (fs.existsSync(current) && (!fs.lstatSync(current).isDirectory() || fs.lstatSync(current).isSymbolicLink()))
|
|
19
|
+
return;
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
current = path.join(current, part);
|
|
22
|
+
try {
|
|
23
|
+
const stat = fs.lstatSync(current);
|
|
24
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && current !== path.resolve(rootDir, relative)))
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== 'ENOENT')
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const receipt = fs.lstatSync(current + RECEIPT_SUFFIX);
|
|
34
|
+
if (!receipt.isFile() || receipt.isSymbolicLink())
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'ENOENT')
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return current;
|
|
42
|
+
}
|
|
43
|
+
function owned(file, name, content) {
|
|
44
|
+
const digest = skillDigest(content);
|
|
45
|
+
if (stock.has(`${name} ${digest}`))
|
|
46
|
+
return true;
|
|
47
|
+
try {
|
|
48
|
+
return fs.readFileSync(file + RECEIPT_SUFFIX, 'utf8').trim() === digest;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code !== 'ENOENT')
|
|
52
|
+
throw error;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function writeManagedSkill(rootDir, name, content, steering = false) {
|
|
57
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
|
|
58
|
+
throw new Error(`Invalid managed skill name: ${name}`);
|
|
59
|
+
const file = safeFile(rootDir, steering ? `${name}.md` : `${name}/SKILL.md`);
|
|
60
|
+
if (!file)
|
|
61
|
+
return false;
|
|
62
|
+
if (fs.existsSync(file)) {
|
|
63
|
+
if (!fs.lstatSync(file).isFile())
|
|
64
|
+
return false;
|
|
65
|
+
const prior = fs.readFileSync(file, 'utf8');
|
|
66
|
+
if (prior !== content && !owned(file, name, prior)) {
|
|
67
|
+
console.warn(`memtrace: preserved customized skill ${file}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
72
|
+
fs.writeFileSync(file, content);
|
|
73
|
+
fs.writeFileSync(file + RECEIPT_SUFFIX, `${skillDigest(content)}\n`);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
export function removeManagedSkills(rootDir, steering = false) {
|
|
77
|
+
if (!fs.existsSync(rootDir) || fs.lstatSync(rootDir).isSymbolicLink())
|
|
78
|
+
return;
|
|
79
|
+
for (const entry of fs.readdirSync(rootDir)) {
|
|
80
|
+
const name = steering ? entry.replace(/\.md$/, '') : entry;
|
|
81
|
+
if (!/^memtrace-[a-z0-9-]+$/.test(name) || (steering && !entry.endsWith('.md')))
|
|
82
|
+
continue;
|
|
83
|
+
const file = safeFile(rootDir, steering ? entry : `${entry}/SKILL.md`);
|
|
84
|
+
if (!file || !fs.existsSync(file) || !fs.lstatSync(file).isFile())
|
|
85
|
+
continue;
|
|
86
|
+
if (!owned(file, name, fs.readFileSync(file, 'utf8')))
|
|
87
|
+
continue;
|
|
88
|
+
fs.unlinkSync(file);
|
|
89
|
+
if (fs.existsSync(file + RECEIPT_SUFFIX))
|
|
90
|
+
fs.unlinkSync(file + RECEIPT_SUFFIX);
|
|
91
|
+
// Never recursively delete a skill folder: user references/assets survive.
|
|
92
|
+
if (!steering && fs.readdirSync(path.dirname(file)).length === 0)
|
|
93
|
+
fs.rmdirSync(path.dirname(file));
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/rail-hook.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Post-phase gate, before any lease read or network call (design 4.1): only a
|
|
3
|
+
* native Grep, or a shell command that runs a search program and did not
|
|
4
|
+
* fail, is worth one loopback POST. Glob, Read and every other shell command
|
|
5
|
+
* exit here.
|
|
6
|
+
*/
|
|
7
|
+
export declare function enrichEligible(payload: any): boolean;
|
package/dist/rail-hook.js
CHANGED
|
@@ -1,77 +1,187 @@
|
|
|
1
1
|
/** Cached host hooks stop here when Memtrace is absent. No output and no network. */
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
3
4
|
import http from 'node:http';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { activeRailRuntimes, enrichEnabled, pidAlive, runtimeForCwd, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
|
|
9
|
+
const argv = process.argv;
|
|
10
|
+
function arg(name) {
|
|
11
|
+
const index = argv.indexOf(name);
|
|
12
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
13
|
+
}
|
|
14
|
+
function off(value) {
|
|
15
|
+
return value?.trim().toLowerCase() === 'off';
|
|
16
|
+
}
|
|
17
|
+
const SEARCH_TOOLS = new Set(['grep', 'search_file_content', 'grep_search']);
|
|
18
|
+
const SHELL_TOOLS = new Set(['bash', 'powershell', 'shell', 'run_shell_command', 'run_terminal_cmd', 'execute_command', 'run_in_terminal']);
|
|
19
|
+
/**
|
|
20
|
+
* Post-phase gate, before any lease read or network call (design 4.1): only a
|
|
21
|
+
* native Grep, or a shell command that runs a search program and did not
|
|
22
|
+
* fail, is worth one loopback POST. Glob, Read and every other shell command
|
|
23
|
+
* exit here.
|
|
24
|
+
*/
|
|
25
|
+
export function enrichEligible(payload) {
|
|
26
|
+
if (!payload || typeof payload !== 'object')
|
|
27
|
+
return false;
|
|
28
|
+
const tool = String(payload.tool_name ?? '').toLowerCase();
|
|
29
|
+
const input = payload.tool_input ?? {};
|
|
30
|
+
if (SEARCH_TOOLS.has(tool))
|
|
31
|
+
return true;
|
|
32
|
+
const command = typeof input.command === 'string' ? input.command
|
|
33
|
+
: (!tool && typeof payload.command === 'string' ? payload.command : undefined);
|
|
34
|
+
if (!(SHELL_TOOLS.has(tool) || (!tool && command !== undefined)))
|
|
35
|
+
return false;
|
|
36
|
+
if (typeof command !== 'string' || !SEARCH_COMMAND_RE.test(command))
|
|
37
|
+
return false;
|
|
38
|
+
// A failed command (exit >= 2, nothing printed) is not a search result.
|
|
39
|
+
// grep's exit 1 with no output is a real zero-match and stays eligible.
|
|
40
|
+
const response = payload.tool_response ?? payload.tool_output ?? {};
|
|
41
|
+
if (response && typeof response === 'object') {
|
|
42
|
+
const code = Number(response.exit_code ?? response.exitCode ?? response.returnCode);
|
|
43
|
+
const stdout = response.stdout ?? response.output ?? response.llmContent ?? '';
|
|
44
|
+
if (Number.isFinite(code) && code >= 2 && String(stdout).trim() === '')
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
function ledgerDirectory() {
|
|
50
|
+
const home = process.env.MEMTRACE_HOME?.trim();
|
|
51
|
+
return home || path.join(os.homedir(), '.memtrace');
|
|
52
|
+
}
|
|
53
|
+
/** One timing row per enrichment hook call, written after stdout (design 10). */
|
|
54
|
+
function logHookTiming(row) {
|
|
55
|
+
if (process.env.MEMTRACE_TELEMETRY?.trim().toLowerCase() === 'off')
|
|
9
56
|
return;
|
|
57
|
+
try {
|
|
58
|
+
const dir = ledgerDirectory();
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
// One write per line: concurrent hooks never merge records.
|
|
61
|
+
fs.appendFileSync(path.join(dir, 'rail-enrich-hook.jsonl'), JSON.stringify(row) + '\n');
|
|
62
|
+
}
|
|
63
|
+
catch { /* timing is best effort */ }
|
|
64
|
+
}
|
|
65
|
+
/** POST to the daemon; resolves empty on timeout, error or daemon death. */
|
|
66
|
+
function postToDaemon(runtime, requestPath, input, killMs) {
|
|
67
|
+
return new Promise(resolve => {
|
|
68
|
+
let finished = false;
|
|
69
|
+
const finish = (value = { status: 0, body: '', headers: {} }) => {
|
|
70
|
+
if (finished)
|
|
71
|
+
return;
|
|
72
|
+
finished = true;
|
|
73
|
+
clearTimeout(deadline);
|
|
74
|
+
clearInterval(monitor);
|
|
75
|
+
resolve(value);
|
|
76
|
+
};
|
|
77
|
+
const request = http.request({ hostname: '127.0.0.1', port: runtime.port,
|
|
78
|
+
method: 'POST', path: requestPath,
|
|
79
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(input) },
|
|
80
|
+
}, response => {
|
|
81
|
+
let value = '';
|
|
82
|
+
response.on('data', chunk => {
|
|
83
|
+
value += chunk;
|
|
84
|
+
if (value.length > 2_000_000) {
|
|
85
|
+
finish();
|
|
86
|
+
request.destroy();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
response.on('error', () => finish());
|
|
90
|
+
response.on('end', () => finish({ status: response.statusCode ?? 0, body: value, headers: response.headers }));
|
|
91
|
+
});
|
|
92
|
+
const deadline = setTimeout(() => { finish(); request.destroy(); }, killMs);
|
|
93
|
+
const monitor = setInterval(() => {
|
|
94
|
+
if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)) {
|
|
95
|
+
finish();
|
|
96
|
+
request.destroy();
|
|
97
|
+
}
|
|
98
|
+
}, 50);
|
|
99
|
+
request.on('error', () => finish());
|
|
100
|
+
request.end(input);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function readStdin() {
|
|
10
104
|
let input = '';
|
|
11
105
|
for await (const chunk of process.stdin) {
|
|
12
106
|
input += chunk;
|
|
13
107
|
if (input.length > 2_000_000)
|
|
14
|
-
return;
|
|
108
|
+
return undefined;
|
|
15
109
|
}
|
|
110
|
+
return input;
|
|
111
|
+
}
|
|
112
|
+
async function main() {
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
const env = process.env;
|
|
115
|
+
if (off(env.MEMTRACE_RAIL) || env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow')
|
|
116
|
+
return;
|
|
117
|
+
const host = arg('--host') ?? 'generic';
|
|
118
|
+
const phase = arg('--phase');
|
|
119
|
+
const event = arg('--event');
|
|
120
|
+
const enrichPath = phase === 'post' || event === 'precompact';
|
|
121
|
+
// Explicit off (or a non-Nudge mode without opt-in) means no stdin parse,
|
|
122
|
+
// no lease read and no HTTP. Fresh Nudge enables bounded context by default.
|
|
123
|
+
if (enrichPath && !enrichEnabled(env))
|
|
124
|
+
return;
|
|
125
|
+
const input = await readStdin();
|
|
126
|
+
if (input === undefined)
|
|
127
|
+
return;
|
|
16
128
|
const payload = JSON.parse(input);
|
|
129
|
+
if (phase === 'post' && !enrichEligible(payload))
|
|
130
|
+
return;
|
|
17
131
|
const cwd = payload.cwd || payload.workspace_roots?.[0] || process.cwd();
|
|
18
132
|
if (typeof cwd !== 'string')
|
|
19
133
|
return;
|
|
20
|
-
|
|
134
|
+
// Leases are read ONCE per call.
|
|
135
|
+
const runtime = runtimeForCwd(cwd, activeRailRuntimes());
|
|
21
136
|
if (!runtime)
|
|
22
137
|
return;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
138
|
+
const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
|
|
139
|
+
if (event === 'precompact') {
|
|
140
|
+
const session = typeof payload.session_id === 'string' ? payload.session_id : '';
|
|
141
|
+
await postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (phase === 'post') {
|
|
145
|
+
const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
|
|
146
|
+
mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
147
|
+
const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, input, ENRICH_HOOK_KILL_MS);
|
|
148
|
+
let wrote = 0;
|
|
149
|
+
let malformed = false;
|
|
150
|
+
if (result.status === 200 && result.body && alive()) {
|
|
151
|
+
try {
|
|
152
|
+
JSON.parse(result.body); // never emit malformed endpoint output into an agent
|
|
153
|
+
process.stdout.write(result.body);
|
|
154
|
+
wrote = Buffer.byteLength(result.body);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
malformed = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
logHookTiming({
|
|
161
|
+
ts: new Date().toISOString(), host, phase: 'post', tool: payload.tool_name ?? null,
|
|
162
|
+
status: result.status,
|
|
163
|
+
outcome: malformed ? 'error' : result.headers['x-memtrace-enrich-outcome'] ?? (result.status ? null : 'no_daemon'),
|
|
164
|
+
bytes: Number(result.headers['x-memtrace-enrich-bytes'] ?? 0), stdout_bytes: wrote,
|
|
165
|
+
server_ms: Number(result.headers['x-memtrace-enrich-server-ms'] ?? 0), hook_ms: Date.now() - started,
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const jsonMode = argv.includes('--json');
|
|
26
170
|
// JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
|
|
27
171
|
// Other hosts use the in-process daemon router with no native binary spawn.
|
|
28
172
|
if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
|
|
29
|
-
const query = new URLSearchParams({ host, mode:
|
|
30
|
-
escape:
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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);
|
|
173
|
+
const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
|
|
174
|
+
escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
175
|
+
const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, input, 5500);
|
|
176
|
+
if (result.status === 200 && result.body && alive()) {
|
|
177
|
+
JSON.parse(result.body); // never emit malformed endpoint output into an agent
|
|
178
|
+
process.stdout.write(result.body);
|
|
69
179
|
}
|
|
70
180
|
return;
|
|
71
181
|
}
|
|
72
182
|
const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
|
|
73
183
|
cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
74
|
-
env: { ...
|
|
184
|
+
env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
|
|
75
185
|
});
|
|
76
186
|
let output = '';
|
|
77
187
|
let stderr = '';
|
|
@@ -83,17 +193,15 @@ async function main() {
|
|
|
83
193
|
child.stdin.end(input);
|
|
84
194
|
const timer = setTimeout(() => child.kill(), 5500);
|
|
85
195
|
// If the daemon exits while a request is in flight, discard the response.
|
|
86
|
-
const monitor = setInterval(() => {
|
|
87
|
-
|
|
88
|
-
child.kill();
|
|
89
|
-
}, 50);
|
|
196
|
+
const monitor = setInterval(() => { if (!alive())
|
|
197
|
+
child.kill(); }, 50);
|
|
90
198
|
const code = await new Promise(resolve => {
|
|
91
199
|
child.once('error', () => resolve(null));
|
|
92
200
|
child.once('close', resolve);
|
|
93
201
|
});
|
|
94
202
|
clearTimeout(timer);
|
|
95
203
|
clearInterval(monitor);
|
|
96
|
-
if (!
|
|
204
|
+
if (!alive())
|
|
97
205
|
return;
|
|
98
206
|
if (code === 0)
|
|
99
207
|
process.stdout.write(output);
|
|
@@ -102,4 +210,16 @@ async function main() {
|
|
|
102
210
|
process.exitCode = 2;
|
|
103
211
|
}
|
|
104
212
|
}
|
|
105
|
-
|
|
213
|
+
// Only run as a hook process, not when a test imports `enrichEligible`.
|
|
214
|
+
const invokedDirectly = (() => {
|
|
215
|
+
try {
|
|
216
|
+
const self = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
217
|
+
return fs.realpathSync(path.resolve(process.argv[1] ?? '')) === self;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
if (invokedDirectly) {
|
|
224
|
+
main().catch(() => { });
|
|
225
|
+
}
|
package/dist/rail-install.js
CHANGED
|
@@ -9,7 +9,7 @@ import path from 'path';
|
|
|
9
9
|
import { railIsActive } from './rail-runtime.js';
|
|
10
10
|
import { registerRailHookInSettingsAt, removeRailHookFromSettingsAt, } from './transformers/claude.js';
|
|
11
11
|
import { geminiSettingsPath } from './transformers/gemini.js';
|
|
12
|
-
import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, } from './transformers/rail-hooks.js';
|
|
12
|
+
import { registerCursorRailHook, removeCursorRailHook, registerSettingsHook, removeSettingsHook, registerWindsurfRailHook, removeWindsurfRailHook, registerVsCodeRailHook, removeVsCodeRailHook, windsurfHooksPath, vscodeCopilotRailHookPath, CODEX_MATCHER, GEMINI_MATCHER, openCodeRailPluginPaths, openCodePluginSource, writeOpenCodeRailPlugin, } from './transformers/rail-hooks.js';
|
|
13
13
|
/** Hosts with no Rail hook surface yet (MCP/skills only). */
|
|
14
14
|
export const RAIL_UNSUPPORTED = [
|
|
15
15
|
{ host: 'hermes', reason: 'Hermes pre_tool_call adapter not wired yet' },
|
|
@@ -73,14 +73,11 @@ export function installRailHooks(ctx) {
|
|
|
73
73
|
});
|
|
74
74
|
try {
|
|
75
75
|
const src = openCodePluginSource(bin);
|
|
76
|
-
|
|
77
|
-
fs.mkdirSync(path.dirname(pluginPath), { recursive: true });
|
|
78
|
-
fs.writeFileSync(pluginPath, src);
|
|
79
|
-
}
|
|
76
|
+
const pluginPath = writeOpenCodeRailPlugin(ctx, src);
|
|
80
77
|
results.push({
|
|
81
78
|
host: 'opencode',
|
|
82
79
|
registered: true,
|
|
83
|
-
path:
|
|
80
|
+
path: pluginPath,
|
|
84
81
|
});
|
|
85
82
|
}
|
|
86
83
|
catch (e) {
|
package/dist/rail-lifecycle.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { installRailHooks, uninstallRailHooks } from './rail-install.js';
|
|
6
|
-
import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive, LEASE_REFRESH_INTERVAL_MS } from './rail-runtime.js';
|
|
6
|
+
import { runtimeDirectory, railDisabled, activeRailRuntimes, pidAlive, LEASE_REFRESH_INTERVAL_MS, enrichEnabled, railMode } from './rail-runtime.js';
|
|
7
7
|
import { writeJsonAtomic } from './fs-safe.js';
|
|
8
8
|
const arg = (name) => {
|
|
9
9
|
const index = process.argv.indexOf(name);
|
|
@@ -43,13 +43,16 @@ async function main() {
|
|
|
43
43
|
}
|
|
44
44
|
writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
|
|
45
45
|
const enabled = !railDisabled();
|
|
46
|
-
|
|
46
|
+
// Grep enrichment and the Rail mode decide WHICH hooks are registered,
|
|
47
|
+
// so a rail.json change to either re-wires them without a restart.
|
|
48
|
+
const signature = enabled ? `on:${enrichEnabled()}:${railMode()}` : 'off';
|
|
49
|
+
if (signature === wired)
|
|
47
50
|
return;
|
|
48
51
|
if (enabled)
|
|
49
52
|
installRailHooks(ctx);
|
|
50
53
|
else
|
|
51
54
|
uninstallRailHooks(ctx);
|
|
52
|
-
wired =
|
|
55
|
+
wired = signature;
|
|
53
56
|
};
|
|
54
57
|
const stop = () => {
|
|
55
58
|
if (done)
|
package/dist/rail-runtime.d.ts
CHANGED
|
@@ -15,13 +15,48 @@ export declare function runtimeDirectory(): string;
|
|
|
15
15
|
export declare function railDisabled(): boolean;
|
|
16
16
|
export declare function persistHookOptOut(): void;
|
|
17
17
|
export declare function pidAlive(pid: number): boolean;
|
|
18
|
+
/** A lease this old whose owner and guardian are both gone is garbage. */
|
|
19
|
+
export declare const LEASE_PRUNE_AGE_MS = 60000;
|
|
20
|
+
/** Grep enrichment's hook kill (design section 10): the hook never waits longer. */
|
|
21
|
+
export declare const ENRICH_HOOK_KILL_MS = 600;
|
|
22
|
+
/**
|
|
23
|
+
* Cheap pre-network gate for shell commands: only these programs are code
|
|
24
|
+
* searches worth a loopback round trip (design 4.1). `git tag v1` must not
|
|
25
|
+
* match `ag`, so a program must start a word.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SEARCH_COMMAND_RE: RegExp;
|
|
28
|
+
export interface RailConfig {
|
|
29
|
+
mode?: string;
|
|
30
|
+
enrich?: boolean;
|
|
31
|
+
hooksEnabled?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
|
|
34
|
+
export declare function railConfig(): RailConfig;
|
|
35
|
+
/**
|
|
36
|
+
* Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
|
|
37
|
+
* non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
|
|
38
|
+
* not implicitly enable this additional post-search path.
|
|
39
|
+
*/
|
|
40
|
+
export declare function enrichEnabled(env?: NodeJS.ProcessEnv, config?: RailConfig): boolean;
|
|
41
|
+
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
|
|
42
|
+
export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig): string;
|
|
43
|
+
/**
|
|
44
|
+
* Live leases. Reading them also deletes garbage: a lease whose owner and
|
|
45
|
+
* guardian are both dead and whose heartbeat stopped over a minute ago.
|
|
46
|
+
* Every hook call reads every lease, and old daemons used to leave dozens.
|
|
47
|
+
*/
|
|
18
48
|
export declare function activeRailRuntimes(): RailRuntime[];
|
|
19
49
|
export declare function railIsActive(): boolean;
|
|
20
|
-
export declare function runtimeForCwd(cwd: string): RailRuntime | undefined;
|
|
21
|
-
|
|
50
|
+
export declare function runtimeForCwd(cwd: string, runtimes?: RailRuntime[]): RailRuntime | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
53
|
+
* Rail PreToolUse router, `--phase post` for grep enrichment, `--event
|
|
54
|
+
* precompact` for the enrichment dedupe reset.
|
|
55
|
+
*/
|
|
56
|
+
export declare function railHookInvocation(host: string, extra?: string[]): {
|
|
22
57
|
command: string;
|
|
23
58
|
args: string[];
|
|
24
59
|
};
|
|
25
60
|
/** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
|
|
26
|
-
export declare function railHookCommand(host: string, platform?: NodeJS.Platform): string;
|
|
61
|
+
export declare function railHookCommand(host: string, platform?: NodeJS.Platform, extra?: string[]): string;
|
|
27
62
|
export declare function isRailCommand(command: string): boolean;
|
package/dist/rail-runtime.js
CHANGED
|
@@ -54,6 +54,73 @@ export function pidAlive(pid) {
|
|
|
54
54
|
return error.code === 'EPERM';
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
/** A lease this old whose owner and guardian are both gone is garbage. */
|
|
58
|
+
export const LEASE_PRUNE_AGE_MS = 60_000;
|
|
59
|
+
/** Grep enrichment's hook kill (design section 10): the hook never waits longer. */
|
|
60
|
+
export const ENRICH_HOOK_KILL_MS = 600;
|
|
61
|
+
/**
|
|
62
|
+
* Cheap pre-network gate for shell commands: only these programs are code
|
|
63
|
+
* searches worth a loopback round trip (design 4.1). `git tag v1` must not
|
|
64
|
+
* match `ag`, so a program must start a word.
|
|
65
|
+
*/
|
|
66
|
+
export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep)(\s|$)/;
|
|
67
|
+
/** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
|
|
68
|
+
export function railConfig() {
|
|
69
|
+
const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
|
|
70
|
+
|| path.join(os.homedir(), '.memtrace', 'rail.json');
|
|
71
|
+
try {
|
|
72
|
+
const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
73
|
+
return value && typeof value === 'object' ? value : {};
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function flag(value) {
|
|
80
|
+
switch (value?.trim().toLowerCase()) {
|
|
81
|
+
case 'on':
|
|
82
|
+
case '1':
|
|
83
|
+
case 'true':
|
|
84
|
+
case 'yes':
|
|
85
|
+
case 'enabled': return true;
|
|
86
|
+
case 'off':
|
|
87
|
+
case '0':
|
|
88
|
+
case 'false':
|
|
89
|
+
case 'no':
|
|
90
|
+
case 'disabled': return false;
|
|
91
|
+
default: return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
|
|
96
|
+
* non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
|
|
97
|
+
* not implicitly enable this additional post-search path.
|
|
98
|
+
*/
|
|
99
|
+
export function enrichEnabled(env = process.env, config = railConfig()) {
|
|
100
|
+
// Durable opt-outs remain effective even when an old agent inherited an
|
|
101
|
+
// enabling environment. This is the same no-hooks gate used by the lease.
|
|
102
|
+
if (env.MEMTRACE_INSTALL_NO_HOOKS === '1' || config.hooksEnabled === false
|
|
103
|
+
|| (typeof config.mode === 'string' && config.mode.trim().toLowerCase() === 'off')
|
|
104
|
+
|| env.MEMTRACE_RAIL?.trim().toLowerCase() === 'off'
|
|
105
|
+
|| env.MEMTRACE_RAW_SEARCH?.trim().toLowerCase() === 'allow')
|
|
106
|
+
return false;
|
|
107
|
+
return flag(env.MEMTRACE_ENRICH)
|
|
108
|
+
?? (typeof config.enrich === 'boolean' ? config.enrich : railMode(env, config) === 'nudge');
|
|
109
|
+
}
|
|
110
|
+
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
|
|
111
|
+
export function railMode(env = process.env, config = railConfig()) {
|
|
112
|
+
const valid = ['off', 'observe', 'nudge', 'rail', 'strict'];
|
|
113
|
+
const fromEnv = env.MEMTRACE_RAIL?.trim().toLowerCase();
|
|
114
|
+
if (fromEnv && valid.includes(fromEnv))
|
|
115
|
+
return fromEnv;
|
|
116
|
+
const fromConfig = typeof config.mode === 'string' ? config.mode.trim().toLowerCase() : '';
|
|
117
|
+
return valid.includes(fromConfig) ? fromConfig : 'nudge';
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Live leases. Reading them also deletes garbage: a lease whose owner and
|
|
121
|
+
* guardian are both dead and whose heartbeat stopped over a minute ago.
|
|
122
|
+
* Every hook call reads every lease, and old daemons used to leave dozens.
|
|
123
|
+
*/
|
|
57
124
|
export function activeRailRuntimes() {
|
|
58
125
|
if (railDisabled())
|
|
59
126
|
return [];
|
|
@@ -61,8 +128,17 @@ export function activeRailRuntimes() {
|
|
|
61
128
|
const now = Date.now();
|
|
62
129
|
return fs.readdirSync(runtimeDirectory()).filter(name => name.endsWith('.json')).flatMap(name => {
|
|
63
130
|
try {
|
|
64
|
-
const
|
|
131
|
+
const file = path.join(runtimeDirectory(), name);
|
|
132
|
+
const runtime = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
65
133
|
const age = now - runtime.updatedAt;
|
|
134
|
+
if (Number.isFinite(age) && age > LEASE_PRUNE_AGE_MS
|
|
135
|
+
&& !pidAlive(runtime.pid) && !pidAlive(runtime.guardianPid)) {
|
|
136
|
+
try {
|
|
137
|
+
fs.rmSync(file, { force: true });
|
|
138
|
+
}
|
|
139
|
+
catch { /* best effort */ }
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
66
142
|
if (!Number.isFinite(age) || age < -1000 || age > LEASE_MAX_AGE_MS
|
|
67
143
|
|| !pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid)
|
|
68
144
|
|| typeof runtime.binary !== 'string' || !path.isAbsolute(runtime.binary)
|
|
@@ -91,20 +167,25 @@ function normalizedWorkspacePath(directory) {
|
|
|
91
167
|
const normalized = resolved.replace(/\\/g, '/').replace(/\/$/, '');
|
|
92
168
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
93
169
|
}
|
|
94
|
-
export function runtimeForCwd(cwd) {
|
|
170
|
+
export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
|
|
95
171
|
const target = normalizedWorkspacePath(cwd);
|
|
96
|
-
return
|
|
172
|
+
return runtimes
|
|
97
173
|
.map(runtime => ({ runtime, root: normalizedWorkspacePath(runtime.cwd) }))
|
|
98
174
|
.filter(({ root }) => target === root || target.startsWith(root + '/'))
|
|
99
175
|
.sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
|
|
100
176
|
}
|
|
101
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
179
|
+
* Rail PreToolUse router, `--phase post` for grep enrichment, `--event
|
|
180
|
+
* precompact` for the enrichment dedupe reset.
|
|
181
|
+
*/
|
|
182
|
+
export function railHookInvocation(host, extra = []) {
|
|
102
183
|
return { command: process.execPath,
|
|
103
|
-
args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host] };
|
|
184
|
+
args: [fileURLToPath(new URL('./rail-hook.js', import.meta.url)), '--host', host, ...extra] };
|
|
104
185
|
}
|
|
105
186
|
/** POSIX and Windows hosts that accept only command text. Claude uses exec form. */
|
|
106
|
-
export function railHookCommand(host, platform = process.platform) {
|
|
107
|
-
const invocation = railHookInvocation(host);
|
|
187
|
+
export function railHookCommand(host, platform = process.platform, extra = []) {
|
|
188
|
+
const invocation = railHookInvocation(host, extra);
|
|
108
189
|
const quote = platform === 'win32'
|
|
109
190
|
? (s) => '"' + s.replace(/\\/g, '/').replace(/"/g, '\\"') + '"'
|
|
110
191
|
: (s) => "'" + s.replace(/'/g, "'\\''") + "'";
|
package/dist/tool-names.js
CHANGED
|
@@ -18,6 +18,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
|
|
|
18
18
|
'detect_changes',
|
|
19
19
|
'embed_diag',
|
|
20
20
|
'embed_reset_breaker',
|
|
21
|
+
'explain',
|
|
21
22
|
'export_query_audit',
|
|
22
23
|
'find_api_calls',
|
|
23
24
|
'find_api_endpoints',
|
|
@@ -66,6 +67,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
|
|
|
66
67
|
'get_function_quality_metrics',
|
|
67
68
|
'get_impact',
|
|
68
69
|
'get_process_flow',
|
|
70
|
+
'get_query_revision',
|
|
69
71
|
'get_repository_stats',
|
|
70
72
|
'get_service_diagram',
|
|
71
73
|
'get_source_window',
|
|
@@ -74,6 +76,7 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
|
|
|
74
76
|
'get_timeline',
|
|
75
77
|
'governing_contracts',
|
|
76
78
|
'governing_rules',
|
|
79
|
+
'guided_refactor',
|
|
77
80
|
'index_directory',
|
|
78
81
|
'ingest_graph_fragment',
|
|
79
82
|
'link_repositories',
|
|
@@ -86,10 +89,13 @@ export const MEMTRACE_TOOL_NAMES = Object.freeze([
|
|
|
86
89
|
'list_watched_paths',
|
|
87
90
|
'list_worktrees',
|
|
88
91
|
'mem_diag',
|
|
92
|
+
'pdg_query',
|
|
89
93
|
'preflight_check',
|
|
94
|
+
'query_graph',
|
|
90
95
|
'read_doc',
|
|
91
96
|
'recall_decision',
|
|
92
97
|
'record_external_episode',
|
|
98
|
+
'rename_symbol',
|
|
93
99
|
'replay_history',
|
|
94
100
|
'review_agent_sessions',
|
|
95
101
|
'review_github_pr',
|