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.
Files changed (48) hide show
  1. package/dist/managed-skills.d.ts +4 -0
  2. package/dist/managed-skills.js +95 -0
  3. package/dist/rail-hook.d.ts +7 -1
  4. package/dist/rail-hook.js +177 -57
  5. package/dist/rail-install.js +3 -6
  6. package/dist/rail-lifecycle.js +6 -3
  7. package/dist/rail-runtime.d.ts +38 -3
  8. package/dist/rail-runtime.js +88 -7
  9. package/dist/tool-names.js +6 -0
  10. package/dist/transformers/claude.d.ts +16 -4
  11. package/dist/transformers/claude.js +69 -82
  12. package/dist/transformers/codex.js +5 -20
  13. package/dist/transformers/cursor.js +5 -20
  14. package/dist/transformers/kiro.js +5 -14
  15. package/dist/transformers/opencode.js +6 -6
  16. package/dist/transformers/pi.js +5 -5
  17. package/dist/transformers/rail-hooks.d.ts +6 -1
  18. package/dist/transformers/rail-hooks.js +72 -13
  19. package/dist/transformers/shared.d.ts +2 -1
  20. package/dist/transformers/shared.js +28 -23
  21. package/dist/transformers/vscode.js +3 -1
  22. package/package.json +2 -2
  23. package/plugins/memtrace-skills/references/mcp-parameters.md +14 -3
  24. package/plugins/memtrace-skills/skills/memtrace-codebase-exploration/SKILL.md +4 -0
  25. package/plugins/memtrace-skills/skills/memtrace-daily/SKILL.md +4 -0
  26. package/plugins/memtrace-skills/skills/memtrace-first/SKILL.md +55 -24
  27. package/plugins/memtrace-skills/skills/memtrace-graph/SKILL.md +4 -0
  28. package/plugins/memtrace-skills/skills/memtrace-guided-refactor/SKILL.md +88 -0
  29. package/plugins/memtrace-skills/skills/memtrace-impact/SKILL.md +4 -0
  30. package/plugins/memtrace-skills/skills/memtrace-index/SKILL.md +21 -0
  31. package/plugins/memtrace-skills/skills/memtrace-preflight/SKILL.md +4 -0
  32. package/plugins/memtrace-skills/skills/memtrace-quality/SKILL.md +16 -2
  33. package/plugins/memtrace-skills/skills/memtrace-refactoring-guide/SKILL.md +4 -0
  34. package/plugins/memtrace-skills/skills/memtrace-search/SKILL.md +1 -0
  35. package/plugins/memtrace-skills/skills/memtrace-session-continuity/SKILL.md +4 -0
  36. package/skills/commands/memtrace-daily.md +4 -0
  37. package/skills/commands/memtrace-graph.md +4 -0
  38. package/skills/commands/memtrace-impact.md +4 -0
  39. package/skills/commands/memtrace-index.md +22 -0
  40. package/skills/commands/memtrace-preflight.md +4 -0
  41. package/skills/commands/memtrace-quality.md +19 -2
  42. package/skills/commands/memtrace-search.md +1 -0
  43. package/skills/managed-stock-sha256.txt +364 -0
  44. package/skills/workflows/memtrace-codebase-exploration.md +4 -0
  45. package/skills/workflows/memtrace-first.md +55 -24
  46. package/skills/workflows/memtrace-guided-refactor.md +89 -0
  47. package/skills/workflows/memtrace-refactoring-guide.md +4 -0
  48. 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
+ }
@@ -1 +1,7 @@
1
- export {};
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 { 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())
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
- const runtime = runtimeForCwd(cwd);
134
+ // Leases are read ONCE per call.
135
+ const runtime = runtimeForCwd(cwd, activeRailRuntimes());
21
136
  if (!runtime)
22
137
  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');
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: 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);
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: { ...process.env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
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
- if (!pidAlive(runtime.pid) || !pidAlive(runtime.guardianPid))
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 (!runtimeForCwd(cwd))
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
- main().catch(() => { });
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
+ }
@@ -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
- for (const pluginPath of openCodeRailPluginPaths(ctx)) {
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: openCodeRailPluginPaths(ctx)[0],
80
+ path: pluginPath,
84
81
  });
85
82
  }
86
83
  catch (e) {
@@ -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
- if (enabled === wired)
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 = enabled;
55
+ wired = signature;
53
56
  };
54
57
  const stop = () => {
55
58
  if (done)
@@ -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
- export declare function railHookInvocation(host: string): {
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;
@@ -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 runtime = JSON.parse(fs.readFileSync(path.join(runtimeDirectory(), name), 'utf8'));
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 activeRailRuntimes()
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
- export function railHookInvocation(host) {
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, "'\\''") + "'";
@@ -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',