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.
@@ -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 diagnostics.
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 { getLegacyMetricsFilePath, getMetricsFilePath } from '../src/metrics.js';
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 resolved = resolveMetricsInput(options);
153
- const { entries, invalidLines } = readEntries(resolved.filePath);
154
- const filteredEntries = options.tool ? entries.filter((entry) => entry.tool === options.tool) : entries;
155
- const summary = aggregate(filteredEntries);
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: resolved.filePath,
159
- source: resolved.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
- try {
207
- main();
208
- } catch (error) {
209
- console.error(error.message);
210
- process.exit(1);
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
  }