smart-context-mcp 1.0.3 → 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 +139 -26
- 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 +137 -6
- package/scripts/report-metrics.js +22 -121
- package/src/hooks/claude-hooks.js +424 -0
- 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/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,6 +119,25 @@ 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: {
|
|
@@ -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];
|
|
@@ -44,122 +44,21 @@ export const parseArgs = (argv) => {
|
|
|
44
44
|
return options;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const unique = (items) => [...new Set(items.filter(Boolean))];
|
|
48
|
-
|
|
49
|
-
export 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
|
-
export 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 = [];
|
|
81
|
-
|
|
82
|
-
lines.forEach((line, index) => {
|
|
83
|
-
try {
|
|
84
|
-
entries.push(JSON.parse(line));
|
|
85
|
-
} catch {
|
|
86
|
-
invalidLines.push(index + 1);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
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
|
-
export 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
|
-
return {
|
|
140
|
-
count: entries.length,
|
|
141
|
-
rawTokens,
|
|
142
|
-
compressedTokens,
|
|
143
|
-
savedTokens,
|
|
144
|
-
savingsPct: rawTokens > 0 ? +((savedTokens / rawTokens) * 100).toFixed(2) : 0,
|
|
145
|
-
tools,
|
|
146
|
-
};
|
|
147
|
-
};
|
|
148
|
-
|
|
149
47
|
const formatNumber = (value) => new Intl.NumberFormat('en-US').format(value);
|
|
150
48
|
|
|
151
|
-
export const createReport = (options) => {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
|
55
|
+
});
|
|
157
56
|
return {
|
|
158
|
-
filePath:
|
|
159
|
-
source:
|
|
57
|
+
filePath: result.filePath,
|
|
58
|
+
source: result.source,
|
|
160
59
|
toolFilter: options.tool,
|
|
161
|
-
invalidLines,
|
|
162
|
-
summary,
|
|
60
|
+
invalidLines: result.invalidLines,
|
|
61
|
+
summary: result.summary,
|
|
163
62
|
};
|
|
164
63
|
};
|
|
165
64
|
|
|
@@ -191,9 +90,9 @@ const printHuman = (report) => {
|
|
|
191
90
|
}
|
|
192
91
|
};
|
|
193
92
|
|
|
194
|
-
export const main = () => {
|
|
93
|
+
export const main = async () => {
|
|
195
94
|
const options = parseArgs(process.argv.slice(2));
|
|
196
|
-
const report = createReport(options);
|
|
95
|
+
const report = await createReport(options);
|
|
197
96
|
|
|
198
97
|
if (options.json) {
|
|
199
98
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
@@ -203,9 +102,11 @@ export const main = () => {
|
|
|
203
102
|
printHuman(report);
|
|
204
103
|
};
|
|
205
104
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
});
|
|
211
112
|
}
|