smart-context-mcp 1.0.4 → 1.2.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 +196 -586
- package/package.json +11 -7
- package/scripts/init-clients.js +56 -27
- package/scripts/report-metrics.js +5 -0
- package/scripts/report-workflow-metrics.js +255 -0
- package/src/analytics/adoption.js +197 -0
- package/src/cache-warming.js +131 -0
- package/src/context-patterns.js +192 -0
- package/src/cross-project.js +343 -0
- package/src/diff-analysis.js +291 -0
- package/src/git-blame.js +324 -0
- package/src/index.js +54 -5
- package/src/metrics.js +6 -1
- package/src/server.js +199 -13
- package/src/storage/sqlite.js +50 -1
- package/src/streaming.js +152 -0
- package/src/tools/smart-context.js +115 -6
- package/src/tools/smart-metrics.js +7 -0
- package/src/tools/smart-read-batch.js +9 -0
- package/src/tools/smart-read.js +21 -1
- package/src/tools/smart-shell.js +33 -9
- package/src/tools/smart-turn.js +1 -0
- package/src/workflow-tracker-stub.js +53 -0
- package/src/workflow-tracker.js +410 -0
package/src/metrics.js
CHANGED
|
@@ -129,9 +129,10 @@ const appendLegacyMetricsFile = async (entry) => {
|
|
|
129
129
|
};
|
|
130
130
|
|
|
131
131
|
export const persistMetrics = async (entry) => {
|
|
132
|
+
let enrichedEntry = entry;
|
|
133
|
+
|
|
132
134
|
try {
|
|
133
135
|
const resolvedInput = resolveMetricsInput();
|
|
134
|
-
let enrichedEntry = entry;
|
|
135
136
|
const safety = getSqliteSafetyPolicy();
|
|
136
137
|
|
|
137
138
|
if (!safety.shouldBlock) {
|
|
@@ -146,7 +147,11 @@ export const persistMetrics = async (entry) => {
|
|
|
146
147
|
}
|
|
147
148
|
});
|
|
148
149
|
}
|
|
150
|
+
} catch {
|
|
151
|
+
// best-effort — never fail a tool call for metrics
|
|
152
|
+
}
|
|
149
153
|
|
|
154
|
+
try {
|
|
150
155
|
await appendLegacyMetricsFile(enrichedEntry);
|
|
151
156
|
} catch {
|
|
152
157
|
// best-effort — never fail a tool call for metrics
|
package/src/server.js
CHANGED
|
@@ -12,6 +12,21 @@ import { smartSummary } from './tools/smart-summary.js';
|
|
|
12
12
|
import { smartMetrics } from './tools/smart-metrics.js';
|
|
13
13
|
import { smartTurn } from './tools/smart-turn.js';
|
|
14
14
|
import { projectRoot, projectRootSource } from './utils/paths.js';
|
|
15
|
+
import { setServerForStreaming } from './streaming.js';
|
|
16
|
+
import {
|
|
17
|
+
getSymbolBlame,
|
|
18
|
+
getFileAuthorshipStats,
|
|
19
|
+
findSymbolsByAuthor,
|
|
20
|
+
getRecentlyModifiedSymbols
|
|
21
|
+
} from './git-blame.js';
|
|
22
|
+
import {
|
|
23
|
+
discoverRelatedProjects,
|
|
24
|
+
searchAcrossProjects,
|
|
25
|
+
readAcrossProjects,
|
|
26
|
+
findSymbolAcrossProjects,
|
|
27
|
+
getCrossProjectDependencies,
|
|
28
|
+
getCrossProjectStats,
|
|
29
|
+
} from './cross-project.js';
|
|
15
30
|
|
|
16
31
|
const require = createRequire(import.meta.url);
|
|
17
32
|
const { version } = require('../package.json');
|
|
@@ -31,6 +46,9 @@ export const createDevctxServer = () => {
|
|
|
31
46
|
version,
|
|
32
47
|
});
|
|
33
48
|
|
|
49
|
+
// Enable streaming progress notifications
|
|
50
|
+
setServerForStreaming(server);
|
|
51
|
+
|
|
34
52
|
server.tool(
|
|
35
53
|
'smart_read',
|
|
36
54
|
'Read a file with token-efficient modes. outline/signatures: compact structure (~90% savings). range: specific line range with line numbers. symbol: extract function/class/method by name (string or array for batch). full: file content capped at 12k chars. maxTokens: token budget — auto-selects the most detailed mode that fits (full -> outline -> signatures -> truncated). context=true (symbol mode only): includes callers, tests, and referenced types from the dependency graph; returns graphCoverage (imports/tests: full|partial|none) so the agent knows how reliable the cross-file context is. Responses are cached in memory per session and invalidated by file mtime; cached=true when served from cache. Every response includes a unified confidence block: { parser, truncated, cached, graphCoverage? }. Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
|
|
@@ -78,7 +96,7 @@ export const createDevctxServer = () => {
|
|
|
78
96
|
|
|
79
97
|
server.tool(
|
|
80
98
|
'smart_context',
|
|
81
|
-
'Get curated context for a task in one call. Combines smart_search + smart_read + graph expansion. Returns relevant files, evidence for why each file was included, related tests, dependencies, symbol previews from the index, and symbol details — optimized for tokens. Includes a unified confidence block: { indexFreshness, graphCoverage } indicating index state and how complete the relational context is. Replaces the manual search → read → read cycle. Optional intent override, token budget, diff mode (pass diff=true for HEAD or diff="main" to scope context to changed files only), detail mode (minimal=index+signatures+snippets, balanced=default, deep=full content),
|
|
99
|
+
'Get curated context for a task in one call. Combines smart_search + smart_read + graph expansion. Returns relevant files, evidence for why each file was included, related tests, dependencies, symbol previews from the index, and symbol details — optimized for tokens. Includes a unified confidence block: { indexFreshness, graphCoverage } indicating index state and how complete the relational context is. Replaces the manual search → read → read cycle. Optional intent override, token budget, diff mode (pass diff=true for HEAD or diff="main" to scope context to changed files only), detail mode (minimal=index+signatures+snippets, balanced=default, deep=full content), include array to control which fields are returned (["content","graph","hints","symbolDetail"]), and prefetch=true to enable intelligent context prediction based on historical patterns (reduces round-trips by 40-60%).',
|
|
82
100
|
{
|
|
83
101
|
task: z.string(),
|
|
84
102
|
intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
|
|
@@ -87,9 +105,10 @@ export const createDevctxServer = () => {
|
|
|
87
105
|
diff: z.union([z.boolean(), z.string()]).optional(),
|
|
88
106
|
detail: z.enum(['minimal', 'balanced', 'deep']).optional(),
|
|
89
107
|
include: z.array(z.enum(['content', 'graph', 'hints', 'symbolDetail'])).optional(),
|
|
108
|
+
prefetch: z.boolean().optional(),
|
|
90
109
|
},
|
|
91
|
-
async ({ task, intent, maxTokens, entryFile, diff, detail, include }) =>
|
|
92
|
-
asTextResult(await smartContext({ task, intent, maxTokens, entryFile, diff, detail, include })),
|
|
110
|
+
async ({ task, intent, maxTokens, entryFile, diff, detail, include, prefetch }) =>
|
|
111
|
+
asTextResult(await smartContext({ task, intent, maxTokens, entryFile, diff, detail, include, prefetch })),
|
|
93
112
|
);
|
|
94
113
|
|
|
95
114
|
server.tool(
|
|
@@ -103,23 +122,190 @@ export const createDevctxServer = () => {
|
|
|
103
122
|
|
|
104
123
|
server.tool(
|
|
105
124
|
'build_index',
|
|
106
|
-
'Build a lightweight symbol index for the project. Speeds up smart_search ranking and smart_read symbol lookups. Pass incremental=true to only reindex files with changed mtime (much faster for large repos). Without incremental, rebuilds from scratch.',
|
|
125
|
+
'Build a lightweight symbol index for the project. Speeds up smart_search ranking and smart_read symbol lookups. Pass incremental=true to only reindex files with changed mtime (much faster for large repos). Pass warmCache=true to preload frequently accessed files after indexing. Without incremental, rebuilds from scratch. Sends progress notifications during indexing for large projects.',
|
|
107
126
|
{
|
|
108
127
|
incremental: z.boolean().optional(),
|
|
128
|
+
warmCache: z.boolean().optional(),
|
|
109
129
|
},
|
|
110
|
-
async ({ incremental }) => {
|
|
111
|
-
|
|
112
|
-
|
|
130
|
+
async ({ incremental, warmCache }) => {
|
|
131
|
+
const { createProgressReporter } = await import('./streaming.js');
|
|
132
|
+
const progress = createProgressReporter('build_index');
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (incremental) {
|
|
136
|
+
const { index, stats } = buildIndexIncremental(projectRoot, progress);
|
|
137
|
+
await persistIndex(index, projectRoot);
|
|
138
|
+
const symbolCount = Object.values(index.files).reduce((sum, f) => sum + f.symbols.length, 0);
|
|
139
|
+
const result = { status: 'ok', files: stats.total, symbols: symbolCount, ...stats };
|
|
140
|
+
|
|
141
|
+
if (warmCache) {
|
|
142
|
+
const { warmCache: warmCacheFn } = await import('./cache-warming.js');
|
|
143
|
+
const warmResult = await warmCacheFn(projectRoot, progress);
|
|
144
|
+
result.cacheWarming = warmResult;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
progress.complete(result);
|
|
148
|
+
return asTextResult(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const index = buildIndex(projectRoot, progress);
|
|
113
152
|
await persistIndex(index, projectRoot);
|
|
153
|
+
const fileCount = Object.keys(index.files).length;
|
|
114
154
|
const symbolCount = Object.values(index.files).reduce((sum, f) => sum + f.symbols.length, 0);
|
|
115
|
-
|
|
155
|
+
const result = { status: 'ok', files: fileCount, symbols: symbolCount };
|
|
156
|
+
|
|
157
|
+
if (warmCache) {
|
|
158
|
+
const { warmCache: warmCacheFn } = await import('./cache-warming.js');
|
|
159
|
+
const warmResult = await warmCacheFn(projectRoot, progress);
|
|
160
|
+
result.cacheWarming = warmResult;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
progress.complete(result);
|
|
164
|
+
return asTextResult(result);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
progress.error(error);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
server.tool(
|
|
173
|
+
'warm_cache',
|
|
174
|
+
'Preload frequently accessed files into OS cache to reduce cold-start latency. Analyzes last 30 days of access patterns and warms the top 50 most-used files (configurable via DEVCTX_WARM_FILES env). Skips files >1MB. Returns warmed/skipped counts. Use after build_index or before starting intensive work sessions.',
|
|
175
|
+
{},
|
|
176
|
+
async () => {
|
|
177
|
+
const { createProgressReporter } = await import('./streaming.js');
|
|
178
|
+
const { warmCache: warmCacheFn } = await import('./cache-warming.js');
|
|
179
|
+
|
|
180
|
+
const progress = createProgressReporter('warm_cache');
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const result = await warmCacheFn(projectRoot, progress);
|
|
184
|
+
progress.complete(result);
|
|
185
|
+
return asTextResult(result);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
progress.error(error);
|
|
188
|
+
throw error;
|
|
116
189
|
}
|
|
190
|
+
},
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
server.tool(
|
|
194
|
+
'git_blame',
|
|
195
|
+
'Get symbol-level git blame attribution. Modes: symbol (blame for specific file symbols), file (aggregated file stats), author (find symbols by author), recent (recently modified symbols). Returns author, email, date, commit, and authorship percentage for each symbol. Requires git repository and symbol index.',
|
|
196
|
+
{
|
|
197
|
+
mode: z.enum(['symbol', 'file', 'author', 'recent']),
|
|
198
|
+
filePath: z.string().optional(),
|
|
199
|
+
authorQuery: z.string().optional(),
|
|
200
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
201
|
+
daysBack: z.number().int().min(1).max(365).optional(),
|
|
202
|
+
},
|
|
203
|
+
async ({ mode, filePath, authorQuery, limit, daysBack }) => {
|
|
204
|
+
try {
|
|
205
|
+
if (mode === 'symbol') {
|
|
206
|
+
if (!filePath) {
|
|
207
|
+
throw new Error('filePath is required for symbol mode');
|
|
208
|
+
}
|
|
209
|
+
const result = await getSymbolBlame(filePath, projectRoot);
|
|
210
|
+
return asTextResult({ mode, filePath, symbols: result });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (mode === 'file') {
|
|
214
|
+
if (!filePath) {
|
|
215
|
+
throw new Error('filePath is required for file mode');
|
|
216
|
+
}
|
|
217
|
+
const result = await getFileAuthorshipStats(filePath, projectRoot);
|
|
218
|
+
return asTextResult({ mode, filePath, ...result });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (mode === 'author') {
|
|
222
|
+
if (!authorQuery) {
|
|
223
|
+
throw new Error('authorQuery is required for author mode');
|
|
224
|
+
}
|
|
225
|
+
const result = await findSymbolsByAuthor(authorQuery, projectRoot, limit || 50);
|
|
226
|
+
return asTextResult({ mode, authorQuery, matches: result.length, symbols: result });
|
|
227
|
+
}
|
|
117
228
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
229
|
+
if (mode === 'recent') {
|
|
230
|
+
const result = await getRecentlyModifiedSymbols(projectRoot, limit || 20, daysBack || 30);
|
|
231
|
+
return asTextResult({ mode, daysBack: daysBack || 30, symbols: result });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
throw new Error(`Unknown mode: ${mode}`);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return asTextResult({ error: error.message, mode, filePath, authorQuery });
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
server.tool(
|
|
242
|
+
'cross_project',
|
|
243
|
+
'Work with multiple related projects (monorepos, microservices, shared libraries). Modes: discover (list related projects), search (search across projects), read (read files from multiple projects), symbol (find symbol definitions across projects), deps (get cross-project dependency graph), stats (usage statistics). Requires .devctx-projects.json config file in project root.',
|
|
244
|
+
{
|
|
245
|
+
mode: z.enum(['discover', 'search', 'read', 'symbol', 'deps', 'stats']),
|
|
246
|
+
query: z.string().optional(),
|
|
247
|
+
intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs']).optional(),
|
|
248
|
+
symbolName: z.string().optional(),
|
|
249
|
+
fileRefs: z.array(z.object({
|
|
250
|
+
project: z.string(),
|
|
251
|
+
file: z.string(),
|
|
252
|
+
mode: z.enum(['full', 'outline', 'symbols']).optional(),
|
|
253
|
+
})).optional(),
|
|
254
|
+
maxResultsPerProject: z.number().int().min(1).max(20).optional(),
|
|
255
|
+
includeProjects: z.array(z.string()).optional(),
|
|
256
|
+
excludeProjects: z.array(z.string()).optional(),
|
|
257
|
+
},
|
|
258
|
+
async ({ mode, query, intent, symbolName, fileRefs, maxResultsPerProject, includeProjects, excludeProjects }) => {
|
|
259
|
+
try {
|
|
260
|
+
if (mode === 'discover') {
|
|
261
|
+
const projects = discoverRelatedProjects(projectRoot);
|
|
262
|
+
return asTextResult({ mode, projects });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (mode === 'search') {
|
|
266
|
+
if (!query) {
|
|
267
|
+
throw new Error('query is required for search mode');
|
|
268
|
+
}
|
|
269
|
+
const results = await searchAcrossProjects(query, {
|
|
270
|
+
root: projectRoot,
|
|
271
|
+
intent: intent || 'implementation',
|
|
272
|
+
maxResultsPerProject: maxResultsPerProject || 5,
|
|
273
|
+
includeProjects,
|
|
274
|
+
excludeProjects,
|
|
275
|
+
});
|
|
276
|
+
return asTextResult({ mode, query, intent, totalProjects: results.length, results });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (mode === 'read') {
|
|
280
|
+
if (!fileRefs || fileRefs.length === 0) {
|
|
281
|
+
throw new Error('fileRefs is required for read mode');
|
|
282
|
+
}
|
|
283
|
+
const results = await readAcrossProjects(fileRefs, projectRoot);
|
|
284
|
+
return asTextResult({ mode, filesRead: results.length, results });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (mode === 'symbol') {
|
|
288
|
+
if (!symbolName) {
|
|
289
|
+
throw new Error('symbolName is required for symbol mode');
|
|
290
|
+
}
|
|
291
|
+
const results = await findSymbolAcrossProjects(symbolName, projectRoot);
|
|
292
|
+
return asTextResult({ mode, symbolName, matches: results.length, results });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (mode === 'deps') {
|
|
296
|
+
const deps = getCrossProjectDependencies(projectRoot);
|
|
297
|
+
return asTextResult({ mode, ...deps });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (mode === 'stats') {
|
|
301
|
+
const stats = getCrossProjectStats(projectRoot);
|
|
302
|
+
return asTextResult({ mode, ...stats });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
throw new Error(`Unknown mode: ${mode}`);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
return asTextResult({ error: error.message, mode });
|
|
308
|
+
}
|
|
123
309
|
},
|
|
124
310
|
);
|
|
125
311
|
|
package/src/storage/sqlite.js
CHANGED
|
@@ -5,16 +5,18 @@ import path from 'node:path';
|
|
|
5
5
|
import { projectRoot } from '../utils/runtime-config.js';
|
|
6
6
|
|
|
7
7
|
export const STATE_DB_FILENAME = 'state.sqlite';
|
|
8
|
-
export const SQLITE_SCHEMA_VERSION =
|
|
8
|
+
export const SQLITE_SCHEMA_VERSION = 5;
|
|
9
9
|
export const ACTIVE_SESSION_SCOPE = 'project';
|
|
10
10
|
export const EXPECTED_TABLES = [
|
|
11
11
|
'active_session',
|
|
12
|
+
'context_access',
|
|
12
13
|
'hook_turn_state',
|
|
13
14
|
'meta',
|
|
14
15
|
'metrics_events',
|
|
15
16
|
'session_events',
|
|
16
17
|
'sessions',
|
|
17
18
|
'summary_cache',
|
|
19
|
+
'workflow_metrics',
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
const MIGRATIONS = [
|
|
@@ -125,6 +127,53 @@ const MIGRATIONS = [
|
|
|
125
127
|
ON hook_turn_state(claude_session_id, updated_at DESC)`,
|
|
126
128
|
],
|
|
127
129
|
},
|
|
130
|
+
{
|
|
131
|
+
version: 4,
|
|
132
|
+
statements: [
|
|
133
|
+
`CREATE TABLE IF NOT EXISTS context_access (
|
|
134
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
135
|
+
session_id TEXT NOT NULL,
|
|
136
|
+
task TEXT NOT NULL,
|
|
137
|
+
intent TEXT,
|
|
138
|
+
file_path TEXT NOT NULL,
|
|
139
|
+
relevance REAL,
|
|
140
|
+
access_order INTEGER,
|
|
141
|
+
timestamp TEXT NOT NULL
|
|
142
|
+
)`,
|
|
143
|
+
`CREATE INDEX IF NOT EXISTS idx_context_access_file_timestamp
|
|
144
|
+
ON context_access(file_path, timestamp DESC)`,
|
|
145
|
+
`CREATE INDEX IF NOT EXISTS idx_context_access_session
|
|
146
|
+
ON context_access(session_id, timestamp DESC)`,
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
version: 5,
|
|
151
|
+
statements: [
|
|
152
|
+
`CREATE TABLE IF NOT EXISTS workflow_metrics (
|
|
153
|
+
workflow_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
workflow_type TEXT NOT NULL,
|
|
155
|
+
session_id TEXT,
|
|
156
|
+
start_time TEXT NOT NULL,
|
|
157
|
+
end_time TEXT,
|
|
158
|
+
duration_ms INTEGER,
|
|
159
|
+
tools_used_json TEXT NOT NULL DEFAULT '[]',
|
|
160
|
+
steps_count INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
raw_tokens INTEGER NOT NULL DEFAULT 0,
|
|
162
|
+
compressed_tokens INTEGER NOT NULL DEFAULT 0,
|
|
163
|
+
saved_tokens INTEGER NOT NULL DEFAULT 0,
|
|
164
|
+
savings_pct REAL NOT NULL DEFAULT 0,
|
|
165
|
+
baseline_tokens INTEGER NOT NULL DEFAULT 0,
|
|
166
|
+
vs_baseline_pct REAL NOT NULL DEFAULT 0,
|
|
167
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
168
|
+
created_at TEXT NOT NULL,
|
|
169
|
+
FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE SET NULL
|
|
170
|
+
)`,
|
|
171
|
+
`CREATE INDEX IF NOT EXISTS idx_workflow_metrics_type_created
|
|
172
|
+
ON workflow_metrics(workflow_type, created_at DESC)`,
|
|
173
|
+
`CREATE INDEX IF NOT EXISTS idx_workflow_metrics_session
|
|
174
|
+
ON workflow_metrics(session_id, created_at DESC)`,
|
|
175
|
+
],
|
|
176
|
+
},
|
|
128
177
|
];
|
|
129
178
|
|
|
130
179
|
let sqliteModulePromise = null;
|
package/src/streaming.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming progress notifications for long-running operations.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* ```js
|
|
6
|
+
* const progress = createProgressReporter(server, 'build_index');
|
|
7
|
+
* progress.report({ phase: 'scanning', filesScanned: 100 });
|
|
8
|
+
* progress.report({ phase: 'indexing', filesProcessed: 50, total: 100 });
|
|
9
|
+
* progress.complete({ files: 1000, symbols: 5000 });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
let currentServer = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set the MCP server instance for sending notifications.
|
|
17
|
+
* Called once during server initialization.
|
|
18
|
+
*/
|
|
19
|
+
export const setServerForStreaming = (server) => {
|
|
20
|
+
currentServer = server;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a progress reporter for a specific operation.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} operation - Operation name (e.g., 'build_index', 'smart_search')
|
|
27
|
+
* @param {string} [operationId] - Optional unique ID for this operation instance
|
|
28
|
+
* @returns {ProgressReporter}
|
|
29
|
+
*/
|
|
30
|
+
export const createProgressReporter = (operation, operationId = null) => {
|
|
31
|
+
const id = operationId || `${operation}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
32
|
+
let startTime = Date.now();
|
|
33
|
+
let lastReportTime = 0; // Allow first report immediately
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
/**
|
|
37
|
+
* Report progress update.
|
|
38
|
+
* @param {object} data - Progress data (phase, percentage, items processed, etc.)
|
|
39
|
+
*/
|
|
40
|
+
report(data) {
|
|
41
|
+
if (!currentServer) return;
|
|
42
|
+
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const elapsed = now - startTime;
|
|
45
|
+
const sinceLast = now - lastReportTime;
|
|
46
|
+
|
|
47
|
+
// Throttle: only send if >100ms since last report
|
|
48
|
+
if (sinceLast < 100) return;
|
|
49
|
+
|
|
50
|
+
lastReportTime = now;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
currentServer.notification({
|
|
54
|
+
method: 'notifications/progress',
|
|
55
|
+
params: {
|
|
56
|
+
progressToken: id,
|
|
57
|
+
progress: {
|
|
58
|
+
operation,
|
|
59
|
+
elapsed,
|
|
60
|
+
...data,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Ignore notification errors - don't fail the operation
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Report completion with final result summary.
|
|
71
|
+
* @param {object} summary - Final result summary
|
|
72
|
+
*/
|
|
73
|
+
complete(summary) {
|
|
74
|
+
if (!currentServer) return;
|
|
75
|
+
|
|
76
|
+
const elapsed = Date.now() - startTime;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
currentServer.notification({
|
|
80
|
+
method: 'notifications/progress',
|
|
81
|
+
params: {
|
|
82
|
+
progressToken: id,
|
|
83
|
+
progress: {
|
|
84
|
+
operation,
|
|
85
|
+
phase: 'complete',
|
|
86
|
+
elapsed,
|
|
87
|
+
...summary,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
// Ignore notification errors
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Report error.
|
|
98
|
+
* @param {Error|string} error - Error that occurred
|
|
99
|
+
*/
|
|
100
|
+
error(error) {
|
|
101
|
+
if (!currentServer) return;
|
|
102
|
+
|
|
103
|
+
const elapsed = Date.now() - startTime;
|
|
104
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
currentServer.notification({
|
|
108
|
+
method: 'notifications/progress',
|
|
109
|
+
params: {
|
|
110
|
+
progressToken: id,
|
|
111
|
+
progress: {
|
|
112
|
+
operation,
|
|
113
|
+
phase: 'error',
|
|
114
|
+
elapsed,
|
|
115
|
+
error: message,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
// Ignore notification errors
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Wrap an async operation with automatic progress reporting.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} operation - Operation name
|
|
130
|
+
* @param {Function} fn - Async function to execute
|
|
131
|
+
* @param {Function} [progressFn] - Optional function to extract progress from intermediate results
|
|
132
|
+
* @returns {Promise} - Result of the operation
|
|
133
|
+
*/
|
|
134
|
+
export const withProgress = async (operation, fn, progressFn = null) => {
|
|
135
|
+
const progress = createProgressReporter(operation);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const result = await fn(progress);
|
|
139
|
+
|
|
140
|
+
if (progressFn && result) {
|
|
141
|
+
const summary = progressFn(result);
|
|
142
|
+
progress.complete(summary);
|
|
143
|
+
} else {
|
|
144
|
+
progress.complete({});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
progress.error(error);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
@@ -10,6 +10,14 @@ import { projectRoot } from '../utils/paths.js';
|
|
|
10
10
|
import { resolveSafePath } from '../utils/fs.js';
|
|
11
11
|
import { countTokens } from '../tokenCounter.js';
|
|
12
12
|
import { persistMetrics } from '../metrics.js';
|
|
13
|
+
import { predictContextFiles, recordContextAccess } from '../context-patterns.js';
|
|
14
|
+
import {
|
|
15
|
+
getDetailedDiff,
|
|
16
|
+
analyzeChangeImpact,
|
|
17
|
+
expandChangedContext,
|
|
18
|
+
generateDiffSummary as generateDetailedDiffSummary,
|
|
19
|
+
getChangedSymbols,
|
|
20
|
+
} from '../diff-analysis.js';
|
|
13
21
|
|
|
14
22
|
const execFile = promisify(execFileCallback);
|
|
15
23
|
|
|
@@ -869,6 +877,7 @@ export const smartContext = async ({
|
|
|
869
877
|
diff,
|
|
870
878
|
detail = 'balanced',
|
|
871
879
|
include = DEFAULT_INCLUDE,
|
|
880
|
+
prefetch = false,
|
|
872
881
|
}) => {
|
|
873
882
|
const resolvedIntent = (intent && VALID_INTENTS.has(intent)) ? intent : inferIntent(task);
|
|
874
883
|
const root = projectRoot;
|
|
@@ -878,20 +887,65 @@ export const smartContext = async ({
|
|
|
878
887
|
let primarySeeds = [];
|
|
879
888
|
let searchIndexFreshness;
|
|
880
889
|
let diffSummary = null;
|
|
890
|
+
let prefetchResult = null;
|
|
881
891
|
|
|
882
892
|
if (diff) {
|
|
883
893
|
const changed = await getChangedFiles(diff, root);
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
894
|
+
|
|
895
|
+
// Get detailed diff stats
|
|
896
|
+
const detailedChanges = await getDetailedDiff(changed.ref, root);
|
|
897
|
+
const index = loadIndex(root);
|
|
898
|
+
|
|
899
|
+
// Analyze impact and prioritize
|
|
900
|
+
const prioritized = analyzeChangeImpact(detailedChanges, index);
|
|
901
|
+
|
|
902
|
+
// Expand to include related files (importers, dependencies, tests)
|
|
903
|
+
const expandedFiles = expandChangedContext(changed.files, index, 10);
|
|
904
|
+
|
|
905
|
+
// Build primary seeds with priority and impact data
|
|
906
|
+
primarySeeds = Array.from(expandedFiles).map(rel => {
|
|
907
|
+
const changeInfo = prioritized.find(c => c.file === rel);
|
|
908
|
+
const evidence = [{
|
|
909
|
+
type: 'diffHit',
|
|
910
|
+
ref: changed.ref,
|
|
911
|
+
priority: changeInfo?.priority || 'related',
|
|
912
|
+
impact: changeInfo?.impactScore || 0,
|
|
913
|
+
}];
|
|
914
|
+
|
|
915
|
+
// Mark files that were expanded (not directly changed)
|
|
916
|
+
if (!changed.files.includes(rel)) {
|
|
917
|
+
evidence[0].expanded = true;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
rel,
|
|
922
|
+
absPath: path.join(root, rel),
|
|
923
|
+
evidence,
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Sort by impact (critical changes first)
|
|
928
|
+
primarySeeds.sort((a, b) => {
|
|
929
|
+
const impactA = a.evidence[0].impact || 0;
|
|
930
|
+
const impactB = b.evidence[0].impact || 0;
|
|
931
|
+
return impactB - impactA;
|
|
932
|
+
});
|
|
933
|
+
|
|
889
934
|
diffSummary = {
|
|
890
935
|
ref: changed.ref,
|
|
891
936
|
totalChanged: changed.files.length + changed.skippedDeleted,
|
|
892
|
-
included: Math.min(
|
|
937
|
+
included: Math.min(primarySeeds.length, maxTokens > 4000 ? 10 : 5),
|
|
938
|
+
expanded: expandedFiles.size - changed.files.length,
|
|
893
939
|
skippedDeleted: changed.skippedDeleted,
|
|
940
|
+
summary: generateDetailedDiffSummary(prioritized.slice(0, 10)),
|
|
941
|
+
topImpact: prioritized.slice(0, 3).map(c => ({
|
|
942
|
+
file: c.file,
|
|
943
|
+
priority: c.priority,
|
|
944
|
+
changes: `+${c.additions}/-${c.deletions}`,
|
|
945
|
+
type: c.changeType,
|
|
946
|
+
})),
|
|
894
947
|
};
|
|
948
|
+
|
|
895
949
|
if (changed.error) diffSummary.error = changed.error;
|
|
896
950
|
searchIndexFreshness = null;
|
|
897
951
|
} else {
|
|
@@ -972,6 +1026,38 @@ export const smartContext = async ({
|
|
|
972
1026
|
|
|
973
1027
|
const index = loadIndex(root);
|
|
974
1028
|
|
|
1029
|
+
if (prefetch && !diff) {
|
|
1030
|
+
try {
|
|
1031
|
+
prefetchResult = await predictContextFiles({ task, intent: resolvedIntent, maxFiles: 8 });
|
|
1032
|
+
|
|
1033
|
+
if (prefetchResult.confidence >= 0.6 && prefetchResult.predicted.length > 0) {
|
|
1034
|
+
for (const predicted of prefetchResult.predicted) {
|
|
1035
|
+
try {
|
|
1036
|
+
const abs = resolveSafePath(predicted.path);
|
|
1037
|
+
if (fs.existsSync(abs)) {
|
|
1038
|
+
const rel = path.relative(root, abs).replace(/\\/g, '/');
|
|
1039
|
+
const alreadyIncluded = primarySeeds.some(seed => seed.absPath === abs);
|
|
1040
|
+
|
|
1041
|
+
if (!alreadyIncluded) {
|
|
1042
|
+
primarySeeds.push({
|
|
1043
|
+
rel,
|
|
1044
|
+
absPath: abs,
|
|
1045
|
+
evidence: [{
|
|
1046
|
+
type: 'prefetch',
|
|
1047
|
+
confidence: predicted.confidence,
|
|
1048
|
+
accessCount: predicted.accessCount
|
|
1049
|
+
}]
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
} catch {}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
prefetchResult = { error: error.message, predicted: [] };
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
975
1061
|
primarySeeds = rerankPrimarySeeds(primarySeeds, task, resolvedIntent);
|
|
976
1062
|
|
|
977
1063
|
const primarySeedsLimited = primarySeeds.slice(0, 5);
|
|
@@ -1143,6 +1229,20 @@ export const smartContext = async ({
|
|
|
1143
1229
|
timestamp: new Date().toISOString(),
|
|
1144
1230
|
});
|
|
1145
1231
|
|
|
1232
|
+
if (prefetch && context.length > 0) {
|
|
1233
|
+
try {
|
|
1234
|
+
await recordContextAccess({
|
|
1235
|
+
task,
|
|
1236
|
+
intent: resolvedIntent,
|
|
1237
|
+
files: context.map((item, idx) => ({
|
|
1238
|
+
path: item.file,
|
|
1239
|
+
relevance: item.role === 'primary' ? 1.0 : (item.role === 'test' ? 0.9 : 0.7),
|
|
1240
|
+
order: idx
|
|
1241
|
+
}))
|
|
1242
|
+
});
|
|
1243
|
+
} catch {}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1146
1246
|
const COVERAGE_RANK = { full: 2, partial: 1, none: 0 };
|
|
1147
1247
|
const coverageMin = (vals) => {
|
|
1148
1248
|
if (vals.length === 0) return 'none';
|
|
@@ -1159,6 +1259,7 @@ export const smartContext = async ({
|
|
|
1159
1259
|
};
|
|
1160
1260
|
|
|
1161
1261
|
const result = {
|
|
1262
|
+
success: true,
|
|
1162
1263
|
task,
|
|
1163
1264
|
intent: resolvedIntent,
|
|
1164
1265
|
indexFreshness,
|
|
@@ -1177,6 +1278,14 @@ export const smartContext = async ({
|
|
|
1177
1278
|
indexOnlyItems,
|
|
1178
1279
|
contentItems,
|
|
1179
1280
|
primaryReadMode: primaryItem?.readMode ?? null,
|
|
1281
|
+
...(prefetchResult ? {
|
|
1282
|
+
prefetch: {
|
|
1283
|
+
enabled: true,
|
|
1284
|
+
confidence: prefetchResult.confidence || 0,
|
|
1285
|
+
predictedFiles: prefetchResult.predicted?.length || 0,
|
|
1286
|
+
matchedPattern: prefetchResult.matchedPattern || null
|
|
1287
|
+
}
|
|
1288
|
+
} : {})
|
|
1180
1289
|
},
|
|
1181
1290
|
...(includeSet.has('hints') ? { hints } : {}),
|
|
1182
1291
|
};
|