smart-context-mcp 1.3.0 → 1.4.0
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 +98 -16
- package/package.json +1 -1
- package/src/config/ignored-paths.js +21 -0
- package/src/cross-project.js +2 -62
- package/src/decision-explainer.js +15 -50
- package/src/index.js +2 -4
- package/src/missed-opportunities.js +26 -60
- package/src/server.js +49 -1
- package/src/storage/sqlite.js +1 -1
- package/src/tools/smart-context.js +33 -527
- package/src/tools/smart-edit.js +105 -0
- package/src/tools/smart-read.js +63 -10
- package/src/tools/smart-search.js +4 -2
- package/src/tools/smart-status.js +201 -0
- package/src/tools/smart-summary.js +29 -0
- package/src/usage-feedback.js +7 -68
- package/src/utils/context-scoring.js +400 -0
- package/src/utils/fs.js +13 -7
- package/src/utils/query-extraction.js +180 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readTextFile, writeTextFile } from '../utils/fs.js';
|
|
2
|
+
import { recordToolUsage } from '../usage-feedback.js';
|
|
3
|
+
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
4
|
+
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
5
|
+
import { countTokens } from '../tokenCounter.js';
|
|
6
|
+
import { persistMetrics } from '../metrics.js';
|
|
7
|
+
|
|
8
|
+
export const smartEdit = ({ pattern, replacement, files, mode = 'literal', dryRun = false }) => {
|
|
9
|
+
const startTime = Date.now();
|
|
10
|
+
|
|
11
|
+
recordDecision({
|
|
12
|
+
tool: 'smart_edit',
|
|
13
|
+
reason: DECISION_REASONS.BATCH_OPERATION,
|
|
14
|
+
benefit: EXPECTED_BENEFITS.EFFICIENCY,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
recordDevctxOperation('smart_edit');
|
|
18
|
+
|
|
19
|
+
const results = [];
|
|
20
|
+
let totalMatches = 0;
|
|
21
|
+
let totalReplacements = 0;
|
|
22
|
+
|
|
23
|
+
for (const filePath of files) {
|
|
24
|
+
try {
|
|
25
|
+
const { content } = readTextFile(filePath);
|
|
26
|
+
let newContent;
|
|
27
|
+
let matches = 0;
|
|
28
|
+
|
|
29
|
+
if (mode === 'regex') {
|
|
30
|
+
const regex = new RegExp(pattern, 'gm');
|
|
31
|
+
const matchesArray = content.match(regex);
|
|
32
|
+
matches = matchesArray ? matchesArray.length : 0;
|
|
33
|
+
newContent = content.replace(regex, replacement);
|
|
34
|
+
} else {
|
|
35
|
+
const parts = content.split(pattern);
|
|
36
|
+
matches = parts.length - 1;
|
|
37
|
+
newContent = parts.join(replacement);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (matches > 0) {
|
|
41
|
+
if (!dryRun) {
|
|
42
|
+
writeTextFile(filePath, newContent);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
results.push({
|
|
46
|
+
file: filePath,
|
|
47
|
+
matches,
|
|
48
|
+
replaced: !dryRun,
|
|
49
|
+
preview: dryRun ? {
|
|
50
|
+
before: content.substring(0, 200),
|
|
51
|
+
after: newContent.substring(0, 200),
|
|
52
|
+
} : undefined,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
totalMatches += matches;
|
|
56
|
+
if (!dryRun) totalReplacements += matches;
|
|
57
|
+
} else {
|
|
58
|
+
results.push({
|
|
59
|
+
file: filePath,
|
|
60
|
+
matches: 0,
|
|
61
|
+
replaced: false,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
results.push({
|
|
66
|
+
file: filePath,
|
|
67
|
+
error: error.message,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const response = {
|
|
73
|
+
success: true,
|
|
74
|
+
mode,
|
|
75
|
+
pattern,
|
|
76
|
+
dryRun,
|
|
77
|
+
totalFiles: files.length,
|
|
78
|
+
filesModified: results.filter(r => r.matches > 0).length,
|
|
79
|
+
totalMatches,
|
|
80
|
+
totalReplacements,
|
|
81
|
+
results,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const outputStr = JSON.stringify(response);
|
|
85
|
+
const tokens = countTokens(outputStr);
|
|
86
|
+
|
|
87
|
+
persistMetrics({
|
|
88
|
+
tool: 'smart_edit',
|
|
89
|
+
rawTokens: 0,
|
|
90
|
+
compressedTokens: tokens,
|
|
91
|
+
savedTokens: 0,
|
|
92
|
+
savingsPct: 0,
|
|
93
|
+
latencyMs: Date.now() - startTime,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
recordToolUsage({
|
|
97
|
+
tool: 'smart_edit',
|
|
98
|
+
rawTokens: 0,
|
|
99
|
+
compressedTokens: tokens,
|
|
100
|
+
savedTokens: 0,
|
|
101
|
+
savingsPct: 0,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return response;
|
|
105
|
+
};
|
package/src/tools/smart-read.js
CHANGED
|
@@ -70,11 +70,11 @@ const extractRange = (content, startLine, endLine) => {
|
|
|
70
70
|
return truncate(numbered.join('\n'), 12000);
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
-
const lookupIndexLine = (fullPath, symbolName) => {
|
|
73
|
+
const lookupIndexLine = (fullPath, symbolName, root = projectRoot) => {
|
|
74
74
|
try {
|
|
75
|
-
const index = loadIndex(
|
|
75
|
+
const index = loadIndex(root);
|
|
76
76
|
if (!index) return { line: undefined, used: false };
|
|
77
|
-
const relPath = path.relative(
|
|
77
|
+
const relPath = path.relative(root, fullPath).replace(/\\/g, '/');
|
|
78
78
|
const hits = queryIndex(index, symbolName);
|
|
79
79
|
const match = hits.find((h) => h.path === relPath);
|
|
80
80
|
return { line: match?.line, used: !!match };
|
|
@@ -255,6 +255,45 @@ export const grepSymbolInFile = async (absPath, symbol) => {
|
|
|
255
255
|
}
|
|
256
256
|
};
|
|
257
257
|
|
|
258
|
+
export const grepMultipleSymbolsInFile = async (absPath, symbols) => {
|
|
259
|
+
if (symbols.length === 0) return {};
|
|
260
|
+
if (symbols.length === 1) {
|
|
261
|
+
const matches = await grepSymbolInFile(absPath, symbols[0]);
|
|
262
|
+
return { [symbols[0]]: matches };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const args = ['--line-number', '--no-heading', '--fixed-strings', '--max-count', '5'];
|
|
267
|
+
for (const sym of symbols) {
|
|
268
|
+
args.push('-e', sym);
|
|
269
|
+
}
|
|
270
|
+
args.push(absPath);
|
|
271
|
+
|
|
272
|
+
const { stdout } = await execFile(rgPath, args, { timeout: 3000 });
|
|
273
|
+
const result = {};
|
|
274
|
+
for (const sym of symbols) result[sym] = [];
|
|
275
|
+
|
|
276
|
+
for (const line of stdout.split('\n').filter(Boolean)) {
|
|
277
|
+
const sep = line.indexOf(':');
|
|
278
|
+
if (sep === -1) continue;
|
|
279
|
+
const formatted = `${line.substring(0, sep)}|${line.substring(sep + 1)}`;
|
|
280
|
+
const content = line.substring(sep + 1);
|
|
281
|
+
|
|
282
|
+
for (const sym of symbols) {
|
|
283
|
+
if (content.includes(sym)) {
|
|
284
|
+
result[sym].push(formatted);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return result;
|
|
290
|
+
} catch {
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const sym of symbols) result[sym] = [];
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
258
297
|
const TYPE_REF_RE = /:\s*([A-Z][A-Za-z0-9_]+)|<([A-Z][A-Za-z0-9_]+)>|(?:extends|implements)\s+([A-Z][A-Za-z0-9_]+)/g;
|
|
259
298
|
|
|
260
299
|
export const extractTypeReferences = (definitionText, index, relPath) => {
|
|
@@ -292,8 +331,10 @@ export const buildSymbolContext = async (fullPath, symbolNames, root) => {
|
|
|
292
331
|
for (const caller of related.importedBy.slice(0, 5)) {
|
|
293
332
|
const callerAbs = path.join(root, caller);
|
|
294
333
|
if (!fs.existsSync(callerAbs)) continue;
|
|
334
|
+
|
|
335
|
+
const matchesBySymbol = await grepMultipleSymbolsInFile(callerAbs, symbolNames);
|
|
295
336
|
for (const sym of symbolNames) {
|
|
296
|
-
const matches =
|
|
337
|
+
const matches = matchesBySymbol[sym] || [];
|
|
297
338
|
if (matches.length > 0) {
|
|
298
339
|
sections.callers.push({ file: caller, symbol: sym, lines: matches.slice(0, 3) });
|
|
299
340
|
}
|
|
@@ -303,8 +344,10 @@ export const buildSymbolContext = async (fullPath, symbolNames, root) => {
|
|
|
303
344
|
for (const testFile of related.tests.slice(0, 3)) {
|
|
304
345
|
const testAbs = path.join(root, testFile);
|
|
305
346
|
if (!fs.existsSync(testAbs)) continue;
|
|
347
|
+
|
|
348
|
+
const matchesBySymbol = await grepMultipleSymbolsInFile(testAbs, symbolNames);
|
|
306
349
|
for (const sym of symbolNames) {
|
|
307
|
-
const matches =
|
|
350
|
+
const matches = matchesBySymbol[sym] || [];
|
|
308
351
|
if (matches.length > 0) {
|
|
309
352
|
sections.tests.push({ file: testFile, symbol: sym, lines: matches.slice(0, 3) });
|
|
310
353
|
}
|
|
@@ -365,11 +408,12 @@ const formatContextSections = (sections) => {
|
|
|
365
408
|
return parts.length > 0 ? '\n' + parts.join('\n') : '';
|
|
366
409
|
};
|
|
367
410
|
|
|
368
|
-
export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext }) => {
|
|
411
|
+
export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine, symbol, maxTokens, context: includeContext, cwd }) => {
|
|
369
412
|
let fullPath, content;
|
|
413
|
+
const effectiveRoot = cwd || projectRoot;
|
|
370
414
|
|
|
371
415
|
try {
|
|
372
|
-
const result = readTextFile(filePath);
|
|
416
|
+
const result = readTextFile(filePath, effectiveRoot);
|
|
373
417
|
fullPath = result.fullPath;
|
|
374
418
|
content = result.content;
|
|
375
419
|
} catch (error) {
|
|
@@ -400,6 +444,15 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
400
444
|
const r = cachedRange(content, startLine, endLine, fullPath, mtime);
|
|
401
445
|
compressedText = r.text;
|
|
402
446
|
cacheHit = r.cached;
|
|
447
|
+
} else if (mode === 'outline' && (startLine || endLine)) {
|
|
448
|
+
const lines = content.split('\n');
|
|
449
|
+
const start = Math.max(0, (startLine ?? 1) - 1);
|
|
450
|
+
const end = endLine ?? lines.length;
|
|
451
|
+
const rangeContent = lines.slice(start, end).join('\n');
|
|
452
|
+
const g = cachedGenerate(fullPath, extension, rangeContent, 'outline', mtime);
|
|
453
|
+
compressedText = g.text;
|
|
454
|
+
cacheHit = g.cached;
|
|
455
|
+
effectiveMode = 'outline';
|
|
403
456
|
} else if (mode === 'symbol') {
|
|
404
457
|
const sym = cachedSymbol(fullPath, content, symbol, mtime);
|
|
405
458
|
compressedText = sym.text;
|
|
@@ -430,7 +483,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
430
483
|
|
|
431
484
|
if (mode === 'symbol' && includeContext && symbol) {
|
|
432
485
|
const symbolNames = Array.isArray(symbol) ? symbol : [symbol];
|
|
433
|
-
const { sections, hints } = await buildSymbolContext(fullPath, symbolNames,
|
|
486
|
+
const { sections, hints } = await buildSymbolContext(fullPath, symbolNames, effectiveRoot);
|
|
434
487
|
const contextText = formatContextSections(sections);
|
|
435
488
|
if (contextText) compressedText += contextText;
|
|
436
489
|
contextResult = {
|
|
@@ -461,7 +514,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
461
514
|
recordToolUsage({
|
|
462
515
|
tool: 'smart_read',
|
|
463
516
|
savedTokens: metrics.savedTokens,
|
|
464
|
-
target: path.relative(
|
|
517
|
+
target: path.relative(effectiveRoot, fullPath),
|
|
465
518
|
});
|
|
466
519
|
|
|
467
520
|
// Record devctx operation for missed opportunity detection
|
|
@@ -482,7 +535,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
482
535
|
|
|
483
536
|
recordDecision({
|
|
484
537
|
tool: 'smart_read',
|
|
485
|
-
action: `read ${path.relative(
|
|
538
|
+
action: `read ${path.relative(effectiveRoot, fullPath)} (${effectiveMode} mode)`,
|
|
486
539
|
reason,
|
|
487
540
|
alternative: 'Read (full file)',
|
|
488
541
|
expectedBenefit,
|
|
@@ -11,6 +11,7 @@ import { truncate } from '../utils/text.js';
|
|
|
11
11
|
import { recordToolUsage } from '../usage-feedback.js';
|
|
12
12
|
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
13
13
|
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
14
|
+
import { IGNORED_DIRS, IGNORED_FILE_NAMES } from '../config/ignored-paths.js';
|
|
14
15
|
|
|
15
16
|
const execFile = promisify(execFileCallback);
|
|
16
17
|
const supportedGlobs = [
|
|
@@ -19,8 +20,8 @@ const supportedGlobs = [
|
|
|
19
20
|
'*.go', '*.rs', '*.java', '*.sh', '*.bash', '*.zsh', '*.tf', '*.tfvars', '*.hcl',
|
|
20
21
|
'Dockerfile', 'Dockerfile.*',
|
|
21
22
|
];
|
|
22
|
-
const ignoredDirs =
|
|
23
|
-
const ignoredFileNames = new Set(
|
|
23
|
+
const ignoredDirs = IGNORED_DIRS;
|
|
24
|
+
const ignoredFileNames = new Set(IGNORED_FILE_NAMES);
|
|
24
25
|
const fallbackExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.json', '.mjs', '.cjs', '.py', '.toml', '.yaml', '.yml', '.md', '.graphql', '.gql', '.sql', '.go', '.rs', '.java', '.sh', '.bash', '.zsh', '.tf', '.tfvars', '.hcl']);
|
|
25
26
|
const likelySourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx', '.py', '.graphql', '.gql', '.sql', '.go', '.rs', '.java', '.sh', '.bash', '.zsh']);
|
|
26
27
|
const likelyConfigExtensions = new Set(['.json', '.toml', '.yaml', '.yml', '.tf', '.tfvars', '.hcl']);
|
|
@@ -127,6 +128,7 @@ const searchWithRipgrep = async (root, query) => {
|
|
|
127
128
|
.filter((match) => !shouldIgnoreFile(match.file));
|
|
128
129
|
} catch (error) {
|
|
129
130
|
if (error.code === 1) return [];
|
|
131
|
+
console.error('[smart-search] ripgrep failed:', error.message, { code: error.code, signal: error.signal });
|
|
130
132
|
return null;
|
|
131
133
|
}
|
|
132
134
|
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { withStateDb } from '../storage/sqlite.js';
|
|
2
|
+
import { recordToolUsage } from '../usage-feedback.js';
|
|
3
|
+
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
4
|
+
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
5
|
+
import { countTokens } from '../tokenCounter.js';
|
|
6
|
+
|
|
7
|
+
const ACTIVE_SESSION_SCOPE = 'active';
|
|
8
|
+
|
|
9
|
+
const getActiveSession = async () => {
|
|
10
|
+
return withStateDb((db) => {
|
|
11
|
+
let activeSessionId = db.prepare(`
|
|
12
|
+
SELECT session_id
|
|
13
|
+
FROM active_session
|
|
14
|
+
WHERE scope = ?
|
|
15
|
+
`).get(ACTIVE_SESSION_SCOPE)?.session_id;
|
|
16
|
+
|
|
17
|
+
if (!activeSessionId) {
|
|
18
|
+
const mostRecent = db.prepare(`
|
|
19
|
+
SELECT session_id
|
|
20
|
+
FROM sessions
|
|
21
|
+
ORDER BY updated_at DESC
|
|
22
|
+
LIMIT 1
|
|
23
|
+
`).get();
|
|
24
|
+
|
|
25
|
+
if (!mostRecent) return null;
|
|
26
|
+
activeSessionId = mostRecent.session_id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const row = db.prepare(`
|
|
30
|
+
SELECT session_id, goal, status, next_step, current_focus, why_blocked,
|
|
31
|
+
snapshot_json, completed_count, decisions_count, touched_files_count, updated_at
|
|
32
|
+
FROM sessions
|
|
33
|
+
WHERE session_id = ?
|
|
34
|
+
`).get(activeSessionId);
|
|
35
|
+
|
|
36
|
+
if (!row) return null;
|
|
37
|
+
|
|
38
|
+
const parseJsonField = (field) => {
|
|
39
|
+
if (!field) return [];
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(field);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const snapshot = parseJsonField(row.snapshot_json);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
sessionId: row.session_id,
|
|
51
|
+
goal: row.goal || 'Untitled session',
|
|
52
|
+
status: row.status || 'in_progress',
|
|
53
|
+
nextStep: row.next_step,
|
|
54
|
+
currentFocus: row.current_focus,
|
|
55
|
+
whyBlocked: row.why_blocked,
|
|
56
|
+
pinnedContext: snapshot.pinnedContext || [],
|
|
57
|
+
unresolvedQuestions: snapshot.unresolvedQuestions || [],
|
|
58
|
+
completed: snapshot.completed || [],
|
|
59
|
+
decisions: snapshot.decisions || [],
|
|
60
|
+
touchedFiles: snapshot.touchedFiles || [],
|
|
61
|
+
completedCount: row.completed_count || 0,
|
|
62
|
+
decisionsCount: row.decisions_count || 0,
|
|
63
|
+
touchedFilesCount: row.touched_files_count || 0,
|
|
64
|
+
updatedAt: row.updated_at,
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const formatContextItem = (item, index, total) => {
|
|
70
|
+
const prefix = index === total - 1 ? '└─' : '├─';
|
|
71
|
+
return `${prefix} ${item}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const formatSection = (title, items, emptyMessage = 'None') => {
|
|
75
|
+
if (!items || items.length === 0) {
|
|
76
|
+
return `${title}:\n ${emptyMessage}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const formatted = items.map((item, i) => formatContextItem(item, i, items.length)).join('\n ');
|
|
80
|
+
return `${title} (${items.length}):\n ${formatted}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const compactPath = (filePath) => {
|
|
84
|
+
if (!filePath) return filePath;
|
|
85
|
+
const parts = filePath.split('/');
|
|
86
|
+
if (parts.length <= 3) return filePath;
|
|
87
|
+
return `.../${parts.slice(-3).join('/')}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const smartStatus = async ({ format = 'detailed', maxItems = 10 } = {}) => {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
|
|
93
|
+
recordDecision({
|
|
94
|
+
tool: 'smart_status',
|
|
95
|
+
reason: DECISION_REASONS.CONTEXT_VISIBILITY,
|
|
96
|
+
benefit: EXPECTED_BENEFITS.TRANSPARENCY,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
recordDevctxOperation('smart_status');
|
|
100
|
+
|
|
101
|
+
const session = await getActiveSession();
|
|
102
|
+
|
|
103
|
+
if (!session) {
|
|
104
|
+
const response = {
|
|
105
|
+
success: false,
|
|
106
|
+
message: 'No active session found',
|
|
107
|
+
hint: 'Use smart_summary with action=update to create a session',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
recordToolUsage({
|
|
111
|
+
tool: 'smart_status',
|
|
112
|
+
rawTokens: 0,
|
|
113
|
+
compressedTokens: countTokens(JSON.stringify(response)),
|
|
114
|
+
savedTokens: 0,
|
|
115
|
+
savingsPct: 0,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return response;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const recentCompleted = session.completed.slice(-maxItems);
|
|
122
|
+
const recentDecisions = session.decisions.slice(-maxItems);
|
|
123
|
+
const recentFiles = session.touchedFiles.slice(-maxItems).map(compactPath);
|
|
124
|
+
|
|
125
|
+
let output;
|
|
126
|
+
|
|
127
|
+
if (format === 'compact') {
|
|
128
|
+
output = {
|
|
129
|
+
sessionId: session.sessionId,
|
|
130
|
+
status: session.status,
|
|
131
|
+
nextStep: session.nextStep,
|
|
132
|
+
stats: {
|
|
133
|
+
completed: session.completedCount,
|
|
134
|
+
decisions: session.decisionsCount,
|
|
135
|
+
files: session.touchedFilesCount,
|
|
136
|
+
},
|
|
137
|
+
recentFiles: recentFiles.slice(-3),
|
|
138
|
+
updatedAt: session.updatedAt,
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
const sections = [
|
|
142
|
+
`📋 Session: ${session.sessionId}`,
|
|
143
|
+
`🎯 Goal: ${session.goal}`,
|
|
144
|
+
`📊 Status: ${session.status}`,
|
|
145
|
+
session.nextStep ? `⏭️ Next: ${session.nextStep}` : null,
|
|
146
|
+
session.currentFocus ? `🔍 Focus: ${session.currentFocus}` : null,
|
|
147
|
+
session.whyBlocked ? `🚫 Blocked: ${session.whyBlocked}` : null,
|
|
148
|
+
'',
|
|
149
|
+
formatSection('✅ Completed', recentCompleted, 'No tasks completed yet'),
|
|
150
|
+
'',
|
|
151
|
+
formatSection('💡 Key Decisions', recentDecisions, 'No decisions recorded yet'),
|
|
152
|
+
'',
|
|
153
|
+
formatSection('📁 Touched Files', recentFiles, 'No files modified yet'),
|
|
154
|
+
'',
|
|
155
|
+
session.pinnedContext.length > 0 ? formatSection('📌 Pinned Context', session.pinnedContext) : null,
|
|
156
|
+
session.unresolvedQuestions.length > 0 ? formatSection('❓ Unresolved Questions', session.unresolvedQuestions) : null,
|
|
157
|
+
'',
|
|
158
|
+
`📈 Totals: ${session.completedCount} completed, ${session.decisionsCount} decisions, ${session.touchedFilesCount} files`,
|
|
159
|
+
`🕐 Updated: ${new Date(session.updatedAt).toLocaleString()}`,
|
|
160
|
+
].filter(Boolean).join('\n');
|
|
161
|
+
|
|
162
|
+
output = {
|
|
163
|
+
success: true,
|
|
164
|
+
sessionId: session.sessionId,
|
|
165
|
+
status: session.status,
|
|
166
|
+
summary: sections,
|
|
167
|
+
context: {
|
|
168
|
+
goal: session.goal,
|
|
169
|
+
nextStep: session.nextStep,
|
|
170
|
+
currentFocus: session.currentFocus,
|
|
171
|
+
whyBlocked: session.whyBlocked,
|
|
172
|
+
stats: {
|
|
173
|
+
completed: session.completedCount,
|
|
174
|
+
decisions: session.decisionsCount,
|
|
175
|
+
files: session.touchedFilesCount,
|
|
176
|
+
},
|
|
177
|
+
recent: {
|
|
178
|
+
completed: recentCompleted,
|
|
179
|
+
decisions: recentDecisions,
|
|
180
|
+
files: recentFiles,
|
|
181
|
+
},
|
|
182
|
+
pinned: session.pinnedContext,
|
|
183
|
+
questions: session.unresolvedQuestions,
|
|
184
|
+
},
|
|
185
|
+
updatedAt: session.updatedAt,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const outputStr = JSON.stringify(output);
|
|
190
|
+
const tokens = countTokens(outputStr);
|
|
191
|
+
|
|
192
|
+
recordToolUsage({
|
|
193
|
+
tool: 'smart_status',
|
|
194
|
+
rawTokens: 0,
|
|
195
|
+
compressedTokens: tokens,
|
|
196
|
+
savedTokens: 0,
|
|
197
|
+
savingsPct: 0,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return output;
|
|
201
|
+
};
|
|
@@ -1106,8 +1106,37 @@ export const smartSummary = async ({
|
|
|
1106
1106
|
keepLatestMetrics,
|
|
1107
1107
|
vacuum,
|
|
1108
1108
|
apply,
|
|
1109
|
+
goal,
|
|
1110
|
+
status,
|
|
1111
|
+
nextStep,
|
|
1112
|
+
currentFocus,
|
|
1113
|
+
whyBlocked,
|
|
1114
|
+
pinnedContext,
|
|
1115
|
+
unresolvedQuestions,
|
|
1116
|
+
blockers,
|
|
1117
|
+
completed,
|
|
1118
|
+
decisions,
|
|
1119
|
+
touchedFiles,
|
|
1109
1120
|
} = {}) => {
|
|
1110
1121
|
const startTime = Date.now();
|
|
1122
|
+
|
|
1123
|
+
if (!update && (goal || status || nextStep || currentFocus || whyBlocked ||
|
|
1124
|
+
pinnedContext || unresolvedQuestions || blockers || completed || decisions || touchedFiles)) {
|
|
1125
|
+
update = {
|
|
1126
|
+
goal,
|
|
1127
|
+
status,
|
|
1128
|
+
nextStep,
|
|
1129
|
+
currentFocus,
|
|
1130
|
+
whyBlocked,
|
|
1131
|
+
pinnedContext,
|
|
1132
|
+
unresolvedQuestions,
|
|
1133
|
+
blockers,
|
|
1134
|
+
completed,
|
|
1135
|
+
decisions,
|
|
1136
|
+
touchedFiles,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1111
1140
|
const mutationSafety = getMutationSafetyPolicy();
|
|
1112
1141
|
const shouldBlockWrites = SUMMARY_WRITE_ACTIONS.has(action) && mutationSafety.shouldBlock;
|
|
1113
1142
|
const allowReadSideEffects = !mutationSafety.shouldBlock;
|
package/src/usage-feedback.js
CHANGED
|
@@ -1,67 +1,28 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Usage feedback system - tracks devctx tool usage in current session
|
|
3
|
-
* and provides visible feedback to users about what tools were used and tokens saved.
|
|
4
|
-
*
|
|
5
|
-
* Enable with environment variable: DEVCTX_SHOW_USAGE=true
|
|
6
|
-
*
|
|
7
|
-
* Auto-enabled for first 10 tool calls (onboarding mode), then auto-disables.
|
|
8
|
-
* User can explicitly enable/disable at any time.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
const sessionUsage = {
|
|
12
|
-
tools: new Map(),
|
|
2
|
+
tools: new Map(),
|
|
13
3
|
totalSavedTokens: 0,
|
|
14
|
-
enabled:
|
|
4
|
+
enabled: true,
|
|
15
5
|
totalToolCalls: 0,
|
|
16
|
-
onboardingMode: true,
|
|
17
|
-
ONBOARDING_THRESHOLD: 10, // Auto-disable after 10 tool calls
|
|
18
6
|
};
|
|
19
7
|
|
|
20
|
-
/**
|
|
21
|
-
* Check if usage feedback is enabled
|
|
22
|
-
*
|
|
23
|
-
* Priority:
|
|
24
|
-
* 1. Explicit env var (DEVCTX_SHOW_USAGE=true/false)
|
|
25
|
-
* 2. Onboarding mode (first 10 tool calls)
|
|
26
|
-
* 3. Default: disabled
|
|
27
|
-
*/
|
|
28
8
|
export const isFeedbackEnabled = () => {
|
|
29
9
|
const envValue = process.env.DEVCTX_SHOW_USAGE?.toLowerCase();
|
|
30
10
|
|
|
31
|
-
// Explicit enable
|
|
32
11
|
if (envValue === 'true' || envValue === '1' || envValue === 'yes') {
|
|
33
12
|
sessionUsage.enabled = true;
|
|
34
|
-
sessionUsage.onboardingMode = false;
|
|
35
13
|
return true;
|
|
36
14
|
}
|
|
37
15
|
|
|
38
|
-
// Explicit disable
|
|
39
16
|
if (envValue === 'false' || envValue === '0' || envValue === 'no') {
|
|
40
17
|
sessionUsage.enabled = false;
|
|
41
|
-
sessionUsage.onboardingMode = false;
|
|
42
18
|
return false;
|
|
43
19
|
}
|
|
44
20
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
sessionUsage.enabled = true;
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// After onboarding threshold, auto-disable
|
|
52
|
-
if (sessionUsage.onboardingMode && sessionUsage.totalToolCalls >= sessionUsage.ONBOARDING_THRESHOLD) {
|
|
53
|
-
sessionUsage.enabled = false;
|
|
54
|
-
sessionUsage.onboardingMode = false;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return sessionUsage.enabled;
|
|
21
|
+
sessionUsage.enabled = true;
|
|
22
|
+
return true;
|
|
58
23
|
};
|
|
59
24
|
|
|
60
|
-
/**
|
|
61
|
-
* Record tool usage for feedback
|
|
62
|
-
*/
|
|
63
25
|
export const recordToolUsage = ({ tool, savedTokens = 0, target = null }) => {
|
|
64
|
-
// Increment total tool calls (for onboarding mode)
|
|
65
26
|
sessionUsage.totalToolCalls += 1;
|
|
66
27
|
|
|
67
28
|
if (!isFeedbackEnabled()) return;
|
|
@@ -75,24 +36,18 @@ export const recordToolUsage = ({ tool, savedTokens = 0, target = null }) => {
|
|
|
75
36
|
sessionUsage.totalSavedTokens += savedTokens;
|
|
76
37
|
};
|
|
77
38
|
|
|
78
|
-
/**
|
|
79
|
-
* Get current session usage stats
|
|
80
|
-
*/
|
|
81
39
|
export const getSessionUsage = () => {
|
|
82
40
|
return {
|
|
83
41
|
tools: Array.from(sessionUsage.tools.entries()).map(([tool, stats]) => ({
|
|
84
42
|
tool,
|
|
85
43
|
count: stats.count,
|
|
86
44
|
savedTokens: stats.savedTokens,
|
|
87
|
-
targets: stats.targets.slice(-3),
|
|
45
|
+
targets: stats.targets.slice(-3),
|
|
88
46
|
})),
|
|
89
47
|
totalSavedTokens: sessionUsage.totalSavedTokens,
|
|
90
48
|
};
|
|
91
49
|
};
|
|
92
50
|
|
|
93
|
-
/**
|
|
94
|
-
* Format usage feedback as markdown
|
|
95
|
-
*/
|
|
96
51
|
export const formatUsageFeedback = () => {
|
|
97
52
|
if (!isFeedbackEnabled()) return '';
|
|
98
53
|
|
|
@@ -128,31 +83,18 @@ export const formatUsageFeedback = () => {
|
|
|
128
83
|
}
|
|
129
84
|
|
|
130
85
|
lines.push('');
|
|
131
|
-
|
|
132
|
-
// Show onboarding message if in onboarding mode
|
|
133
|
-
if (sessionUsage.onboardingMode && sessionUsage.totalToolCalls < sessionUsage.ONBOARDING_THRESHOLD) {
|
|
134
|
-
const remaining = sessionUsage.ONBOARDING_THRESHOLD - sessionUsage.totalToolCalls;
|
|
135
|
-
lines.push(`*Onboarding mode: showing for ${remaining} more tool calls. To keep: \`export DEVCTX_SHOW_USAGE=true\`*`);
|
|
136
|
-
} else {
|
|
137
|
-
lines.push('*To disable this message: `export DEVCTX_SHOW_USAGE=false`*');
|
|
138
|
-
}
|
|
86
|
+
lines.push('*To disable this message: `export DEVCTX_SHOW_USAGE=false`*');
|
|
139
87
|
|
|
140
88
|
return lines.join('\n');
|
|
141
89
|
};
|
|
142
90
|
|
|
143
|
-
/**
|
|
144
|
-
* Reset session usage (for testing or manual reset)
|
|
145
|
-
*/
|
|
146
91
|
export const resetSessionUsage = () => {
|
|
147
92
|
sessionUsage.tools.clear();
|
|
148
93
|
sessionUsage.totalSavedTokens = 0;
|
|
149
94
|
sessionUsage.totalToolCalls = 0;
|
|
150
|
-
sessionUsage.
|
|
95
|
+
sessionUsage.enabled = true;
|
|
151
96
|
};
|
|
152
97
|
|
|
153
|
-
/**
|
|
154
|
-
* Format token count for display
|
|
155
|
-
*/
|
|
156
98
|
const formatTokens = (tokens) => {
|
|
157
99
|
if (tokens >= 1000000) {
|
|
158
100
|
return `${(tokens / 1000000).toFixed(1)}M tokens`;
|
|
@@ -163,9 +105,6 @@ const formatTokens = (tokens) => {
|
|
|
163
105
|
return `${tokens} tokens`;
|
|
164
106
|
};
|
|
165
107
|
|
|
166
|
-
/**
|
|
167
|
-
* Truncate target path for display
|
|
168
|
-
*/
|
|
169
108
|
const truncateTarget = (target) => {
|
|
170
109
|
if (!target) return '';
|
|
171
110
|
if (target.length <= 40) return target;
|