memtrace-skills 1.2.10 → 1.2.13
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.d.ts +1 -0
- package/dist/commands/install.js +3 -2
- package/dist/index.js +9 -0
- package/dist/managed-skills.d.ts +4 -0
- package/dist/managed-skills.js +95 -0
- package/dist/rail-hook.js +28 -12
- package/dist/rail-lifecycle.js +45 -2
- package/dist/rail-runtime.d.ts +38 -2
- package/dist/rail-runtime.js +141 -8
- package/dist/tool-names.js +1 -0
- package/dist/transformers/claude.d.ts +6 -4
- package/dist/transformers/claude.js +54 -65
- package/dist/transformers/codex-config.d.ts +2 -0
- package/dist/transformers/codex-config.js +33 -0
- package/dist/transformers/codex.js +18 -22
- package/dist/transformers/cursor.js +7 -21
- package/dist/transformers/gemini.js +5 -4
- package/dist/transformers/kiro.js +5 -14
- package/dist/transformers/opencode.js +8 -7
- package/dist/transformers/pi.js +8 -6
- package/dist/transformers/rail-hooks.js +42 -5
- package/dist/transformers/shared.js +6 -13
- package/dist/transformers/types.d.ts +2 -0
- package/package.json +4 -3
- package/plugins/memtrace-skills/references/mcp-parameters.md +14 -1
- package/plugins/memtrace-skills/skills/memtrace-first/SKILL.md +42 -17
- package/skills/managed-stock-sha256.txt +364 -0
- package/skills/workflows/memtrace-first.md +42 -17
package/dist/commands/install.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function runInstall(options) {
|
|
|
48
48
|
console.error(' Error: No skills found. Ensure skills/ directory exists.\n');
|
|
49
49
|
process.exit(1);
|
|
50
50
|
}
|
|
51
|
-
const memtraceBin = await resolveMemtraceBinary();
|
|
51
|
+
const memtraceBin = options.mode === 'refresh' ? '' : await resolveMemtraceBinary();
|
|
52
52
|
if (memtraceBin === null) {
|
|
53
53
|
console.warn(' ⚠ memtrace binary not found on PATH.');
|
|
54
54
|
console.warn(' MCP registration will be skipped for every agent.\n');
|
|
@@ -58,7 +58,8 @@ export async function runInstall(options) {
|
|
|
58
58
|
scope: options.scope,
|
|
59
59
|
cwd: process.cwd(),
|
|
60
60
|
memtraceBinary: memtraceBin ?? '',
|
|
61
|
-
skipMcp: options.skipMcp || memtraceBin === null,
|
|
61
|
+
skipMcp: options.mode === 'refresh' || options.skipMcp || memtraceBin === null,
|
|
62
|
+
mode: options.mode,
|
|
62
63
|
};
|
|
63
64
|
for (const t of transformers) {
|
|
64
65
|
console.log(` [${t.name}] installing...`);
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,15 @@ program
|
|
|
30
30
|
};
|
|
31
31
|
await runInstall(options);
|
|
32
32
|
});
|
|
33
|
+
program
|
|
34
|
+
.command('refresh')
|
|
35
|
+
.description('Update skills without registering MCP servers or enabling plugins and hooks')
|
|
36
|
+
.option('--only <agents>', 'comma-separated agent names', parseOnly)
|
|
37
|
+
.option('--local', 'refresh project-scoped skills', false)
|
|
38
|
+
.option('-y, --yes', 'non-interactive')
|
|
39
|
+
.action(async (opts) => {
|
|
40
|
+
await runInstall({ scope: opts.local ? 'local' : 'global', only: opts.only, yes: opts.yes, mode: 'refresh' });
|
|
41
|
+
});
|
|
33
42
|
program
|
|
34
43
|
.command('doctor')
|
|
35
44
|
.description('Check memtrace install health across all agents')
|
|
@@ -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.js
CHANGED
|
@@ -5,7 +5,7 @@ import http from 'node:http';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import { activeRailRuntimes, enrichEnabled, pidAlive,
|
|
8
|
+
import { activeRailRuntimes, bindSearch, enrichEnabled, pidAlive, ENRICH_HOOK_KILL_MS, SEARCH_COMMAND_RE, } from './rail-runtime.js';
|
|
9
9
|
const argv = process.argv;
|
|
10
10
|
function arg(name) {
|
|
11
11
|
const index = argv.indexOf(name);
|
|
@@ -118,7 +118,8 @@ async function main() {
|
|
|
118
118
|
const phase = arg('--phase');
|
|
119
119
|
const event = arg('--event');
|
|
120
120
|
const enrichPath = phase === 'post' || event === 'precompact';
|
|
121
|
-
//
|
|
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.
|
|
122
123
|
if (enrichPath && !enrichEnabled(env))
|
|
123
124
|
return;
|
|
124
125
|
const input = await readStdin();
|
|
@@ -131,19 +132,34 @@ async function main() {
|
|
|
131
132
|
if (typeof cwd !== 'string')
|
|
132
133
|
return;
|
|
133
134
|
// Leases are read ONCE per call.
|
|
134
|
-
const
|
|
135
|
-
if (!runtime)
|
|
136
|
-
return;
|
|
137
|
-
const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
|
|
135
|
+
const runtimes = activeRailRuntimes();
|
|
138
136
|
if (event === 'precompact') {
|
|
137
|
+
// The dedupe lives in whichever runtime answered this session's
|
|
138
|
+
// searches, which a session above its repositories cannot tell.
|
|
139
139
|
const session = typeof payload.session_id === 'string' ? payload.session_id : '';
|
|
140
|
-
|
|
140
|
+
const owner = bindSearch(payload, cwd, runtimes).runtime;
|
|
141
|
+
await Promise.all((owner ? [owner] : runtimes).map(runtime => postToDaemon(runtime, `/api/rail/enrich/reset?${new URLSearchParams({ session })}`, input, ENRICH_HOOK_KILL_MS)));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const bound = bindSearch(payload, cwd, runtimes);
|
|
145
|
+
const runtime = bound.runtime;
|
|
146
|
+
if (!runtime) {
|
|
147
|
+
// Say why nothing was added: Memtrace runs, but for other folders. With
|
|
148
|
+
// nothing running at all the call stays silent and writes nothing.
|
|
149
|
+
if (phase === 'post' && runtimes.length) {
|
|
150
|
+
logHookTiming({ ts: new Date().toISOString(), host, phase: 'post', tool: payload.tool_name ?? null,
|
|
151
|
+
status: 0, outcome: 'no_runtime_for_cwd', bytes: 0, stdout_bytes: 0,
|
|
152
|
+
server_ms: 0, hook_ms: Date.now() - started });
|
|
153
|
+
}
|
|
141
154
|
return;
|
|
142
155
|
}
|
|
156
|
+
// The daemon resolves the repository from `cwd`: send the one bound.
|
|
157
|
+
const body = bound.cwd === cwd ? input : JSON.stringify({ ...payload, cwd: bound.cwd });
|
|
158
|
+
const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
|
|
143
159
|
if (phase === 'post') {
|
|
144
160
|
const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
|
|
145
161
|
mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
146
|
-
const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`,
|
|
162
|
+
const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, body, ENRICH_HOOK_KILL_MS);
|
|
147
163
|
let wrote = 0;
|
|
148
164
|
let malformed = false;
|
|
149
165
|
if (result.status === 200 && result.body && alive()) {
|
|
@@ -169,9 +185,9 @@ async function main() {
|
|
|
169
185
|
// JSON-mode/OpenCode and Windsurf retain their CLI response contracts.
|
|
170
186
|
// Other hosts use the in-process daemon router with no native binary spawn.
|
|
171
187
|
if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
|
|
172
|
-
const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '',
|
|
188
|
+
const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
|
|
173
189
|
escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
174
|
-
const result = await postToDaemon(runtime, `/api/rail/hook?${query}`,
|
|
190
|
+
const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, body, 5500);
|
|
175
191
|
if (result.status === 200 && result.body && alive()) {
|
|
176
192
|
JSON.parse(result.body); // never emit malformed endpoint output into an agent
|
|
177
193
|
process.stdout.write(result.body);
|
|
@@ -179,7 +195,7 @@ async function main() {
|
|
|
179
195
|
return;
|
|
180
196
|
}
|
|
181
197
|
const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
|
|
182
|
-
cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
198
|
+
cwd: bound.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
183
199
|
env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
|
|
184
200
|
});
|
|
185
201
|
let output = '';
|
|
@@ -189,7 +205,7 @@ async function main() {
|
|
|
189
205
|
child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
|
|
190
206
|
stderr += chunk; });
|
|
191
207
|
child.stdin.on('error', () => { });
|
|
192
|
-
child.stdin.end(
|
|
208
|
+
child.stdin.end(body);
|
|
193
209
|
const timer = setTimeout(() => child.kill(), 5500);
|
|
194
210
|
// If the daemon exits while a request is in flight, discard the response.
|
|
195
211
|
const monitor = setInterval(() => { if (!alive())
|
package/dist/rail-lifecycle.js
CHANGED
|
@@ -14,6 +14,48 @@ const port = Number(arg('--port'));
|
|
|
14
14
|
const binary = arg('--binary');
|
|
15
15
|
const cwd = arg('--cwd');
|
|
16
16
|
const routeBinary = arg('--route-binary') || undefined;
|
|
17
|
+
/** Every repository the owner serves (`--root`, repeated). */
|
|
18
|
+
const roots = process.argv.flatMap((value, index) => process.argv[index - 1] === '--root' && path.isAbsolute(value) ? [value] : []);
|
|
19
|
+
const settingsFile = arg('--settings-file');
|
|
20
|
+
/** Saved Settings keys that decide which hooks are registered. */
|
|
21
|
+
const SETTINGS_KEYS = ['MEMTRACE_RAIL', 'MEMTRACE_ENRICH'];
|
|
22
|
+
const startupEnv = Object.fromEntries(SETTINGS_KEYS.map(key => [key, process.env[key]]));
|
|
23
|
+
/**
|
|
24
|
+
* Follow a Settings change while the owner runs. The daemon handed down the
|
|
25
|
+
* values it applied from the settings file (MEMTRACE_SETTINGS_APPLIED); those
|
|
26
|
+
* - and unset keys - track the file, while a variable the user set in the
|
|
27
|
+
* environment still wins, as everywhere else.
|
|
28
|
+
*/
|
|
29
|
+
function followSavedSettings() {
|
|
30
|
+
if (!settingsFile)
|
|
31
|
+
return;
|
|
32
|
+
let applied = {};
|
|
33
|
+
try {
|
|
34
|
+
applied = JSON.parse(process.env.MEMTRACE_SETTINGS_APPLIED || '{}') ?? {};
|
|
35
|
+
}
|
|
36
|
+
catch { /* none applied */ }
|
|
37
|
+
let saved;
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
40
|
+
saved = parsed && typeof parsed.values === 'object' && parsed.values ? parsed.values : {};
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
// A file being rewritten keeps the current choice; a deleted one is "nothing saved".
|
|
44
|
+
if (error.code !== 'ENOENT')
|
|
45
|
+
return;
|
|
46
|
+
saved = {};
|
|
47
|
+
}
|
|
48
|
+
for (const key of SETTINGS_KEYS) {
|
|
49
|
+
const initial = startupEnv[key];
|
|
50
|
+
if (initial !== undefined && applied[key] !== initial)
|
|
51
|
+
continue;
|
|
52
|
+
const value = saved[key];
|
|
53
|
+
if (typeof value === 'string' && value.trim())
|
|
54
|
+
process.env[key] = value;
|
|
55
|
+
else
|
|
56
|
+
delete process.env[key];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
17
59
|
async function main() {
|
|
18
60
|
if (process.argv.includes('--sync')) {
|
|
19
61
|
const live = activeRailRuntimes();
|
|
@@ -41,10 +83,11 @@ async function main() {
|
|
|
41
83
|
stop();
|
|
42
84
|
return;
|
|
43
85
|
}
|
|
44
|
-
writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, binary, port, routeBinary });
|
|
86
|
+
writeJsonAtomic(lease, { pid, guardianPid: process.pid, updatedAt: Date.now(), cwd, roots, binary, port, routeBinary });
|
|
87
|
+
followSavedSettings();
|
|
45
88
|
const enabled = !railDisabled();
|
|
46
89
|
// 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.
|
|
90
|
+
// so a rail.json or Settings change to either re-wires them without a restart.
|
|
48
91
|
const signature = enabled ? `on:${enrichEnabled()}:${railMode()}` : 'off';
|
|
49
92
|
if (signature === wired)
|
|
50
93
|
return;
|
package/dist/rail-runtime.d.ts
CHANGED
|
@@ -7,6 +7,11 @@ export interface RailRuntime {
|
|
|
7
7
|
port: number;
|
|
8
8
|
/** Local development override; packaged hooks normally use the daemon HTTP API. */
|
|
9
9
|
routeBinary?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Repository roots the runtime serves besides `cwd` (its workspace
|
|
12
|
+
* members), so a session in a member it was not started in still reaches it.
|
|
13
|
+
*/
|
|
14
|
+
roots?: string[];
|
|
10
15
|
}
|
|
11
16
|
export declare const LEASE_MAX_AGE_MS = 3000;
|
|
12
17
|
export declare const LEASE_REFRESH_INTERVAL_MS = 1000;
|
|
@@ -34,10 +39,11 @@ export interface RailConfig {
|
|
|
34
39
|
export declare function railConfig(): RailConfig;
|
|
35
40
|
/**
|
|
36
41
|
* Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
|
|
37
|
-
*
|
|
42
|
+
* non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
|
|
43
|
+
* not implicitly enable this additional post-search path.
|
|
38
44
|
*/
|
|
39
45
|
export declare function enrichEnabled(env?: NodeJS.ProcessEnv, config?: RailConfig): boolean;
|
|
40
|
-
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then
|
|
46
|
+
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
|
|
41
47
|
export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig): string;
|
|
42
48
|
/**
|
|
43
49
|
* Live leases. Reading them also deletes garbage: a lease whose owner and
|
|
@@ -46,7 +52,37 @@ export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig):
|
|
|
46
52
|
*/
|
|
47
53
|
export declare function activeRailRuntimes(): RailRuntime[];
|
|
48
54
|
export declare function railIsActive(): boolean;
|
|
55
|
+
/** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
|
|
49
56
|
export declare function runtimeForCwd(cwd: string, runtimes?: RailRuntime[]): RailRuntime | undefined;
|
|
57
|
+
/** The git checkout containing `start` (a directory or a file), if any. */
|
|
58
|
+
export declare function gitRootOf(start: string): string | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* A linked worktree (`git worktree add ../x`) to its main checkout, through
|
|
61
|
+
* `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
|
|
62
|
+
* submodule, an unreadable file) is returned unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mainCheckout(root: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* Where a search looked, from the search itself: the Grep path, a leading
|
|
67
|
+
* `cd`, absolute path arguments, and the files its results name. Existing
|
|
68
|
+
* paths only, resolved against the session's working directory.
|
|
69
|
+
*/
|
|
70
|
+
export declare function searchTargets(payload: any, cwd: string): string[];
|
|
71
|
+
/**
|
|
72
|
+
* Which runtime answers a search, and the directory to send it as `cwd`.
|
|
73
|
+
*
|
|
74
|
+
* The session's cwd alone misses common layouts: an orchestrator (and every
|
|
75
|
+
* subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
|
|
76
|
+
* daemon started in a repository's subfolder, and a linked worktree beside
|
|
77
|
+
* the main checkout. A session inside a covered repository keeps the plain
|
|
78
|
+
* cwd match; otherwise the repository the search itself names is bound, by
|
|
79
|
+
* its git root or its worktree's main checkout, and sent as the cwd so the
|
|
80
|
+
* daemon resolves that repository.
|
|
81
|
+
*/
|
|
82
|
+
export declare function bindSearch(payload: any, cwd: string, runtimes: RailRuntime[]): {
|
|
83
|
+
runtime?: RailRuntime;
|
|
84
|
+
cwd: string;
|
|
85
|
+
};
|
|
50
86
|
/**
|
|
51
87
|
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
52
88
|
* Rail PreToolUse router, `--phase post` for grep enrichment, `--event
|
package/dist/rail-runtime.js
CHANGED
|
@@ -63,7 +63,7 @@ export const ENRICH_HOOK_KILL_MS = 600;
|
|
|
63
63
|
* searches worth a loopback round trip (design 4.1). `git tag v1` must not
|
|
64
64
|
* match `ag`, so a program must start a word.
|
|
65
65
|
*/
|
|
66
|
-
export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep)(\s|$)
|
|
66
|
+
export const SEARCH_COMMAND_RE = /(^|[\s;|&(`])(rg|grep|egrep|fgrep|rgrep|ugrep|ug|ag|ack|git\s+grep|select-string|sls|findstr(?:\.exe)?)(\s|$)/i;
|
|
67
67
|
/** `~/.memtrace/rail.json` (or `MEMTRACE_RAIL_CONFIG`); `{}` when absent or unreadable. */
|
|
68
68
|
export function railConfig() {
|
|
69
69
|
const configPath = process.env.MEMTRACE_RAIL_CONFIG?.trim()
|
|
@@ -93,19 +93,28 @@ function flag(value) {
|
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
95
95
|
* Grep enrichment on/off: `MEMTRACE_ENRICH`, then rail.json `"enrich"`, then
|
|
96
|
-
*
|
|
96
|
+
* non-blocking Nudge mode. Explicit Observe, Off, Rail and Strict modes do
|
|
97
|
+
* not implicitly enable this additional post-search path.
|
|
97
98
|
*/
|
|
98
99
|
export function enrichEnabled(env = process.env, config = railConfig()) {
|
|
99
|
-
|
|
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');
|
|
100
109
|
}
|
|
101
|
-
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then
|
|
110
|
+
/** Rail mode: `MEMTRACE_RAIL`, then rail.json `"mode"`, then non-blocking nudge. */
|
|
102
111
|
export function railMode(env = process.env, config = railConfig()) {
|
|
103
112
|
const valid = ['off', 'observe', 'nudge', 'rail', 'strict'];
|
|
104
113
|
const fromEnv = env.MEMTRACE_RAIL?.trim().toLowerCase();
|
|
105
114
|
if (fromEnv && valid.includes(fromEnv))
|
|
106
115
|
return fromEnv;
|
|
107
116
|
const fromConfig = typeof config.mode === 'string' ? config.mode.trim().toLowerCase() : '';
|
|
108
|
-
return valid.includes(fromConfig) ? fromConfig : '
|
|
117
|
+
return valid.includes(fromConfig) ? fromConfig : 'nudge';
|
|
109
118
|
}
|
|
110
119
|
/**
|
|
111
120
|
* Live leases. Reading them also deletes garbage: a lease whose owner and
|
|
@@ -137,6 +146,10 @@ export function activeRailRuntimes() {
|
|
|
137
146
|
|| typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
|
|
138
147
|
|| !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
|
|
139
148
|
return [];
|
|
149
|
+
// Older leases have no roots; a malformed entry never widens a lease.
|
|
150
|
+
runtime.roots = Array.isArray(runtime.roots)
|
|
151
|
+
? runtime.roots.filter(root => typeof root === 'string' && path.isAbsolute(root))
|
|
152
|
+
: [];
|
|
140
153
|
return [runtime];
|
|
141
154
|
}
|
|
142
155
|
catch {
|
|
@@ -155,16 +168,136 @@ function normalizedWorkspacePath(directory) {
|
|
|
155
168
|
resolved = fs.realpathSync.native(resolved);
|
|
156
169
|
}
|
|
157
170
|
catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
|
|
158
|
-
|
|
171
|
+
// A verbatim `\\?\C:\x` (a canonical Windows path) is the same folder as `C:\x`.
|
|
172
|
+
const normalized = resolved.replace(/\\/g, '/').replace(/^\/\/\?\/(UNC\/)?/i, (_m, unc) => unc ? '//' : '')
|
|
173
|
+
.replace(/\/$/, '');
|
|
159
174
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
160
175
|
}
|
|
176
|
+
function leaseRoots(runtime) {
|
|
177
|
+
return [runtime.cwd, ...(runtime.roots ?? [])];
|
|
178
|
+
}
|
|
179
|
+
/** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
|
|
161
180
|
export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
|
|
162
181
|
const target = normalizedWorkspacePath(cwd);
|
|
163
182
|
return runtimes
|
|
164
|
-
.map(runtime => ({ runtime, root:
|
|
165
|
-
|
|
183
|
+
.map(runtime => ({ runtime, root: leaseRoots(runtime).map(normalizedWorkspacePath)
|
|
184
|
+
.filter(root => target === root || target.startsWith(root + '/'))
|
|
185
|
+
.sort((a, b) => b.length - a.length)[0] ?? '' }))
|
|
186
|
+
.filter(({ root }) => root !== '')
|
|
166
187
|
.sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
|
|
167
188
|
}
|
|
189
|
+
function samePath(a, b) {
|
|
190
|
+
return normalizedWorkspacePath(a) === normalizedWorkspacePath(b);
|
|
191
|
+
}
|
|
192
|
+
/** The git checkout containing `start` (a directory or a file), if any. */
|
|
193
|
+
export function gitRootOf(start) {
|
|
194
|
+
let dir = path.resolve(start);
|
|
195
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
196
|
+
if (fs.existsSync(path.join(dir, '.git')))
|
|
197
|
+
return dir;
|
|
198
|
+
const parent = path.dirname(dir);
|
|
199
|
+
if (parent === dir)
|
|
200
|
+
return undefined;
|
|
201
|
+
dir = parent;
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A linked worktree (`git worktree add ../x`) to its main checkout, through
|
|
207
|
+
* `.git` -> gitdir -> commondir. Anything else (a normal checkout, a
|
|
208
|
+
* submodule, an unreadable file) is returned unchanged.
|
|
209
|
+
*/
|
|
210
|
+
export function mainCheckout(root) {
|
|
211
|
+
try {
|
|
212
|
+
const dotGit = path.join(root, '.git');
|
|
213
|
+
if (!fs.statSync(dotGit).isFile())
|
|
214
|
+
return root;
|
|
215
|
+
const gitdir = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, 'utf8'))?.[1]?.trim();
|
|
216
|
+
if (!gitdir)
|
|
217
|
+
return root;
|
|
218
|
+
const absoluteGitdir = path.resolve(root, gitdir);
|
|
219
|
+
const common = path.resolve(absoluteGitdir, fs.readFileSync(path.join(absoluteGitdir, 'commondir'), 'utf8').trim());
|
|
220
|
+
return path.basename(common) === '.git' ? path.dirname(common) : root;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return root;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const CD_RE = /(?:^|&&|;|\|\|)\s*(?:cd|pushd|set-location|sl)\s+(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/i;
|
|
227
|
+
const ABSOLUTE_ARG_RE = /"((?:[A-Za-z]:)?[\\/][^"]+)"|'((?:[A-Za-z]:)?[\\/][^']+)'|(?:^|\s)((?:[A-Za-z]:)?[\\/][^\s"';|&]+)/g;
|
|
228
|
+
const RESULT_LINE_RE = /^((?:[A-Za-z]:)?[^:\r\n]+?):\d+[:-]/;
|
|
229
|
+
/**
|
|
230
|
+
* Where a search looked, from the search itself: the Grep path, a leading
|
|
231
|
+
* `cd`, absolute path arguments, and the files its results name. Existing
|
|
232
|
+
* paths only, resolved against the session's working directory.
|
|
233
|
+
*/
|
|
234
|
+
export function searchTargets(payload, cwd) {
|
|
235
|
+
const found = [];
|
|
236
|
+
const add = (value) => {
|
|
237
|
+
if (typeof value !== 'string' || !value.trim() || found.length >= 12)
|
|
238
|
+
return;
|
|
239
|
+
const resolved = path.resolve(cwd, value.trim().replace(/^["']|["']$/g, ''));
|
|
240
|
+
if (!found.some(existing => samePath(existing, resolved)) && fs.existsSync(resolved))
|
|
241
|
+
found.push(resolved);
|
|
242
|
+
};
|
|
243
|
+
const input = payload?.tool_input ?? {};
|
|
244
|
+
add(input.path);
|
|
245
|
+
add(input.file_path);
|
|
246
|
+
const command = typeof input.command === 'string' ? input.command : '';
|
|
247
|
+
const cd = CD_RE.exec(command);
|
|
248
|
+
if (cd)
|
|
249
|
+
add(cd[1] ?? cd[2] ?? cd[3]);
|
|
250
|
+
for (const match of command.matchAll(ABSOLUTE_ARG_RE))
|
|
251
|
+
add(match[1] ?? match[2] ?? match[3]);
|
|
252
|
+
const response = payload?.tool_response ?? payload?.tool_output ?? {};
|
|
253
|
+
if (Array.isArray(response?.filenames))
|
|
254
|
+
response.filenames.slice(0, 5).forEach(add);
|
|
255
|
+
const text = typeof response === 'string' ? response
|
|
256
|
+
: String(response?.stdout ?? response?.content ?? response?.output ?? '');
|
|
257
|
+
for (const line of text.split('\n').slice(0, 20))
|
|
258
|
+
add(RESULT_LINE_RE.exec(line.trim())?.[1]);
|
|
259
|
+
return found;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Which runtime answers a search, and the directory to send it as `cwd`.
|
|
263
|
+
*
|
|
264
|
+
* The session's cwd alone misses common layouts: an orchestrator (and every
|
|
265
|
+
* subagent, which keeps its cwd) opened in a folder ABOVE the repositories, a
|
|
266
|
+
* daemon started in a repository's subfolder, and a linked worktree beside
|
|
267
|
+
* the main checkout. A session inside a covered repository keeps the plain
|
|
268
|
+
* cwd match; otherwise the repository the search itself names is bound, by
|
|
269
|
+
* its git root or its worktree's main checkout, and sent as the cwd so the
|
|
270
|
+
* daemon resolves that repository.
|
|
271
|
+
*/
|
|
272
|
+
export function bindSearch(payload, cwd, runtimes) {
|
|
273
|
+
const direct = runtimeForCwd(cwd, runtimes);
|
|
274
|
+
const cwdRoot = gitRootOf(cwd);
|
|
275
|
+
if (direct && cwdRoot)
|
|
276
|
+
return { runtime: direct, cwd };
|
|
277
|
+
const forRepository = (root) => {
|
|
278
|
+
const main = mainCheckout(root);
|
|
279
|
+
const runtime = runtimeForCwd(root, runtimes) ?? runtimeForCwd(main, runtimes)
|
|
280
|
+
?? runtimes.find(candidate => leaseRoots(candidate).some(leaseRoot => {
|
|
281
|
+
const leaseRepository = gitRootOf(leaseRoot);
|
|
282
|
+
return leaseRepository !== undefined && (samePath(leaseRepository, root) || samePath(leaseRepository, main));
|
|
283
|
+
}));
|
|
284
|
+
return { main, runtime };
|
|
285
|
+
};
|
|
286
|
+
for (const target of searchTargets(payload, cwd)) {
|
|
287
|
+
const root = gitRootOf(target);
|
|
288
|
+
if (!root)
|
|
289
|
+
continue;
|
|
290
|
+
const { main, runtime } = forRepository(root);
|
|
291
|
+
if (runtime)
|
|
292
|
+
return { runtime, cwd: main };
|
|
293
|
+
}
|
|
294
|
+
if (cwdRoot) {
|
|
295
|
+
const { main, runtime } = forRepository(cwdRoot);
|
|
296
|
+
if (runtime)
|
|
297
|
+
return { runtime, cwd: samePath(main, cwdRoot) ? cwd : path.join(main, path.relative(cwdRoot, cwd)) };
|
|
298
|
+
}
|
|
299
|
+
return { runtime: direct, cwd };
|
|
300
|
+
}
|
|
168
301
|
/**
|
|
169
302
|
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
170
303
|
* Rail PreToolUse router, `--phase post` for grep enrichment, `--event
|
package/dist/tool-names.js
CHANGED
|
@@ -32,8 +32,8 @@ export declare function claudeRailHookCommand(_memtraceBinary: string, platform?
|
|
|
32
32
|
* This is what makes Rail "part of memtrace" rather than a manual install:
|
|
33
33
|
* `memtrace install` wires a PreToolUse hook that pipes every Grep/Glob/Bash
|
|
34
34
|
* attempt through the Rail router (see {@link claudeRailHookCommand} for the
|
|
35
|
-
* transport). While a daemon is running the hook defaults to **
|
|
36
|
-
*
|
|
35
|
+
* transport). While a daemon is running the hook defaults to **nudge** —
|
|
36
|
+
* context alongside ordinary tools. Explicit modes are preserved. Mode is intentionally NOT
|
|
37
37
|
* baked into the command so the user can change it via env without
|
|
38
38
|
* re-installing.
|
|
39
39
|
*
|
|
@@ -49,7 +49,7 @@ export declare const ENRICH_HOOK_MATCHER = "Grep|Bash|PowerShell";
|
|
|
49
49
|
/** Claude hook timeout for the enrichment hooks, in seconds. */
|
|
50
50
|
export declare const ENRICH_HOOK_TIMEOUT_S = 2;
|
|
51
51
|
export interface RailHookOptions {
|
|
52
|
-
/** Register
|
|
52
|
+
/** Register grep-enrichment hooks. Explicit env/config wins; implicit on only in Nudge. */
|
|
53
53
|
enrich?: boolean;
|
|
54
54
|
/** The Rail mode the PreToolUse decision depends on. Default: env, then rail.json. */
|
|
55
55
|
mode?: string;
|
|
@@ -88,7 +88,9 @@ export declare function claudeUserConfigFile(): string;
|
|
|
88
88
|
* 2. Fall back to manual: write cache files + update settings.json
|
|
89
89
|
* 3. Register MCP server for memtrace tools
|
|
90
90
|
*/
|
|
91
|
-
export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string
|
|
91
|
+
export declare function installClaudePlugin(skills: Skill[], memtraceBinaryPath: string, options?: {
|
|
92
|
+
skipMcp?: boolean;
|
|
93
|
+
}): Promise<{
|
|
92
94
|
cacheDir: string;
|
|
93
95
|
skillCount: number;
|
|
94
96
|
}>;
|