memtrace-skills 1.2.11 → 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/rail-hook.js +25 -10
- package/dist/rail-lifecycle.js +45 -2
- package/dist/rail-runtime.d.ts +35 -0
- package/dist/rail-runtime.js +128 -4
- package/dist/tool-names.js +1 -0
- package/dist/transformers/claude.d.ts +3 -1
- package/dist/transformers/claude.js +32 -3
- package/dist/transformers/codex-config.d.ts +2 -0
- package/dist/transformers/codex-config.js +33 -0
- package/dist/transformers/codex.js +13 -2
- package/dist/transformers/cursor.js +2 -1
- package/dist/transformers/gemini.js +5 -4
- package/dist/transformers/opencode.js +8 -7
- package/dist/transformers/pi.js +3 -1
- package/dist/transformers/types.d.ts +2 -0
- package/package.json +4 -3
- package/plugins/memtrace-skills/references/mcp-parameters.md +14 -1
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')
|
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);
|
|
@@ -132,19 +132,34 @@ async function main() {
|
|
|
132
132
|
if (typeof cwd !== 'string')
|
|
133
133
|
return;
|
|
134
134
|
// Leases are read ONCE per call.
|
|
135
|
-
const
|
|
136
|
-
if (!runtime)
|
|
137
|
-
return;
|
|
138
|
-
const alive = () => pidAlive(runtime.pid) && pidAlive(runtime.guardianPid);
|
|
135
|
+
const runtimes = activeRailRuntimes();
|
|
139
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.
|
|
140
139
|
const session = typeof payload.session_id === 'string' ? payload.session_id : '';
|
|
141
|
-
|
|
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
|
+
}
|
|
142
154
|
return;
|
|
143
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);
|
|
144
159
|
if (phase === 'post') {
|
|
145
160
|
const query = new URLSearchParams({ host, phase: 'post', enrich: env.MEMTRACE_ENRICH || '',
|
|
146
161
|
mode: env.MEMTRACE_RAIL || '', escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
147
|
-
const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`,
|
|
162
|
+
const result = await postToDaemon(runtime, `/api/rail/enrich?${query}`, body, ENRICH_HOOK_KILL_MS);
|
|
148
163
|
let wrote = 0;
|
|
149
164
|
let malformed = false;
|
|
150
165
|
if (result.status === 200 && result.body && alive()) {
|
|
@@ -172,7 +187,7 @@ async function main() {
|
|
|
172
187
|
if (!jsonMode && host !== 'windsurf' && !runtime.routeBinary) {
|
|
173
188
|
const query = new URLSearchParams({ host, mode: env.MEMTRACE_RAIL || '', enrich: env.MEMTRACE_ENRICH || '',
|
|
174
189
|
escape: env.MEMTRACE_RAW_SEARCH || '' });
|
|
175
|
-
const result = await postToDaemon(runtime, `/api/rail/hook?${query}`,
|
|
190
|
+
const result = await postToDaemon(runtime, `/api/rail/hook?${query}`, body, 5500);
|
|
176
191
|
if (result.status === 200 && result.body && alive()) {
|
|
177
192
|
JSON.parse(result.body); // never emit malformed endpoint output into an agent
|
|
178
193
|
process.stdout.write(result.body);
|
|
@@ -180,7 +195,7 @@ async function main() {
|
|
|
180
195
|
return;
|
|
181
196
|
}
|
|
182
197
|
const child = spawn(runtime.routeBinary || runtime.binary, jsonMode ? ['route', '--json', '-'] : ['route', '--hook', '--host', host], {
|
|
183
|
-
cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
198
|
+
cwd: bound.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
184
199
|
env: { ...env, MEMTRACE_RAIL_SEARCH_URL: `http://127.0.0.1:${runtime.port}` },
|
|
185
200
|
});
|
|
186
201
|
let output = '';
|
|
@@ -190,7 +205,7 @@ async function main() {
|
|
|
190
205
|
child.stderr.on('data', chunk => { if (stderr.length < 2_000_000)
|
|
191
206
|
stderr += chunk; });
|
|
192
207
|
child.stdin.on('error', () => { });
|
|
193
|
-
child.stdin.end(
|
|
208
|
+
child.stdin.end(body);
|
|
194
209
|
const timer = setTimeout(() => child.kill(), 5500);
|
|
195
210
|
// If the daemon exits while a request is in flight, discard the response.
|
|
196
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;
|
|
@@ -47,7 +52,37 @@ export declare function railMode(env?: NodeJS.ProcessEnv, config?: RailConfig):
|
|
|
47
52
|
*/
|
|
48
53
|
export declare function activeRailRuntimes(): RailRuntime[];
|
|
49
54
|
export declare function railIsActive(): boolean;
|
|
55
|
+
/** The live runtime whose start folder or served repository contains `cwd`; the innermost wins. */
|
|
50
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
|
+
};
|
|
51
86
|
/**
|
|
52
87
|
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
53
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()
|
|
@@ -146,6 +146,10 @@ export function activeRailRuntimes() {
|
|
|
146
146
|
|| typeof runtime.cwd !== 'string' || !path.isAbsolute(runtime.cwd)
|
|
147
147
|
|| !Number.isInteger(runtime.port) || runtime.port < 1 || runtime.port > 65535)
|
|
148
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
|
+
: [];
|
|
149
153
|
return [runtime];
|
|
150
154
|
}
|
|
151
155
|
catch {
|
|
@@ -164,16 +168,136 @@ function normalizedWorkspacePath(directory) {
|
|
|
164
168
|
resolved = fs.realpathSync.native(resolved);
|
|
165
169
|
}
|
|
166
170
|
catch { /* Unavailable paths retain the existing lexical matching behavior. */ }
|
|
167
|
-
|
|
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(/\/$/, '');
|
|
168
174
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
169
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. */
|
|
170
180
|
export function runtimeForCwd(cwd, runtimes = activeRailRuntimes()) {
|
|
171
181
|
const target = normalizedWorkspacePath(cwd);
|
|
172
182
|
return runtimes
|
|
173
|
-
.map(runtime => ({ runtime, root:
|
|
174
|
-
|
|
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 !== '')
|
|
175
187
|
.sort((a, b) => b.root.length - a.root.length || b.runtime.updatedAt - a.runtime.updatedAt)[0]?.runtime;
|
|
176
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
|
+
}
|
|
177
301
|
/**
|
|
178
302
|
* Exec-form hook invocation. `extra` selects the hook's role: none for the
|
|
179
303
|
* Rail PreToolUse router, `--phase post` for grep enrichment, `--event
|
package/dist/tool-names.js
CHANGED
|
@@ -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
|
}>;
|
|
@@ -554,7 +554,7 @@ export function claudeUserConfigFile() {
|
|
|
554
554
|
* 2. Fall back to manual: write cache files + update settings.json
|
|
555
555
|
* 3. Register MCP server for memtrace tools
|
|
556
556
|
*/
|
|
557
|
-
export async function installClaudePlugin(skills, memtraceBinaryPath) {
|
|
557
|
+
export async function installClaudePlugin(skills, memtraceBinaryPath, options = {}) {
|
|
558
558
|
// Retire known stock bare copies when moving to plugin delivery. Customized
|
|
559
559
|
// strict policies/opt-outs survive; names alone never prove ownership.
|
|
560
560
|
removeManagedSkills(path.join(os.homedir(), '.claude', 'skills'));
|
|
@@ -597,12 +597,39 @@ export async function installClaudePlugin(skills, memtraceBinaryPath) {
|
|
|
597
597
|
}
|
|
598
598
|
enablePluginInSettings();
|
|
599
599
|
// Step 6: Register MCP server
|
|
600
|
-
|
|
600
|
+
if (!options.skipMcp)
|
|
601
|
+
await registerMcpServer(memtraceBinaryPath);
|
|
601
602
|
// Skills ship via the memtrace-skills@memtrace plugin only. Writing
|
|
602
603
|
// bare copies to ~/.claude/skills/memtrace-* duplicates every skill
|
|
603
604
|
// in Claude Code when the plugin is also enabled (issue #11).
|
|
604
605
|
return { cacheDir, skillCount };
|
|
605
606
|
}
|
|
607
|
+
function refreshEnabledClaudePlugin(skills) {
|
|
608
|
+
const settingsFile = path.join(os.homedir(), '.claude', 'settings.json');
|
|
609
|
+
if (!fs.existsSync(settingsFile))
|
|
610
|
+
return 0;
|
|
611
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
612
|
+
if (settings.enabledPlugins?.[PLUGIN_KEY] !== true)
|
|
613
|
+
return 0;
|
|
614
|
+
const root = getPluginCacheRoot();
|
|
615
|
+
if (!fs.existsSync(root))
|
|
616
|
+
return 0;
|
|
617
|
+
let skillCount = 0;
|
|
618
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
619
|
+
if (!entry.isDirectory())
|
|
620
|
+
continue;
|
|
621
|
+
const cacheDir = path.join(root, entry.name);
|
|
622
|
+
copyBundledPluginAssetsAt(cacheDir);
|
|
623
|
+
for (const skill of skills) {
|
|
624
|
+
for (const asset of transformForClaude(skill)) {
|
|
625
|
+
const name = path.basename(path.dirname(asset.relativePath));
|
|
626
|
+
if (writeManagedSkill(path.join(cacheDir, 'skills'), name, asset.content))
|
|
627
|
+
skillCount++;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return skillCount;
|
|
632
|
+
}
|
|
606
633
|
/**
|
|
607
634
|
* Write skills to ~/.claude/skills/ for SDK-based integrations.
|
|
608
635
|
*/
|
|
@@ -677,7 +704,9 @@ export const claudeTransformer = {
|
|
|
677
704
|
name: 'claude',
|
|
678
705
|
async install(skills, ctx) {
|
|
679
706
|
if (ctx.scope === 'global') {
|
|
680
|
-
const
|
|
707
|
+
const skillCount = ctx.mode === 'refresh'
|
|
708
|
+
? refreshEnabledClaudePlugin(skills)
|
|
709
|
+
: (await installClaudePlugin(skills, ctx.memtraceBinary, { skipMcp: ctx.skipMcp })).skillCount;
|
|
681
710
|
return {
|
|
682
711
|
agent: 'claude',
|
|
683
712
|
skillsWritten: skillCount,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { parse } from 'smol-toml';
|
|
3
|
+
const readConfig = (raw) => parse(raw, { integersAsBigInt: 'asNeeded' });
|
|
4
|
+
function memtraceServer(config) {
|
|
5
|
+
const servers = config.mcp_servers;
|
|
6
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers))
|
|
7
|
+
return undefined;
|
|
8
|
+
const server = servers.memtrace;
|
|
9
|
+
return server && typeof server === 'object' && !Array.isArray(server) ? server : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function hasCodexMemtraceServer(raw) {
|
|
12
|
+
return memtraceServer(readConfig(raw)) !== undefined;
|
|
13
|
+
}
|
|
14
|
+
export function refreshCodexCommand(raw, command) {
|
|
15
|
+
const expected = readConfig(raw);
|
|
16
|
+
const server = memtraceServer(expected);
|
|
17
|
+
if (!server || typeof server.command !== 'string' || server.command === command)
|
|
18
|
+
return raw;
|
|
19
|
+
server.command = command;
|
|
20
|
+
let inServer = false;
|
|
21
|
+
const next = raw.split(/(?<=\n)/).map(line => {
|
|
22
|
+
if (/^\s*\[/.test(line)) {
|
|
23
|
+
inServer = /^\s*\[\s*mcp_servers\s*\.\s*(?:memtrace|"memtrace"|'memtrace')\s*\]\s*(?:#.*)?$/.test(line.trimEnd());
|
|
24
|
+
}
|
|
25
|
+
if (!inServer)
|
|
26
|
+
return line;
|
|
27
|
+
return line.replace(/^(\s*command\s*=\s*)(["'])(.*?)\2([ \t]*(?:#.*)?)(\r?\n)?$/, (_match, prefix, _quote, _value, comment, newline) => (`${prefix}${JSON.stringify(command)}${comment}${newline ?? ''}`));
|
|
28
|
+
}).join('');
|
|
29
|
+
if (!isDeepStrictEqual(readConfig(next), expected)) {
|
|
30
|
+
throw new Error('Cannot safely update the Codex MCP command; existing configuration was left unchanged.');
|
|
31
|
+
}
|
|
32
|
+
return next;
|
|
33
|
+
}
|
|
@@ -3,6 +3,7 @@ import os from 'os';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { writeManagedSkill, removeManagedSkills } from '../managed-skills.js';
|
|
5
5
|
import { commandExists, execCommand } from '../utils.js';
|
|
6
|
+
import { hasCodexMemtraceServer, refreshCodexCommand } from './codex-config.js';
|
|
6
7
|
import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
|
|
7
8
|
const MCP_SERVER_NAME = 'memtrace';
|
|
8
9
|
const LEGACY_MCP_SERVER_NAME = 'memfleet';
|
|
@@ -123,6 +124,12 @@ function extractMemtraceEnvBlock(raw) {
|
|
|
123
124
|
}
|
|
124
125
|
export function registerCodexMcpAt(configFile, binary) {
|
|
125
126
|
const existing = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf-8') : '';
|
|
127
|
+
if (hasCodexMemtraceServer(existing)) {
|
|
128
|
+
const next = refreshCodexCommand(existing, binary);
|
|
129
|
+
if (next !== existing)
|
|
130
|
+
writeTextAtomic(configFile, next);
|
|
131
|
+
return { registered: true };
|
|
132
|
+
}
|
|
126
133
|
// Strip leaves the env sub-table intact; split it out so we can reorder it
|
|
127
134
|
// after the freshly written parent block for readability.
|
|
128
135
|
const stripped = stripCodexMcpServer(existing);
|
|
@@ -144,6 +151,8 @@ export function registerCodexMcpAt(configFile, binary) {
|
|
|
144
151
|
if (envBlock)
|
|
145
152
|
parts.push(envBlock);
|
|
146
153
|
const next = `${parts.join('\n\n')}\n`;
|
|
154
|
+
if (!hasCodexMemtraceServer(next))
|
|
155
|
+
throw new Error('Codex MCP registration could not be validated.');
|
|
147
156
|
writeTextAtomic(configFile, next);
|
|
148
157
|
return { registered: true };
|
|
149
158
|
}
|
|
@@ -172,7 +181,8 @@ export const codexTransformer = {
|
|
|
172
181
|
const warnings = [];
|
|
173
182
|
const mcpConfigPath = codexConfigPath(ctx);
|
|
174
183
|
if (!ctx.skipMcp) {
|
|
175
|
-
const cliRegistered = ctx.scope === 'global' &&
|
|
184
|
+
const cliRegistered = ctx.scope === 'global' && !fs.existsSync(mcpConfigPath)
|
|
185
|
+
&& await tryCodexMcpAdd(ctx.memtraceBinary);
|
|
176
186
|
registerCodexMcpAt(mcpConfigPath, ctx.memtraceBinary);
|
|
177
187
|
mcpRegistered = true;
|
|
178
188
|
if (ctx.scope === 'global' && !cliRegistered) {
|
|
@@ -182,7 +192,8 @@ export const codexTransformer = {
|
|
|
182
192
|
// ─── memtrace-rail ─── Wire the discovery hook into .codex/hooks.json
|
|
183
193
|
// (PreToolUse, Bash matcher — Codex code discovery runs through the shell).
|
|
184
194
|
try {
|
|
185
|
-
|
|
195
|
+
if (ctx.mode !== 'refresh')
|
|
196
|
+
registerSettingsHook(codexRailHooksPath(ctx), ctx.memtraceBinary, 'codex', 'PreToolUse', CODEX_MATCHER);
|
|
186
197
|
}
|
|
187
198
|
catch (e) {
|
|
188
199
|
warnings.push(`failed to register Codex Rail hook: ${e.message}`);
|
|
@@ -107,7 +107,8 @@ export const cursorTransformer = {
|
|
|
107
107
|
// ─── memtrace-rail ─── Wire the Memtrace-first discovery hook (default
|
|
108
108
|
// observe). Failure must not block the rest of the install.
|
|
109
109
|
try {
|
|
110
|
-
|
|
110
|
+
if (ctx.mode !== 'refresh')
|
|
111
|
+
registerCursorRailHook(railHooksPath(ctx), ctx.memtraceBinary);
|
|
111
112
|
}
|
|
112
113
|
catch (e) {
|
|
113
114
|
console.warn(`memtrace: failed to register Cursor Rail hook: ${e.message}`);
|
|
@@ -46,10 +46,11 @@ export const geminiTransformer = {
|
|
|
46
46
|
// (PR #14460) — without them a registered BeforeTool hook NEVER fires, so we
|
|
47
47
|
// enable both here (only if the user hasn't already set them).
|
|
48
48
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
if (ctx.mode !== 'refresh')
|
|
50
|
+
registerSettingsHook(settingsPath, ctx.memtraceBinary, 'gemini', 'BeforeTool', GEMINI_MATCHER, {
|
|
51
|
+
enableHooks: true,
|
|
52
|
+
enableMessageBusIntegration: true,
|
|
53
|
+
});
|
|
53
54
|
}
|
|
54
55
|
catch (e) {
|
|
55
56
|
warnings.push(`failed to register Gemini Rail hook: ${e.message}`);
|
|
@@ -31,13 +31,14 @@ export const opencodeTransformer = {
|
|
|
31
31
|
// ─── memtrace-rail ─── Write the in-process discovery plugin (OpenCode
|
|
32
32
|
// has no stdio hook). Defaults to observe; fail-open. Best effort.
|
|
33
33
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
if (ctx.mode !== 'refresh') {
|
|
35
|
+
if (railIsActive()) {
|
|
36
|
+
writeOpenCodeRailPlugin(ctx, openCodePluginSource(ctx.memtraceBinary));
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
for (const pluginPath of openCodeRailPluginPaths(ctx)) {
|
|
40
|
+
fs.rmSync(pluginPath, { force: true });
|
|
41
|
+
}
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
}
|
package/dist/transformers/pi.js
CHANGED
|
@@ -153,7 +153,9 @@ export const piTransformer = {
|
|
|
153
153
|
warnings: [`${e.message}`],
|
|
154
154
|
};
|
|
155
155
|
}
|
|
156
|
-
const result =
|
|
156
|
+
const result = ctx.mode === 'refresh'
|
|
157
|
+
? { registered: false, packagePath, backupPath: undefined }
|
|
158
|
+
: registerPiPackageAt(settingsFile, packagePath);
|
|
157
159
|
if (!result.registered && result.backupPath) {
|
|
158
160
|
warnings.push(`Pi settings.json was malformed; backed up to ${result.backupPath}.`);
|
|
159
161
|
}
|
|
@@ -20,6 +20,8 @@ export interface InstallContext {
|
|
|
20
20
|
memtraceBinary: string;
|
|
21
21
|
/** When true, write skills but skip MCP registration. */
|
|
22
22
|
skipMcp?: boolean;
|
|
23
|
+
/** Refresh resources without changing integration registrations or activation. */
|
|
24
|
+
mode?: 'refresh';
|
|
23
25
|
}
|
|
24
26
|
export interface InstallResult {
|
|
25
27
|
agent: AgentName;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memtrace-skills",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"description": "Memtrace skills for AI coding agents — codebase exploration, temporal evolution, impact analysis, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
"prebuild": "node scripts/copy-skills.js",
|
|
18
18
|
"build": "tsc",
|
|
19
19
|
"prepublishOnly": "npm run build",
|
|
20
|
-
"test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs tests/runtime/rail-enrich-hook.test.mjs tests/runtime/opencode-rail-plugin.test.mjs",
|
|
20
|
+
"test": "vitest run && tsc && node --test tests/runtime/rail-lifecycle.test.mjs tests/runtime/rail-enrich-hook.test.mjs tests/runtime/opencode-rail-plugin.test.mjs tests/runtime/upgrade-integrations.test.mjs tests/runtime/rail-search-binding.test.mjs",
|
|
21
21
|
"test:watch": "vitest"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"commander": "^12.0.0",
|
|
25
|
-
"fs-extra": "^11.0.0"
|
|
25
|
+
"fs-extra": "^11.0.0",
|
|
26
|
+
"smol-toml": "1.9.1"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@types/fs-extra": "^11.0.0",
|
|
@@ -364,6 +364,17 @@ CALLS/REFERENCES/MAKES_API_CALL. Each result includes `confidence`,
|
|
|
364
364
|
| `include_external` | boolean | no | `false` | Include calls to services outside indexed repos |
|
|
365
365
|
| `repo_id` | string | no | — | Omit for full cross-service topology |
|
|
366
366
|
|
|
367
|
+
### `get_env_contract`
|
|
368
|
+
Lays the environment variables a deployment **sets** (Azure Bicep: Container Apps `env`, App Service `appSettings`, Container Instances `environmentVariables`, the `appsettings` config resource) against the ones the code **reads** (`process.env.X`, `os.getenv`, `System.getenv`, `std::env::var`, ...). Answers `matched`, `read_but_never_set` and `set_but_never_read`.
|
|
369
|
+
|
|
370
|
+
| Field | Type | Required | Default | Notes |
|
|
371
|
+
|---|---|---|---|---|
|
|
372
|
+
| `repo_id` | string | no | — | Omit to compare across the whole workspace — the infrastructure repo and the services it deploys are usually different repos |
|
|
373
|
+
| `name` | string | no | — | Exact variable name: every place it is read and set |
|
|
374
|
+
| `include_platform_provided` | boolean | no | `false` | List `PORT`, `WEBSITE_*`, `NODE_ENV`, ... under `read_but_never_set` instead of setting them apart |
|
|
375
|
+
|
|
376
|
+
Only literal names are found, and only Bicep is read as an infrastructure source today; the answer's `caveats` say so.
|
|
377
|
+
|
|
367
378
|
### `link_repositories`
|
|
368
379
|
Connects already-indexed repos so `get_api_topology` can stitch their HTTP graphs. Parameters vary — see `link_repositories` tool schema via the MCP `list_tools` call.
|
|
369
380
|
|
|
@@ -408,7 +419,9 @@ No parameters. Returns `{ watches: [{ path, repo_id, branch, started_at, origin
|
|
|
408
419
|
| Field | Type | Required | Default |
|
|
409
420
|
|---|---|---|---|
|
|
410
421
|
| `repo_id` | string | yes | — |
|
|
411
|
-
| `days` | integer | no |
|
|
422
|
+
| `days` | integer | no | as far back as the newest `MEMTRACE_REPLAY_MAX_COMMITS` commits reach (500 unless set; 0 = no limit) |
|
|
423
|
+
|
|
424
|
+
Returns `replay_window`: `full_history` is true only when every reachable commit was read; `stopped_at_max_commits` and its `note` say when the limit left older history unread.
|
|
412
425
|
|
|
413
426
|
## Session bookends & pre-edit checks
|
|
414
427
|
|