smart-context-mcp 1.3.1 → 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 +89 -2
- 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 +2 -58
- package/src/index.js +2 -4
- package/src/missed-opportunities.js +4 -66
- 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 +4 -43
- 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,52 +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
|
-
* ENABLED BY DEFAULT - disable with: DEVCTX_SHOW_USAGE=false
|
|
6
|
-
*
|
|
7
|
-
* Shows feedback after every devctx tool call to ensure visibility.
|
|
8
|
-
* User can explicitly disable if they find it too verbose.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
const sessionUsage = {
|
|
12
|
-
tools: new Map(),
|
|
2
|
+
tools: new Map(),
|
|
13
3
|
totalSavedTokens: 0,
|
|
14
|
-
enabled: true,
|
|
4
|
+
enabled: true,
|
|
15
5
|
totalToolCalls: 0,
|
|
16
6
|
};
|
|
17
7
|
|
|
18
|
-
/**
|
|
19
|
-
* Check if usage feedback is enabled
|
|
20
|
-
*
|
|
21
|
-
* Priority:
|
|
22
|
-
* 1. Explicit env var (DEVCTX_SHOW_USAGE=true/false)
|
|
23
|
-
* 2. Default: ENABLED (changed from disabled)
|
|
24
|
-
*/
|
|
25
8
|
export const isFeedbackEnabled = () => {
|
|
26
9
|
const envValue = process.env.DEVCTX_SHOW_USAGE?.toLowerCase();
|
|
27
10
|
|
|
28
|
-
// Explicit enable
|
|
29
11
|
if (envValue === 'true' || envValue === '1' || envValue === 'yes') {
|
|
30
12
|
sessionUsage.enabled = true;
|
|
31
13
|
return true;
|
|
32
14
|
}
|
|
33
15
|
|
|
34
|
-
// Explicit disable
|
|
35
16
|
if (envValue === 'false' || envValue === '0' || envValue === 'no') {
|
|
36
17
|
sessionUsage.enabled = false;
|
|
37
18
|
return false;
|
|
38
19
|
}
|
|
39
20
|
|
|
40
|
-
// Default: ENABLED (changed)
|
|
41
21
|
sessionUsage.enabled = true;
|
|
42
22
|
return true;
|
|
43
23
|
};
|
|
44
24
|
|
|
45
|
-
/**
|
|
46
|
-
* Record tool usage for feedback
|
|
47
|
-
*/
|
|
48
25
|
export const recordToolUsage = ({ tool, savedTokens = 0, target = null }) => {
|
|
49
|
-
// Increment total tool calls (for onboarding mode)
|
|
50
26
|
sessionUsage.totalToolCalls += 1;
|
|
51
27
|
|
|
52
28
|
if (!isFeedbackEnabled()) return;
|
|
@@ -60,24 +36,18 @@ export const recordToolUsage = ({ tool, savedTokens = 0, target = null }) => {
|
|
|
60
36
|
sessionUsage.totalSavedTokens += savedTokens;
|
|
61
37
|
};
|
|
62
38
|
|
|
63
|
-
/**
|
|
64
|
-
* Get current session usage stats
|
|
65
|
-
*/
|
|
66
39
|
export const getSessionUsage = () => {
|
|
67
40
|
return {
|
|
68
41
|
tools: Array.from(sessionUsage.tools.entries()).map(([tool, stats]) => ({
|
|
69
42
|
tool,
|
|
70
43
|
count: stats.count,
|
|
71
44
|
savedTokens: stats.savedTokens,
|
|
72
|
-
targets: stats.targets.slice(-3),
|
|
45
|
+
targets: stats.targets.slice(-3),
|
|
73
46
|
})),
|
|
74
47
|
totalSavedTokens: sessionUsage.totalSavedTokens,
|
|
75
48
|
};
|
|
76
49
|
};
|
|
77
50
|
|
|
78
|
-
/**
|
|
79
|
-
* Format usage feedback as markdown
|
|
80
|
-
*/
|
|
81
51
|
export const formatUsageFeedback = () => {
|
|
82
52
|
if (!isFeedbackEnabled()) return '';
|
|
83
53
|
|
|
@@ -118,19 +88,13 @@ export const formatUsageFeedback = () => {
|
|
|
118
88
|
return lines.join('\n');
|
|
119
89
|
};
|
|
120
90
|
|
|
121
|
-
/**
|
|
122
|
-
* Reset session usage (for testing or manual reset)
|
|
123
|
-
*/
|
|
124
91
|
export const resetSessionUsage = () => {
|
|
125
92
|
sessionUsage.tools.clear();
|
|
126
93
|
sessionUsage.totalSavedTokens = 0;
|
|
127
94
|
sessionUsage.totalToolCalls = 0;
|
|
128
|
-
sessionUsage.enabled = true;
|
|
95
|
+
sessionUsage.enabled = true;
|
|
129
96
|
};
|
|
130
97
|
|
|
131
|
-
/**
|
|
132
|
-
* Format token count for display
|
|
133
|
-
*/
|
|
134
98
|
const formatTokens = (tokens) => {
|
|
135
99
|
if (tokens >= 1000000) {
|
|
136
100
|
return `${(tokens / 1000000).toFixed(1)}M tokens`;
|
|
@@ -141,9 +105,6 @@ const formatTokens = (tokens) => {
|
|
|
141
105
|
return `${tokens} tokens`;
|
|
142
106
|
};
|
|
143
107
|
|
|
144
|
-
/**
|
|
145
|
-
* Truncate target path for display
|
|
146
|
-
*/
|
|
147
108
|
const truncateTarget = (target) => {
|
|
148
109
|
if (!target) return '';
|
|
149
110
|
if (target.length <= 40) return target;
|