smart-context-mcp 1.0.2 → 1.0.4
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/README.md +145 -30
- package/package.json +8 -3
- package/scripts/check-repo-safety.js +84 -0
- package/scripts/claude-hook.js +33 -0
- package/scripts/headless-wrapper.js +106 -0
- package/scripts/init-clients.js +138 -7
- package/scripts/report-metrics.js +24 -119
- package/src/hooks/claude-hooks.js +424 -0
- package/src/mcp-server.js +6 -3
- package/src/metrics.js +218 -8
- package/src/orchestration/headless-wrapper.js +314 -0
- package/src/repo-safety.js +166 -0
- package/src/server.js +83 -4
- package/src/storage/sqlite.js +1092 -0
- package/src/tools/smart-metrics.js +249 -0
- package/src/tools/smart-summary.js +1230 -324
- package/src/tools/smart-turn.js +307 -0
- package/src/utils/runtime-config.js +13 -1
package/scripts/init-clients.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import fs from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -118,13 +119,32 @@ const writeFile = (filePath, content, dryRun) => {
|
|
|
118
119
|
console.log(`updated ${filePath}`);
|
|
119
120
|
};
|
|
120
121
|
|
|
122
|
+
const runGit = (args, cwd) => {
|
|
123
|
+
try {
|
|
124
|
+
const stdout = execFileSync('git', args, {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
stdout: stdout.trim(),
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
stdout: '',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
121
141
|
const getServerConfig = ({ name, command, args, cwd }) => ({
|
|
122
142
|
name,
|
|
123
143
|
config: {
|
|
124
144
|
command,
|
|
125
145
|
args,
|
|
126
146
|
env: {
|
|
127
|
-
|
|
147
|
+
DEVCTX_PROJECT_ROOT: cwd,
|
|
128
148
|
},
|
|
129
149
|
},
|
|
130
150
|
});
|
|
@@ -159,6 +179,61 @@ const updateClaudeConfig = (targetDir, serverConfig, dryRun) => {
|
|
|
159
179
|
writeFile(filePath, `${JSON.stringify(current, null, 2)}\n`, dryRun);
|
|
160
180
|
};
|
|
161
181
|
|
|
182
|
+
const buildClaudeHookCommand = (targetDir, eventName) => {
|
|
183
|
+
const scriptPath = normalizeCommandPath(path.relative(targetDir, path.join(devctxDir, 'scripts', 'claude-hook.js')));
|
|
184
|
+
return `"${process.execPath}" "${scriptPath}" --event ${eventName} --project-root "$CLAUDE_PROJECT_DIR"`;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const getClaudeHookMatcher = (eventName) => {
|
|
188
|
+
if (eventName === 'SessionStart') {
|
|
189
|
+
return 'startup|resume|clear|compact';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (eventName === 'PostToolUse') {
|
|
193
|
+
return 'Write|Edit|MultiEdit|mcp__.*__smart_turn|mcp__.*__smart_summary';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return '*';
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const upsertClaudeHook = (settings, eventName, matcher, command) => {
|
|
200
|
+
settings.hooks ??= {};
|
|
201
|
+
settings.hooks[eventName] = Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
|
|
202
|
+
|
|
203
|
+
const normalizedMatcher = matcher ?? '*';
|
|
204
|
+
const existingGroup = settings.hooks[eventName].find((group) => (group.matcher ?? '*') === normalizedMatcher);
|
|
205
|
+
|
|
206
|
+
if (existingGroup) {
|
|
207
|
+
existingGroup.hooks = Array.isArray(existingGroup.hooks) ? existingGroup.hooks : [];
|
|
208
|
+
const alreadyPresent = existingGroup.hooks.some((hook) => hook?.type === 'command' && hook?.command === command);
|
|
209
|
+
if (!alreadyPresent) {
|
|
210
|
+
existingGroup.hooks.push({ type: 'command', command });
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
settings.hooks[eventName].push({
|
|
216
|
+
matcher: normalizedMatcher,
|
|
217
|
+
hooks: [{ type: 'command', command }],
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const updateClaudeHooksConfig = (targetDir, dryRun) => {
|
|
222
|
+
const filePath = path.join(targetDir, '.claude', 'settings.json');
|
|
223
|
+
const current = readJson(filePath, {});
|
|
224
|
+
|
|
225
|
+
['SessionStart', 'UserPromptSubmit', 'PostToolUse', 'Stop'].forEach((eventName) => {
|
|
226
|
+
upsertClaudeHook(
|
|
227
|
+
current,
|
|
228
|
+
eventName,
|
|
229
|
+
getClaudeHookMatcher(eventName),
|
|
230
|
+
buildClaudeHookCommand(targetDir, eventName),
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
writeFile(filePath, `${JSON.stringify(current, null, 2)}\n`, dryRun);
|
|
235
|
+
};
|
|
236
|
+
|
|
162
237
|
const updateQwenConfig = (targetDir, serverConfig, dryRun) => {
|
|
163
238
|
const filePath = path.join(targetDir, '.qwen', 'settings.json');
|
|
164
239
|
const current = readJson(filePath, {});
|
|
@@ -241,6 +316,18 @@ const upsertTomlSection = (content, header, bodyLines) => {
|
|
|
241
316
|
return `${preserved}\n\n${section}\n`;
|
|
242
317
|
};
|
|
243
318
|
|
|
319
|
+
const upsertSentinelSection = (content, startMarker, endMarker, section) => {
|
|
320
|
+
const startIdx = content.indexOf(startMarker);
|
|
321
|
+
const endIdx = content.indexOf(endMarker);
|
|
322
|
+
|
|
323
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
324
|
+
return content.slice(0, startIdx) + section + content.slice(endIdx + endMarker.length);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const trimmed = content.trimEnd();
|
|
328
|
+
return trimmed.length === 0 ? `${section}\n` : `${trimmed}\n\n${section}\n`;
|
|
329
|
+
};
|
|
330
|
+
|
|
244
331
|
const updateCodexConfig = (targetDir, serverConfig, dryRun) => {
|
|
245
332
|
const filePath = path.join(targetDir, '.codex', 'config.toml');
|
|
246
333
|
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
@@ -249,12 +336,53 @@ const updateCodexConfig = (targetDir, serverConfig, dryRun) => {
|
|
|
249
336
|
writeFile(filePath, nextContent, dryRun);
|
|
250
337
|
};
|
|
251
338
|
|
|
339
|
+
const HOOK_SECTION_START = '# devctx:start';
|
|
340
|
+
const HOOK_SECTION_END = '# devctx:end';
|
|
341
|
+
|
|
342
|
+
const buildPreCommitHookSection = (targetDir) => {
|
|
343
|
+
const scriptPath = normalizeCommandPath(path.relative(targetDir, path.join(devctxDir, 'scripts', 'check-repo-safety.js')));
|
|
344
|
+
return `${HOOK_SECTION_START}
|
|
345
|
+
# Prevent committing project-local devctx state.
|
|
346
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
347
|
+
"${process.execPath}" "${scriptPath}" --project-root "$repo_root"
|
|
348
|
+
status=$?
|
|
349
|
+
if [ "$status" -ne 0 ]; then
|
|
350
|
+
echo "devctx: commit blocked by repo safety checks." >&2
|
|
351
|
+
exit "$status"
|
|
352
|
+
fi
|
|
353
|
+
${HOOK_SECTION_END}`;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const updatePreCommitHook = (targetDir, dryRun) => {
|
|
357
|
+
const hookPathResult = runGit(['rev-parse', '--git-path', 'hooks/pre-commit'], targetDir);
|
|
358
|
+
if (!hookPathResult.ok || !hookPathResult.stdout) {
|
|
359
|
+
console.log(`skipped git hook installation for ${targetDir} (not a git repository)`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const filePath = path.resolve(targetDir, hookPathResult.stdout);
|
|
364
|
+
const current = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
365
|
+
const nextBody = upsertSentinelSection(current, HOOK_SECTION_START, HOOK_SECTION_END, buildPreCommitHookSection(targetDir));
|
|
366
|
+
const nextContent = nextBody.startsWith('#!') ? nextBody : `#!/bin/sh\n\n${nextBody}`;
|
|
367
|
+
writeFile(filePath, nextContent, dryRun);
|
|
368
|
+
|
|
369
|
+
if (!dryRun) {
|
|
370
|
+
fs.chmodSync(filePath, 0o755);
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
252
374
|
// ---------------------------------------------------------------------------
|
|
253
375
|
// Agent rules — instruct agents to prefer devctx tools over built-in ones
|
|
254
376
|
// ---------------------------------------------------------------------------
|
|
255
377
|
|
|
256
|
-
const agentRuleBody = `Always prefer devctx MCP tools over built-in file reading, code search, and
|
|
378
|
+
const agentRuleBody = `Always prefer devctx MCP tools over built-in file reading, code search, diagnostics, and context recovery.
|
|
257
379
|
|
|
380
|
+
- For every non-trivial user prompt, start with smart_turn phase=start before exploring the codebase. Pass the current user prompt and ensureSession=true for a meaningful new task.
|
|
381
|
+
- Treat smart_turn phase=start as the default entrypoint for context recovery instead of manually deciding whether to call smart_summary(get).
|
|
382
|
+
- Inspect repoSafety from smart_turn or smart_summary responses. If .devctx/state.sqlite is tracked or staged, mutating context writes are blocked until git hygiene is fixed.
|
|
383
|
+
- If smart_turn phase=start reports ambiguous_resume, choose the recommended session explicitly with smart_summary sessionId="auto" before continuing.
|
|
384
|
+
- After meaningful milestones, end the turn with smart_turn phase=end and the appropriate event (milestone, decision, blocker, task_complete, session_end) so persistence stays event-driven.
|
|
385
|
+
- Before ending work, persist the latest nextStep with smart_turn phase=end and event=session_end.
|
|
258
386
|
- Use smart_read outline or signatures before full (~90% token savings).
|
|
259
387
|
- Use smart_read symbol (string or array) to extract specific functions/classes before editing.
|
|
260
388
|
- Use smart_read range for specific lines when you know the location.
|
|
@@ -262,14 +390,15 @@ const agentRuleBody = `Always prefer devctx MCP tools over built-in file reading
|
|
|
262
390
|
- Use smart_search instead of grep/ripgrep — it groups, ranks, and filters automatically.
|
|
263
391
|
- Pass intent to smart_search to get task-aware ranking (implementation/debug/tests/config/docs/explore).
|
|
264
392
|
- Use smart_shell for diagnostics: git status, ls, find, pwd, test output.
|
|
393
|
+
- Use smart_metrics to report token savings or recent devctx usage instead of reading metrics files manually.
|
|
265
394
|
|
|
266
395
|
By task:
|
|
267
|
-
- Debugging: smart_search with intent=debug → read signatures → inspect symbol → smart_shell for tests/errors.
|
|
268
|
-
- Review: smart_search with intent=implementation → read outline/signatures, focus on changed symbols, minimal changes.
|
|
269
|
-
- Refactor: smart_search with intent=implementation → signatures for public API, preserve behavior, small edits, verify with tests.
|
|
270
|
-
- Tests: smart_search with intent=tests → find existing tests, read symbol of function under test.
|
|
396
|
+
- Debugging: smart_turn(start) → smart_search with intent=debug → read signatures → inspect symbol → smart_shell for tests/errors → smart_turn(end event=milestone or blocker).
|
|
397
|
+
- Review: smart_turn(start) → smart_search with intent=implementation → read outline/signatures, focus on changed symbols, minimal changes → smart_turn(end event=milestone).
|
|
398
|
+
- Refactor: smart_turn(start) → smart_search with intent=implementation → signatures for public API, preserve behavior, small edits, verify with tests → smart_turn(end event=milestone).
|
|
399
|
+
- Tests: smart_turn(start) → smart_search with intent=tests → find existing tests, read symbol of function under test → smart_turn(end event=milestone).
|
|
271
400
|
- Config: smart_search with intent=config → find settings, env vars, infrastructure files.
|
|
272
|
-
- Architecture: smart_search with intent=explore → directory structure, outlines of key modules and API boundaries.`;
|
|
401
|
+
- Architecture: smart_turn(start) → smart_search with intent=explore → directory structure, outlines of key modules and API boundaries → smart_turn(end event=task_switch or milestone).`;
|
|
273
402
|
|
|
274
403
|
const cursorRuleContent = `---
|
|
275
404
|
description: Prefer devctx MCP tools for file reading, code search, and diagnostics
|
|
@@ -352,6 +481,7 @@ const main = () => {
|
|
|
352
481
|
|
|
353
482
|
const clientSet = new Set(options.clients);
|
|
354
483
|
ensureGitignoreEntry(targetDir, options.dryRun);
|
|
484
|
+
updatePreCommitHook(targetDir, options.dryRun);
|
|
355
485
|
|
|
356
486
|
if (clientSet.has('cursor')) {
|
|
357
487
|
updateCursorConfig(targetDir, serverConfig, options.dryRun);
|
|
@@ -369,6 +499,7 @@ const main = () => {
|
|
|
369
499
|
|
|
370
500
|
if (clientSet.has('claude')) {
|
|
371
501
|
updateClaudeConfig(targetDir, serverConfig, options.dryRun);
|
|
502
|
+
updateClaudeHooksConfig(targetDir, options.dryRun);
|
|
372
503
|
updateClaudeMd(targetDir, options.dryRun);
|
|
373
504
|
}
|
|
374
505
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import fs from 'node:fs';
|
|
3
2
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { smartMetrics } from '../src/tools/smart-metrics.js';
|
|
5
5
|
|
|
6
6
|
const requireValue = (argv, index, flag) => {
|
|
7
7
|
const value = argv[index + 1];
|
|
@@ -11,7 +11,7 @@ const requireValue = (argv, index, flag) => {
|
|
|
11
11
|
return value;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const parseArgs = (argv) => {
|
|
14
|
+
export const parseArgs = (argv) => {
|
|
15
15
|
const options = {
|
|
16
16
|
file: null,
|
|
17
17
|
json: false,
|
|
@@ -44,110 +44,24 @@ const parseArgs = (argv) => {
|
|
|
44
44
|
return options;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const resolveMetricsInput = (options) => {
|
|
50
|
-
if (options.file) {
|
|
51
|
-
return { filePath: options.file, source: 'explicit' };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const defaultPath = getMetricsFilePath();
|
|
55
|
-
const legacyPath = getLegacyMetricsFilePath();
|
|
56
|
-
const candidates = unique([defaultPath, legacyPath]);
|
|
57
|
-
const existing = candidates.find((filePath) => fs.existsSync(filePath));
|
|
58
|
-
|
|
59
|
-
if (existing) {
|
|
60
|
-
return {
|
|
61
|
-
filePath: existing,
|
|
62
|
-
source: existing === legacyPath ? 'legacy' : 'default',
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return { filePath: defaultPath, source: 'default' };
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const readEntries = (filePath) => {
|
|
70
|
-
if (!fs.existsSync(filePath)) {
|
|
71
|
-
throw new Error(`No metrics file found at ${filePath}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const lines = fs.readFileSync(filePath, 'utf8')
|
|
75
|
-
.split('\n')
|
|
76
|
-
.map((line) => line.trim())
|
|
77
|
-
.filter(Boolean);
|
|
78
|
-
|
|
79
|
-
const entries = [];
|
|
80
|
-
const invalidLines = [];
|
|
47
|
+
const formatNumber = (value) => new Intl.NumberFormat('en-US').format(value);
|
|
81
48
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
49
|
+
export const createReport = async (options) => {
|
|
50
|
+
const result = await smartMetrics({
|
|
51
|
+
file: options.file,
|
|
52
|
+
tool: options.tool,
|
|
53
|
+
window: 'all',
|
|
54
|
+
latest: 100,
|
|
88
55
|
});
|
|
89
|
-
|
|
90
|
-
return { entries, invalidLines };
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const getCompressedTokens = (entry) => Number(entry.compressedTokens ?? entry.finalTokens ?? 0);
|
|
94
|
-
|
|
95
|
-
const getSavedTokens = (entry, compressedTokens) => {
|
|
96
|
-
if (entry.savedTokens !== undefined) {
|
|
97
|
-
return Number(entry.savedTokens ?? 0);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return Math.max(0, Number(entry.rawTokens ?? 0) - compressedTokens);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const aggregate = (entries) => {
|
|
104
|
-
const byTool = new Map();
|
|
105
|
-
let rawTokens = 0;
|
|
106
|
-
let compressedTokens = 0;
|
|
107
|
-
let savedTokens = 0;
|
|
108
|
-
|
|
109
|
-
for (const entry of entries) {
|
|
110
|
-
const tool = entry.tool ?? 'unknown';
|
|
111
|
-
const compressedTokensForEntry = getCompressedTokens(entry);
|
|
112
|
-
const savedTokensForEntry = getSavedTokens(entry, compressedTokensForEntry);
|
|
113
|
-
const current = byTool.get(tool) ?? {
|
|
114
|
-
tool,
|
|
115
|
-
count: 0,
|
|
116
|
-
rawTokens: 0,
|
|
117
|
-
compressedTokens: 0,
|
|
118
|
-
savedTokens: 0,
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
current.count += 1;
|
|
122
|
-
current.rawTokens += Number(entry.rawTokens ?? 0);
|
|
123
|
-
current.compressedTokens += compressedTokensForEntry;
|
|
124
|
-
current.savedTokens += savedTokensForEntry;
|
|
125
|
-
byTool.set(tool, current);
|
|
126
|
-
|
|
127
|
-
rawTokens += Number(entry.rawTokens ?? 0);
|
|
128
|
-
compressedTokens += compressedTokensForEntry;
|
|
129
|
-
savedTokens += savedTokensForEntry;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const tools = [...byTool.values()]
|
|
133
|
-
.map((item) => ({
|
|
134
|
-
...item,
|
|
135
|
-
savingsPct: item.rawTokens > 0 ? +((item.savedTokens / item.rawTokens) * 100).toFixed(2) : 0,
|
|
136
|
-
}))
|
|
137
|
-
.sort((a, b) => b.savedTokens - a.savedTokens || b.count - a.count || a.tool.localeCompare(b.tool));
|
|
138
|
-
|
|
139
56
|
return {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
tools,
|
|
57
|
+
filePath: result.filePath,
|
|
58
|
+
source: result.source,
|
|
59
|
+
toolFilter: options.tool,
|
|
60
|
+
invalidLines: result.invalidLines,
|
|
61
|
+
summary: result.summary,
|
|
146
62
|
};
|
|
147
63
|
};
|
|
148
64
|
|
|
149
|
-
const formatNumber = (value) => new Intl.NumberFormat('en-US').format(value);
|
|
150
|
-
|
|
151
65
|
const printHuman = (report) => {
|
|
152
66
|
console.log('');
|
|
153
67
|
console.log('devctx metrics report');
|
|
@@ -176,20 +90,9 @@ const printHuman = (report) => {
|
|
|
176
90
|
}
|
|
177
91
|
};
|
|
178
92
|
|
|
179
|
-
const main = () => {
|
|
93
|
+
export const main = async () => {
|
|
180
94
|
const options = parseArgs(process.argv.slice(2));
|
|
181
|
-
const
|
|
182
|
-
const { entries, invalidLines } = readEntries(resolved.filePath);
|
|
183
|
-
const filteredEntries = options.tool ? entries.filter((entry) => entry.tool === options.tool) : entries;
|
|
184
|
-
const summary = aggregate(filteredEntries);
|
|
185
|
-
|
|
186
|
-
const report = {
|
|
187
|
-
filePath: resolved.filePath,
|
|
188
|
-
source: resolved.source,
|
|
189
|
-
toolFilter: options.tool,
|
|
190
|
-
invalidLines,
|
|
191
|
-
summary,
|
|
192
|
-
};
|
|
95
|
+
const report = await createReport(options);
|
|
193
96
|
|
|
194
97
|
if (options.json) {
|
|
195
98
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
@@ -199,9 +102,11 @@ const main = () => {
|
|
|
199
102
|
printHuman(report);
|
|
200
103
|
};
|
|
201
104
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
105
|
+
const isDirectExecution = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
106
|
+
|
|
107
|
+
if (isDirectExecution) {
|
|
108
|
+
main().catch((error) => {
|
|
109
|
+
console.error(error.message);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
});
|
|
207
112
|
}
|