smart-context-mcp 1.10.0 → 1.13.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 +1 -1
- package/package.json +1 -1
- package/scripts/init-clients.js +1 -3
- package/scripts/report-adoption-metrics.js +0 -9
- package/scripts/report-workflow-metrics.js +0 -1
- package/server.json +2 -2
- package/src/index-manager.js +14 -9
- package/src/index.js +1 -3
- package/src/missed-opportunities.js +0 -1
- package/src/server.js +13 -29
- package/src/tokenCounter.js +28 -5
- package/src/tools/smart-context.js +7 -78
- package/src/tools/smart-read/code.js +16 -4
- package/src/tools/smart-read-batch.js +0 -8
- package/src/tools/smart-read.js +0 -21
- package/src/tools/smart-search.js +3 -24
- package/src/tools/smart-shell.js +56 -24
- package/src/tools/smart-turn.js +7 -20
- package/src/usage-feedback.js +0 -1
- package/src/utils/mutation-safety.js +3 -3
- package/src/utils/text.js +5 -0
- package/src/workflow-tracker.js +0 -10
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ Restart your AI client. Done.
|
|
|
56
56
|
# Check installed version
|
|
57
57
|
npm list -g smart-context-mcp
|
|
58
58
|
|
|
59
|
-
# Should show: smart-context-mcp@1.
|
|
59
|
+
# Should show: smart-context-mcp@1.13.0 (or later)
|
|
60
60
|
|
|
61
61
|
# Update to latest version
|
|
62
62
|
npm update -g smart-context-mcp
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-context-mcp",
|
|
3
3
|
"mcpName": "io.github.Arrayo/smart-context-mcp",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.0",
|
|
5
5
|
"description": "MCP server that reduces agent token usage by 90% with intelligent context compression, task checkpoint persistence, and workflow-aware agent guidance.",
|
|
6
6
|
"author": "Francisco Caballero Portero <fcp1978@hotmail.com>",
|
|
7
7
|
"type": "module",
|
package/scripts/init-clients.js
CHANGED
|
@@ -461,11 +461,9 @@ const updateCursorRule = (targetDir, dryRun) => {
|
|
|
461
461
|
const rulesDir = path.join(targetDir, '.cursor', 'rules');
|
|
462
462
|
const profilesDir = path.join(rulesDir, 'profiles-compact');
|
|
463
463
|
|
|
464
|
-
// Write base rule (always active)
|
|
465
464
|
const baseFilePath = path.join(rulesDir, 'devctx.mdc');
|
|
466
465
|
writeFile(baseFilePath, cursorRuleContent, dryRun);
|
|
467
|
-
|
|
468
|
-
// Write profiles README
|
|
466
|
+
|
|
469
467
|
const profilesReadmePath = path.join(profilesDir, 'README.md');
|
|
470
468
|
writeFile(profilesReadmePath, cursorProfilesNote, dryRun);
|
|
471
469
|
|
|
@@ -73,7 +73,6 @@ const calculateAdoptionMetrics = (days = 30) => {
|
|
|
73
73
|
return withStateDb((db) => {
|
|
74
74
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
75
75
|
|
|
76
|
-
// Get all sessions since cutoff
|
|
77
76
|
const sessions = db
|
|
78
77
|
.prepare(
|
|
79
78
|
`
|
|
@@ -94,7 +93,6 @@ const calculateAdoptionMetrics = (days = 30) => {
|
|
|
94
93
|
toolUsage: {},
|
|
95
94
|
};
|
|
96
95
|
|
|
97
|
-
// Initialize workflow stats
|
|
98
96
|
Object.keys(WORKFLOW_DEFINITIONS).forEach((type) => {
|
|
99
97
|
results.byWorkflow[type] = {
|
|
100
98
|
total: 0,
|
|
@@ -103,12 +101,10 @@ const calculateAdoptionMetrics = (days = 30) => {
|
|
|
103
101
|
};
|
|
104
102
|
});
|
|
105
103
|
|
|
106
|
-
// Analyze each session
|
|
107
104
|
sessions.forEach((session) => {
|
|
108
105
|
const snapshot = JSON.parse(session.snapshot_json || '{}');
|
|
109
106
|
const sessionId = session.session_id;
|
|
110
107
|
|
|
111
|
-
// Get events for this session
|
|
112
108
|
const sessionEvents = db
|
|
113
109
|
.prepare('SELECT * FROM session_events WHERE session_id = ?')
|
|
114
110
|
.all(sessionId);
|
|
@@ -117,25 +113,21 @@ const calculateAdoptionMetrics = (days = 30) => {
|
|
|
117
113
|
.prepare('SELECT * FROM metrics_events WHERE session_id = ?')
|
|
118
114
|
.all(sessionId);
|
|
119
115
|
|
|
120
|
-
// Check if non-trivial
|
|
121
116
|
if (!isNonTrivialTask(sessionEvents, metricsEvents)) {
|
|
122
117
|
return;
|
|
123
118
|
}
|
|
124
119
|
|
|
125
120
|
results.nonTrivialTasks++;
|
|
126
121
|
|
|
127
|
-
// Check if used devctx
|
|
128
122
|
const hasDevctx = usedDevctx(metricsEvents);
|
|
129
123
|
if (hasDevctx) {
|
|
130
124
|
results.tasksWithDevctx++;
|
|
131
125
|
}
|
|
132
126
|
|
|
133
|
-
// Track tool usage
|
|
134
127
|
metricsEvents.forEach((m) => {
|
|
135
128
|
results.toolUsage[m.tool] = (results.toolUsage[m.tool] || 0) + 1;
|
|
136
129
|
});
|
|
137
130
|
|
|
138
|
-
// Classify by workflow if possible
|
|
139
131
|
const goal = snapshot.goal || '';
|
|
140
132
|
let workflowType = null;
|
|
141
133
|
|
|
@@ -154,7 +146,6 @@ const calculateAdoptionMetrics = (days = 30) => {
|
|
|
154
146
|
}
|
|
155
147
|
});
|
|
156
148
|
|
|
157
|
-
// Calculate rates
|
|
158
149
|
if (results.nonTrivialTasks > 0) {
|
|
159
150
|
results.adoptionRate = (results.tasksWithDevctx / results.nonTrivialTasks) * 100;
|
|
160
151
|
}
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/Arrayo/smart-context-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.13.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "smart-context-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.13.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
package/src/index-manager.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { execFile as execFileCallback } from 'node:child_process';
|
|
4
4
|
import { promisify } from 'node:util';
|
|
5
5
|
import { projectRoot } from './utils/paths.js';
|
|
6
|
-
import { loadIndex,
|
|
6
|
+
import { loadIndex, buildIndexIncremental, persistIndex } from './index.js';
|
|
7
7
|
|
|
8
8
|
const execFile = promisify(execFileCallback);
|
|
9
9
|
|
|
@@ -83,23 +83,28 @@ export const ensureIndexReady = async (options = {}) => {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
log('Building search index...');
|
|
86
|
-
|
|
86
|
+
|
|
87
87
|
try {
|
|
88
|
-
const buildPromise =
|
|
88
|
+
const buildPromise = (async () => {
|
|
89
|
+
const { index, stats } = buildIndexIncremental(root);
|
|
90
|
+
await persistIndex(index, root);
|
|
91
|
+
return { stats, fileCount: Object.keys(index.files).length, version: index.version };
|
|
92
|
+
})();
|
|
93
|
+
|
|
89
94
|
const result = await Promise.race([
|
|
90
95
|
buildPromise,
|
|
91
|
-
timeout(timeoutMs, 'Index build timeout')
|
|
96
|
+
timeout(timeoutMs, 'Index build timeout'),
|
|
92
97
|
]);
|
|
93
|
-
|
|
98
|
+
|
|
94
99
|
saveIndexMetadata({
|
|
95
100
|
builtAt: Date.now(),
|
|
96
101
|
gitHead: getGitHead(root),
|
|
97
|
-
fileCount: result
|
|
98
|
-
version: result
|
|
102
|
+
fileCount: result.fileCount,
|
|
103
|
+
version: result.version,
|
|
99
104
|
}, root);
|
|
100
|
-
|
|
105
|
+
|
|
101
106
|
log('Index ready');
|
|
102
|
-
return { status: 'built', cached: false, fileCount: result
|
|
107
|
+
return { status: 'built', cached: false, fileCount: result.fileCount };
|
|
103
108
|
} catch (error) {
|
|
104
109
|
log(`Index build failed: ${error.message}`);
|
|
105
110
|
return { status: 'fallback', error: error.message };
|
package/src/index.js
CHANGED
|
@@ -726,9 +726,7 @@ export const buildIndex = (root, progress = null) => {
|
|
|
726
726
|
if (sym.snippet) entry.snippet = sym.snippet;
|
|
727
727
|
invertedIndex[key].push(entry);
|
|
728
728
|
}
|
|
729
|
-
} catch {
|
|
730
|
-
// skip unreadable files
|
|
731
|
-
}
|
|
729
|
+
} catch { /* unreadable */ }
|
|
732
730
|
|
|
733
731
|
processed++;
|
|
734
732
|
|
|
@@ -141,7 +141,6 @@ export const formatMissedOpportunities = () => {
|
|
|
141
141
|
lines.push('⚠️ **Missed devctx opportunities detected:**');
|
|
142
142
|
lines.push('');
|
|
143
143
|
|
|
144
|
-
// Show session stats
|
|
145
144
|
lines.push(`**Session stats:**`);
|
|
146
145
|
lines.push(`- Duration: ${analysis.sessionDuration}s`);
|
|
147
146
|
lines.push(`- devctx operations: ${analysis.devctxOperations}`);
|
package/src/server.js
CHANGED
|
@@ -39,7 +39,7 @@ export const asTextResult = (result) => ({
|
|
|
39
39
|
content: [
|
|
40
40
|
{
|
|
41
41
|
type: 'text',
|
|
42
|
-
text: JSON.stringify(result
|
|
42
|
+
text: JSON.stringify(result),
|
|
43
43
|
},
|
|
44
44
|
],
|
|
45
45
|
});
|
|
@@ -57,13 +57,11 @@ export const createDevctxServer = () => {
|
|
|
57
57
|
version,
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
// Enable streaming progress notifications
|
|
61
60
|
setServerForStreaming(server);
|
|
62
61
|
|
|
63
|
-
// Register prompts
|
|
64
62
|
server.prompt(
|
|
65
63
|
'use-devctx',
|
|
66
|
-
'Force the agent to use devctx tools for the current task.
|
|
64
|
+
'Force the agent to use devctx tools for the current task.',
|
|
67
65
|
{},
|
|
68
66
|
async () => ({
|
|
69
67
|
messages: [
|
|
@@ -71,7 +69,7 @@ export const createDevctxServer = () => {
|
|
|
71
69
|
role: 'user',
|
|
72
70
|
content: {
|
|
73
71
|
type: 'text',
|
|
74
|
-
text: 'Use devctx
|
|
72
|
+
text: 'Use devctx MCP tools for this task. Start with smart_context(task) for multi-file context. Use smart_read(outline) → smart_read(symbol) cascade for individual files. Never use native Read on large files.',
|
|
75
73
|
},
|
|
76
74
|
},
|
|
77
75
|
],
|
|
@@ -80,7 +78,7 @@ export const createDevctxServer = () => {
|
|
|
80
78
|
|
|
81
79
|
server.prompt(
|
|
82
80
|
'devctx-workflow',
|
|
83
|
-
'Complete devctx workflow
|
|
81
|
+
'Complete devctx workflow for complex tasks with session continuity.',
|
|
84
82
|
{},
|
|
85
83
|
async () => ({
|
|
86
84
|
messages: [
|
|
@@ -88,15 +86,7 @@ export const createDevctxServer = () => {
|
|
|
88
86
|
role: 'user',
|
|
89
87
|
content: {
|
|
90
88
|
type: 'text',
|
|
91
|
-
text:
|
|
92
|
-
|
|
93
|
-
1. smart_turn(start) - Start session and recover previous context
|
|
94
|
-
2. smart_context(task) - Build complete context for the task
|
|
95
|
-
3. smart_search(query) - Search for specific patterns if needed
|
|
96
|
-
4. smart_read(file) - Read files with appropriate mode (outline/signatures/symbol)
|
|
97
|
-
5. smart_turn(end) - Save checkpoint for next session
|
|
98
|
-
|
|
99
|
-
Use devctx tools instead of native Read/Grep/Shell when possible.`,
|
|
89
|
+
text: 'Follow devctx workflow: 1) smart_turn(start) to recover session 2) smart_context(task) for curated context (replaces search+read cycle) 3) smart_read(symbol) only for specific functions not covered by smart_context 4) smart_turn(end) to checkpoint. Never skip to smart_read(full) — use the cascade: outline → signatures → symbol → full.',
|
|
100
90
|
},
|
|
101
91
|
},
|
|
102
92
|
],
|
|
@@ -105,7 +95,7 @@ Use devctx tools instead of native Read/Grep/Shell when possible.`,
|
|
|
105
95
|
|
|
106
96
|
server.prompt(
|
|
107
97
|
'devctx-preflight',
|
|
108
|
-
'Preflight
|
|
98
|
+
'Preflight: build index and initialize session before work.',
|
|
109
99
|
{},
|
|
110
100
|
async () => ({
|
|
111
101
|
messages: [
|
|
@@ -113,13 +103,7 @@ Use devctx tools instead of native Read/Grep/Shell when possible.`,
|
|
|
113
103
|
role: 'user',
|
|
114
104
|
content: {
|
|
115
105
|
type: 'text',
|
|
116
|
-
text:
|
|
117
|
-
|
|
118
|
-
1. build_index(incremental=true) - Build/update symbol index
|
|
119
|
-
2. smart_turn(start) - Initialize session and recover context
|
|
120
|
-
3. Proceed with your task using devctx tools
|
|
121
|
-
|
|
122
|
-
This ensures optimal performance and context recovery.`,
|
|
106
|
+
text: 'Preflight: 1) build_index(incremental=true) 2) smart_turn(start) 3) Proceed with devctx tools.',
|
|
123
107
|
},
|
|
124
108
|
},
|
|
125
109
|
],
|
|
@@ -128,7 +112,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
128
112
|
|
|
129
113
|
server.tool(
|
|
130
114
|
'smart_read',
|
|
131
|
-
'Read a file with token-efficient modes. outline/signatures:
|
|
115
|
+
'Read a file with token-efficient modes. ALWAYS prefer outline/signatures/symbol over full. Reading cascade: outline → signatures → symbol → range → full (last resort). Mode guide: outline (~90% savings): file structure, exports, top-level symbols — use first for orientation. signatures (~85% savings): function signatures with parameters and return types — use when you need the API surface. symbol: extract specific functions/classes by name (string or array) — use when you know what to read; add context=true for callers, tests, and dependencies. range: specific line range — use only when you need exact lines. full: raw content, no savings — only for config/lock files. maxTokens: token budget — auto-cascades to fit (outline → signatures → truncated). Supports JS/TS, Python, Go, Rust, Java, C#, Kotlin, PHP, Swift, shell, Terraform, Dockerfile, SQL, JSON, TOML, YAML.',
|
|
132
116
|
{
|
|
133
117
|
filePath: z.string(),
|
|
134
118
|
mode: z.enum(['full', 'outline', 'signatures', 'range', 'symbol']).optional(),
|
|
@@ -144,7 +128,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
144
128
|
|
|
145
129
|
server.tool(
|
|
146
130
|
'smart_read_batch',
|
|
147
|
-
'Read multiple files in one call. Each item accepts path, mode, symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded. Max 20 files per call.',
|
|
131
|
+
'Read multiple files in one call. Each item accepts path, mode (prefer outline/signatures/symbol — full saves 0 tokens), symbol, startLine, endLine, maxTokens (per-file budget). Optional global maxTokens budget with early stop when exceeded. Max 20 files per call.',
|
|
148
132
|
{
|
|
149
133
|
files: z.array(z.object({
|
|
150
134
|
path: z.string(),
|
|
@@ -162,7 +146,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
162
146
|
|
|
163
147
|
server.tool(
|
|
164
148
|
'smart_search',
|
|
165
|
-
'Search code across the project using ripgrep (with filesystem fallback). Returns grouped, ranked results. Optional intent (implementation/debug/tests/config/docs/explore) adjusts ranking
|
|
149
|
+
'Search code across the project using ripgrep (with filesystem fallback). Returns grouped, ranked results. Optional intent (implementation/debug/tests/config/docs/explore) adjusts ranking. Use instead of native Grep for ranked, deduplicated results with index boosting.',
|
|
166
150
|
{
|
|
167
151
|
query: z.string(),
|
|
168
152
|
cwd: z.string().optional(),
|
|
@@ -173,7 +157,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
173
157
|
|
|
174
158
|
server.tool(
|
|
175
159
|
'smart_context',
|
|
176
|
-
'
|
|
160
|
+
'PREFERRED for multi-file tasks. Gets curated context in one call — replaces the manual search → read → read cycle. Combines search + graph expansion + selective reading. Returns relevant files with symbols and content, optimized for tokens. Options: intent, maxTokens (budget), diff (true for HEAD or branch name), detail (minimal/balanced/deep), include (content/graph/hints/symbolDetail), prefetch (true for predictive loading). Call this FIRST before individual smart_read/smart_search calls.',
|
|
177
161
|
{
|
|
178
162
|
task: z.string(),
|
|
179
163
|
intent: z.enum(['implementation', 'debug', 'tests', 'config', 'docs', 'explore']).optional(),
|
|
@@ -190,7 +174,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
190
174
|
|
|
191
175
|
server.tool(
|
|
192
176
|
'smart_shell',
|
|
193
|
-
'Run a diagnostic shell command from an allowlist. Allowed: pwd, ls, find, rg, git (status/diff/show/log/branch/rev-parse), npm/pnpm/yarn/bun (test/run/lint/build/typecheck/check). Blocks shell operators, pipes, and unsafe commands.
|
|
177
|
+
'Run a diagnostic shell command from an allowlist. Allowed: pwd, ls, find, rg, git (status/diff/show/log/branch/rev-parse), npm/pnpm/yarn/bun (test/run/lint/build/typecheck/check). Blocks shell operators, pipes, and unsafe commands. For large diffs: output is split by file (up to 8 files, 60 lines each); prefer git diff --stat first, then git show -- <file> per file.',
|
|
194
178
|
{
|
|
195
179
|
command: z.string(),
|
|
196
180
|
},
|
|
@@ -487,7 +471,7 @@ This ensures optimal performance and context recovery.`,
|
|
|
487
471
|
|
|
488
472
|
server.tool(
|
|
489
473
|
'smart_turn',
|
|
490
|
-
'Orchestrate start/end of a meaningful agent turn
|
|
474
|
+
'Orchestrate start/end of a meaningful agent turn for multi-session tasks where context continuity matters. SKIP for single-session point-in-time tasks (reviewing a specific commit, answering a quick question, one-off lookup) — the setup overhead exceeds the benefit if the session will never be resumed. USE when: the task spans multiple chat sessions, you may return to it the next day, or the codebase context is large enough that re-reading is expensive. `phase: "start"` rehydrates persisted context, classifies prompt continuity against the saved session, optionally auto-creates a planning session for a new substantial task, returns `recommendedPath` guidance for the next safe devctx actions, and can include compact metrics. `phase: "end"` writes a checkpoint through smart_summary, returns follow-up `recommendedPath` guidance, and can optionally include compact metrics. Both phases expose `mutationSafety` when repo-safety blocks persisted writes and surface `storageHealth` when SQLite state is missing, oversized, locked, or corrupted.',
|
|
491
475
|
{
|
|
492
476
|
phase: z.enum(['start', 'end']),
|
|
493
477
|
sessionId: z.string().optional(),
|
package/src/tokenCounter.js
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { encodingForModel } from 'js-tiktoken';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const encoder = encodingForModel(fallbackModel);
|
|
3
|
+
const CLAUDE_ALIASES = new Set(['claude', 'anthropic']);
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// js-tiktoken does not ship Claude's tokenizer; gpt-4o (o200k_base) is the
|
|
6
|
+
// closest available encoding. Accuracy for Claude models: ±15-20%.
|
|
7
|
+
const CLAUDE_FALLBACK = 'gpt-4o';
|
|
8
|
+
const DEFAULT_MODEL = 'gpt-4o-mini';
|
|
9
|
+
|
|
10
|
+
const resolveModel = () => {
|
|
11
|
+
const requested = (process.env.DEVCTX_TOKEN_MODEL || '').toLowerCase().trim();
|
|
12
|
+
if (!requested) return DEFAULT_MODEL;
|
|
13
|
+
if (CLAUDE_ALIASES.has(requested) || requested.startsWith('claude')) {
|
|
14
|
+
return CLAUDE_FALLBACK;
|
|
15
|
+
}
|
|
16
|
+
return requested;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const buildEncoder = () => {
|
|
20
|
+
const model = resolveModel();
|
|
21
|
+
try {
|
|
22
|
+
return encodingForModel(model);
|
|
23
|
+
} catch {
|
|
24
|
+
return encodingForModel(DEFAULT_MODEL);
|
|
9
25
|
}
|
|
26
|
+
};
|
|
10
27
|
|
|
28
|
+
// Encoder is initialised once; if the env var changes at runtime the process
|
|
29
|
+
// must be restarted (acceptable for a CLI/MCP server).
|
|
30
|
+
const encoder = buildEncoder();
|
|
31
|
+
|
|
32
|
+
export const countTokens = (text = '') => {
|
|
33
|
+
if (!text) return 0;
|
|
11
34
|
return encoder.encode(String(text)).length;
|
|
12
35
|
};
|
|
@@ -15,7 +15,6 @@ import { predictContextFiles, recordContextAccess } from '../context-patterns.js
|
|
|
15
15
|
import { recordToolUsage } from '../usage-feedback.js';
|
|
16
16
|
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
17
17
|
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
18
|
-
import { buildMetricsDisplay } from '../utils/metrics-display.js';
|
|
19
18
|
import { createProgressReporter } from '../streaming.js';
|
|
20
19
|
import {
|
|
21
20
|
getDetailedDiff,
|
|
@@ -35,7 +34,6 @@ import {
|
|
|
35
34
|
} from '../utils/query-extraction.js';
|
|
36
35
|
import {
|
|
37
36
|
dedupeEvidence,
|
|
38
|
-
formatReasonIncluded,
|
|
39
37
|
buildSymbolPreviews,
|
|
40
38
|
attachSymbolEvidence,
|
|
41
39
|
computeStaticUtility,
|
|
@@ -226,29 +224,6 @@ const getSymbolSignatureLimit = (item, detailMode, readMode) => {
|
|
|
226
224
|
const getSymbolSignatures = (entries, maxItems = 10) =>
|
|
227
225
|
entries.filter((entry) => entry.signature).slice(0, maxItems).map((entry) => entry.signature);
|
|
228
226
|
|
|
229
|
-
const serializeEvidencePayload = (item) => {
|
|
230
|
-
const evidence = dedupeEvidence(item.evidence ?? []);
|
|
231
|
-
if (evidence.length === 0) return [];
|
|
232
|
-
|
|
233
|
-
const limit = item.role === 'primary' ? 2 : 1;
|
|
234
|
-
const preferred = item.role === 'primary'
|
|
235
|
-
? evidence
|
|
236
|
-
: [
|
|
237
|
-
evidence.find((entry) => ['testOf', 'dependencyOf', 'dependentOf'].includes(entry.type)),
|
|
238
|
-
evidence[0],
|
|
239
|
-
].filter(Boolean);
|
|
240
|
-
|
|
241
|
-
return uniqueList(preferred)
|
|
242
|
-
.slice(0, limit)
|
|
243
|
-
.map((entry) => ({
|
|
244
|
-
type: entry.type,
|
|
245
|
-
...(entry.via ? { via: entry.via } : {}),
|
|
246
|
-
...(entry.query && item.role === 'primary' ? { query: entry.query } : {}),
|
|
247
|
-
...(entry.ref && item.role === 'primary' ? { ref: entry.ref } : {}),
|
|
248
|
-
...(Array.isArray(entry.symbols) && entry.symbols.length > 0 ? { symbols: entry.symbols.slice(0, 2) } : {}),
|
|
249
|
-
}));
|
|
250
|
-
};
|
|
251
|
-
|
|
252
227
|
const shouldIncludeSymbolNames = (item, symbolPreviews, readMode) => {
|
|
253
228
|
if (item.role === 'primary') return true;
|
|
254
229
|
if (readMode === 'full') return true;
|
|
@@ -273,14 +248,10 @@ const buildContextItemPayload = (item, index, detailMode, readMode = 'index-only
|
|
|
273
248
|
const symbolSignatures = shouldIncludeSymbolSignatures(item, symbolPreviews)
|
|
274
249
|
? getSymbolSignatures(fileSymbolEntries, getSymbolSignatureLimit(item, detailMode, readMode))
|
|
275
250
|
: [];
|
|
276
|
-
const evidence = serializeEvidencePayload(item);
|
|
277
251
|
|
|
278
252
|
return {
|
|
279
253
|
file: item.rel,
|
|
280
254
|
role: item.role,
|
|
281
|
-
readMode,
|
|
282
|
-
reasonIncluded: formatReasonIncluded(item.evidence),
|
|
283
|
-
evidence,
|
|
284
255
|
...(fileSymbols.length > 0 ? { symbols: fileSymbols } : {}),
|
|
285
256
|
...(symbolSignatures.length > 0 ? { symbolSignatures } : {}),
|
|
286
257
|
...(symbolPreviews.length > 0 ? { symbolPreviews } : {}),
|
|
@@ -412,17 +383,11 @@ export const smartContext = async ({
|
|
|
412
383
|
|
|
413
384
|
await ensureIndexReady({ root });
|
|
414
385
|
|
|
415
|
-
// Get detailed diff stats
|
|
416
386
|
const detailedChanges = await getDetailedDiff(changed.ref, root);
|
|
417
387
|
const index = loadIndex(root);
|
|
418
|
-
|
|
419
|
-
// Analyze impact and prioritize
|
|
420
388
|
const prioritized = analyzeChangeImpact(detailedChanges, index);
|
|
421
|
-
|
|
422
|
-
// Expand to include related files (importers, dependencies, tests)
|
|
423
389
|
const expandedFiles = expandChangedContext(changed.files, index, 10);
|
|
424
|
-
|
|
425
|
-
// Build primary seeds with priority and impact data
|
|
390
|
+
|
|
426
391
|
primarySeeds = Array.from(expandedFiles).map(rel => {
|
|
427
392
|
const changeInfo = prioritized.find(c => c.file === rel);
|
|
428
393
|
const evidence = [{
|
|
@@ -432,7 +397,6 @@ export const smartContext = async ({
|
|
|
432
397
|
impact: changeInfo?.impactScore || 0,
|
|
433
398
|
}];
|
|
434
399
|
|
|
435
|
-
// Mark files that were expanded (not directly changed)
|
|
436
400
|
if (!changed.files.includes(rel)) {
|
|
437
401
|
evidence[0].expanded = true;
|
|
438
402
|
}
|
|
@@ -444,7 +408,6 @@ export const smartContext = async ({
|
|
|
444
408
|
};
|
|
445
409
|
});
|
|
446
410
|
|
|
447
|
-
// Sort by impact (critical changes first)
|
|
448
411
|
primarySeeds.sort((a, b) => {
|
|
449
412
|
const impactA = a.evidence[0].impact || 0;
|
|
450
413
|
const impactB = b.evidence[0].impact || 0;
|
|
@@ -677,16 +640,9 @@ export const smartContext = async ({
|
|
|
677
640
|
|
|
678
641
|
const filtered = filterFoundSymbols(symbolResult.content, symbolCandidates);
|
|
679
642
|
if (filtered) {
|
|
680
|
-
const symbolEvidence = dedupeEvidence([{
|
|
681
|
-
type: 'symbolDetail',
|
|
682
|
-
symbols: symbolCandidates.slice(0, 3),
|
|
683
|
-
}]);
|
|
684
643
|
const symbolPayload = {
|
|
685
644
|
file: topPrimary.rel,
|
|
686
645
|
role: 'symbolDetail',
|
|
687
|
-
readMode: 'symbol',
|
|
688
|
-
reasonIncluded: formatReasonIncluded(symbolEvidence),
|
|
689
|
-
evidence: symbolEvidence,
|
|
690
646
|
content: filtered,
|
|
691
647
|
};
|
|
692
648
|
const symbolTokens = countTokens(JSON.stringify(symbolPayload));
|
|
@@ -700,7 +656,6 @@ export const smartContext = async ({
|
|
|
700
656
|
const existing = context[existingIdx];
|
|
701
657
|
const signaturesOnly = {
|
|
702
658
|
...existing,
|
|
703
|
-
readMode: 'signatures-only',
|
|
704
659
|
content: '(omitted — see symbolDetail)',
|
|
705
660
|
};
|
|
706
661
|
const oldTokens = countTokens(JSON.stringify(existing));
|
|
@@ -755,7 +710,6 @@ export const smartContext = async ({
|
|
|
755
710
|
|
|
756
711
|
const contentTokens = countTokens(context.map((c) => c.content).join('\n'));
|
|
757
712
|
const previewTokens = context.reduce((sum, item) => sum + countTokens(JSON.stringify(item.symbolPreviews ?? [])), 0);
|
|
758
|
-
const indexOnlyItems = context.filter((item) => item.readMode === 'index-only').length;
|
|
759
713
|
const contentItems = context.filter((item) => typeof item.content === 'string' && item.content.length > 0).length;
|
|
760
714
|
const primaryItem = context.find((item) => item.role === 'primary');
|
|
761
715
|
|
|
@@ -771,17 +725,13 @@ export const smartContext = async ({
|
|
|
771
725
|
timestamp: new Date().toISOString(),
|
|
772
726
|
});
|
|
773
727
|
|
|
774
|
-
// Record usage for feedback
|
|
775
728
|
recordToolUsage({
|
|
776
729
|
tool: 'smart_context',
|
|
777
730
|
savedTokens,
|
|
778
731
|
target: task,
|
|
779
732
|
});
|
|
780
|
-
|
|
781
|
-
// Record devctx operation for missed opportunity detection
|
|
782
733
|
recordDevctxOperation();
|
|
783
|
-
|
|
784
|
-
// Record decision explanation
|
|
734
|
+
|
|
785
735
|
let reason = DECISION_REASONS.TASK_CONTEXT;
|
|
786
736
|
if (diff) {
|
|
787
737
|
reason = DECISION_REASONS.DIFF_ANALYSIS;
|
|
@@ -828,17 +778,6 @@ export const smartContext = async ({
|
|
|
828
778
|
};
|
|
829
779
|
|
|
830
780
|
const filesIncluded = new Set(context.map((c) => c.file)).size;
|
|
831
|
-
const metricsDisplay = buildMetricsDisplay({
|
|
832
|
-
tool: 'smart_context',
|
|
833
|
-
target: task,
|
|
834
|
-
metrics: {
|
|
835
|
-
rawTokens: totalRawTokens,
|
|
836
|
-
compressedTokens: totalCompressedTokens,
|
|
837
|
-
savedTokens,
|
|
838
|
-
},
|
|
839
|
-
startTime: enableProgress ? startTime : null,
|
|
840
|
-
filesCount: filesIncluded,
|
|
841
|
-
});
|
|
842
781
|
|
|
843
782
|
if (progress) {
|
|
844
783
|
progress.complete({
|
|
@@ -857,28 +796,20 @@ export const smartContext = async ({
|
|
|
857
796
|
confidence: { indexFreshness, graphCoverage: graphCov },
|
|
858
797
|
context,
|
|
859
798
|
...(includeSet.has('graph') ? { graph: graphSummary, graphCoverage: graphCov } : {}),
|
|
860
|
-
|
|
861
|
-
contentTokens,
|
|
862
|
-
totalTokens: 0,
|
|
799
|
+
stats: {
|
|
863
800
|
filesIncluded,
|
|
864
801
|
filesEvaluated: expanded.size,
|
|
865
|
-
savingsPct,
|
|
866
802
|
detailMode,
|
|
867
|
-
|
|
868
|
-
previewTokens,
|
|
869
|
-
indexOnlyItems,
|
|
870
|
-
contentItems,
|
|
871
|
-
primaryReadMode: primaryItem?.readMode ?? null,
|
|
803
|
+
totalTokens: countTokens(context.map((c) => c.content || '').join('')),
|
|
872
804
|
...(prefetchResult ? {
|
|
873
805
|
prefetch: {
|
|
874
806
|
enabled: true,
|
|
875
807
|
confidence: prefetchResult.confidence || 0,
|
|
876
808
|
predictedFiles: prefetchResult.predicted?.length || 0,
|
|
877
|
-
matchedPattern: prefetchResult.matchedPattern || null
|
|
878
|
-
}
|
|
879
|
-
} : {})
|
|
809
|
+
matchedPattern: prefetchResult.matchedPattern || null,
|
|
810
|
+
},
|
|
811
|
+
} : {}),
|
|
880
812
|
},
|
|
881
|
-
metricsDisplay,
|
|
882
813
|
...(includeSet.has('hints') ? { hints } : {}),
|
|
883
814
|
};
|
|
884
815
|
|
|
@@ -887,7 +818,5 @@ export const smartContext = async ({
|
|
|
887
818
|
result.diffSummary = diffSummary;
|
|
888
819
|
}
|
|
889
820
|
|
|
890
|
-
result.metrics.totalTokens = countTokens(JSON.stringify(result));
|
|
891
|
-
|
|
892
821
|
return result;
|
|
893
822
|
};
|
|
@@ -105,15 +105,25 @@ const formatDeclarationName = (name) => {
|
|
|
105
105
|
|
|
106
106
|
const collectVariableNames = (declarationList) => declarationList.declarations.map((declaration) => formatDeclarationName(declaration.name));
|
|
107
107
|
|
|
108
|
-
const
|
|
108
|
+
const getFunctionSignature = (statement, sourceFile) => {
|
|
109
|
+
const body = statement.body;
|
|
110
|
+
if (!body) return statement.getText(sourceFile).split('\n')[0];
|
|
111
|
+
const fullText = statement.getText(sourceFile);
|
|
112
|
+
const bodyOffset = body.getStart(sourceFile) - statement.getStart(sourceFile);
|
|
113
|
+
const sig = fullText.slice(0, bodyOffset).replace(/\s+$/, '');
|
|
114
|
+
return sig.length > 120 ? `${sig.slice(0, 120)}...` : sig;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const formatTopLevelStatement = (statement, sourceFile, mode = 'outline') => {
|
|
109
118
|
const exported = statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
110
119
|
const prefix = exported ? 'export ' : '';
|
|
111
120
|
|
|
112
121
|
if (ts.isImportDeclaration(statement)) {
|
|
113
|
-
return
|
|
122
|
+
return null;
|
|
114
123
|
}
|
|
115
124
|
|
|
116
125
|
if (ts.isFunctionDeclaration(statement)) {
|
|
126
|
+
if (mode === 'signatures') return getFunctionSignature(statement, sourceFile);
|
|
117
127
|
return `${prefix}function ${getNodeName(statement)}()`;
|
|
118
128
|
}
|
|
119
129
|
|
|
@@ -143,7 +153,8 @@ const formatTopLevelStatement = (statement, sourceFile) => {
|
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
if (ts.isExportAssignment(statement)) {
|
|
146
|
-
|
|
156
|
+
const text = statement.expression.getText(sourceFile);
|
|
157
|
+
return `export default ${text.length > 60 ? `${text.slice(0, 60)}...` : text}`;
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
return statement.getText(sourceFile).split('\n')[0];
|
|
@@ -248,7 +259,8 @@ export const summarizeCode = (fullPath, content, mode) => {
|
|
|
248
259
|
const sourceFile = parseSource(fullPath, content);
|
|
249
260
|
const topLevel = sourceFile.statements.flatMap((statement) => {
|
|
250
261
|
if (isIIFE(statement)) return extractIIFEMembers(statement, sourceFile);
|
|
251
|
-
|
|
262
|
+
const formatted = formatTopLevelStatement(statement, sourceFile, mode);
|
|
263
|
+
return formatted !== null ? [formatted] : [];
|
|
252
264
|
});
|
|
253
265
|
const hooks = collectHooks(sourceFile);
|
|
254
266
|
|
|
@@ -4,7 +4,6 @@ import { countTokens } from '../tokenCounter.js';
|
|
|
4
4
|
export const smartReadBatch = async ({ files, maxTokens }) => {
|
|
5
5
|
const results = [];
|
|
6
6
|
let totalTokens = 0;
|
|
7
|
-
let totalRawTokens = 0;
|
|
8
7
|
let filesSkipped = 0;
|
|
9
8
|
|
|
10
9
|
for (const item of files) {
|
|
@@ -40,13 +39,11 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
|
|
|
40
39
|
parser: readResult.parser,
|
|
41
40
|
truncated: readResult.truncated,
|
|
42
41
|
content: readResult.content,
|
|
43
|
-
...(readResult.confidence ? { confidence: readResult.confidence } : {}),
|
|
44
42
|
...(readResult.indexHint !== undefined ? { indexHint: readResult.indexHint } : {}),
|
|
45
43
|
...(readResult.chosenMode ? { chosenMode: readResult.chosenMode, budgetApplied: true } : {}),
|
|
46
44
|
});
|
|
47
45
|
|
|
48
46
|
totalTokens += itemTokens;
|
|
49
|
-
totalRawTokens += readResult.metrics?.rawTokens ?? 0;
|
|
50
47
|
} catch (err) {
|
|
51
48
|
results.push({
|
|
52
49
|
filePath: item.path,
|
|
@@ -56,17 +53,12 @@ export const smartReadBatch = async ({ files, maxTokens }) => {
|
|
|
56
53
|
}
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
const totalSavingsPct = totalRawTokens > 0
|
|
60
|
-
? Math.max(0, Math.round(((totalRawTokens - totalTokens) / totalRawTokens) * 100))
|
|
61
|
-
: 0;
|
|
62
|
-
|
|
63
56
|
return {
|
|
64
57
|
results,
|
|
65
58
|
metrics: {
|
|
66
59
|
totalTokens,
|
|
67
60
|
filesRead: results.length,
|
|
68
61
|
filesSkipped,
|
|
69
|
-
totalSavingsPct,
|
|
70
62
|
},
|
|
71
63
|
};
|
|
72
64
|
};
|
package/src/tools/smart-read.js
CHANGED
|
@@ -12,7 +12,6 @@ import { countTokens } from '../tokenCounter.js';
|
|
|
12
12
|
import { recordToolUsage } from '../usage-feedback.js';
|
|
13
13
|
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
14
14
|
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
15
|
-
import { buildMetricsDisplay } from '../utils/metrics-display.js';
|
|
16
15
|
import { createProgressReporter } from '../streaming.js';
|
|
17
16
|
|
|
18
17
|
const execFile = promisify(execFileCb);
|
|
@@ -536,17 +535,12 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
536
535
|
|
|
537
536
|
await persistMetrics(metrics);
|
|
538
537
|
|
|
539
|
-
// Record usage for feedback
|
|
540
538
|
recordToolUsage({
|
|
541
539
|
tool: 'smart_read',
|
|
542
540
|
savedTokens: metrics.savedTokens,
|
|
543
541
|
target: path.relative(effectiveRoot, fullPath),
|
|
544
542
|
});
|
|
545
|
-
|
|
546
|
-
// Record devctx operation for missed opportunity detection
|
|
547
543
|
recordDevctxOperation();
|
|
548
|
-
|
|
549
|
-
// Record decision explanation
|
|
550
544
|
const lineCount = content.split('\n').length;
|
|
551
545
|
let reason = DECISION_REASONS.LARGE_FILE;
|
|
552
546
|
let expectedBenefit = EXPECTED_BENEFITS.TOKEN_SAVINGS(metrics.savedTokens);
|
|
@@ -568,16 +562,6 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
568
562
|
context: `${lineCount} lines, ${metrics.rawTokens} tokens → ${metrics.compressedTokens} tokens`,
|
|
569
563
|
});
|
|
570
564
|
|
|
571
|
-
const confidence = { parser, truncated, cached: cacheHit && !contextResult };
|
|
572
|
-
if (contextResult) confidence.graphCoverage = contextResult.graphCoverage;
|
|
573
|
-
|
|
574
|
-
const metricsDisplay = buildMetricsDisplay({
|
|
575
|
-
tool: 'smart_read',
|
|
576
|
-
target: path.relative(effectiveRoot, fullPath),
|
|
577
|
-
metrics,
|
|
578
|
-
startTime: enableProgress ? startTime : null,
|
|
579
|
-
});
|
|
580
|
-
|
|
581
565
|
if (progress) {
|
|
582
566
|
progress.complete({
|
|
583
567
|
file: path.relative(effectiveRoot, fullPath),
|
|
@@ -593,12 +577,7 @@ export const smartRead = async ({ filePath, mode = 'outline', startLine, endLine
|
|
|
593
577
|
parser,
|
|
594
578
|
truncated,
|
|
595
579
|
content: compressedText,
|
|
596
|
-
confidence,
|
|
597
|
-
metrics,
|
|
598
|
-
metricsDisplay,
|
|
599
580
|
};
|
|
600
|
-
|
|
601
|
-
if (cacheHit && !contextResult) result.cached = true;
|
|
602
581
|
if (mode === 'symbol') result.indexHint = indexHintUsed;
|
|
603
582
|
if (validBudget && effectiveMode !== mode) {
|
|
604
583
|
result.chosenMode = effectiveMode;
|
|
@@ -12,7 +12,6 @@ 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
14
|
import { IGNORED_DIRS, IGNORED_FILE_NAMES, IGNORED_FILE_PATTERNS } from '../config/ignored-paths.js';
|
|
15
|
-
import { buildMetricsDisplay } from '../utils/metrics-display.js';
|
|
16
15
|
import { createProgressReporter } from '../streaming.js';
|
|
17
16
|
import { ensureIndexReady } from '../index-manager.js';
|
|
18
17
|
|
|
@@ -475,17 +474,13 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
|
|
|
475
474
|
|
|
476
475
|
await persistMetrics(metrics);
|
|
477
476
|
|
|
478
|
-
// Record usage for feedback
|
|
479
477
|
recordToolUsage({
|
|
480
478
|
tool: 'smart_search',
|
|
481
479
|
savedTokens: metrics.savedTokens,
|
|
482
480
|
target: query,
|
|
483
481
|
});
|
|
484
|
-
|
|
485
|
-
// Record devctx operation for missed opportunity detection
|
|
486
482
|
recordDevctxOperation();
|
|
487
|
-
|
|
488
|
-
// Record decision explanation
|
|
483
|
+
|
|
489
484
|
let reason = DECISION_REASONS.MULTIPLE_FILES;
|
|
490
485
|
if (validIntent) {
|
|
491
486
|
reason = DECISION_REASONS.INTENT_AWARE;
|
|
@@ -510,16 +505,6 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
|
|
|
510
505
|
else if (usedFallback) retrievalConfidence = provenance?.skippedItemsTotal > 0 ? 'low' : 'medium';
|
|
511
506
|
else if (provenance?.skippedItemsTotal > 0) retrievalConfidence = 'low';
|
|
512
507
|
|
|
513
|
-
const confidence = { level: retrievalConfidence, indexFreshness };
|
|
514
|
-
|
|
515
|
-
const metricsDisplay = buildMetricsDisplay({
|
|
516
|
-
tool: 'smart_search',
|
|
517
|
-
target: query,
|
|
518
|
-
metrics,
|
|
519
|
-
startTime: enableProgress ? startTime : null,
|
|
520
|
-
filesCount: groups.length,
|
|
521
|
-
});
|
|
522
|
-
|
|
523
508
|
if (progress) {
|
|
524
509
|
progress.complete({
|
|
525
510
|
query,
|
|
@@ -532,23 +517,17 @@ export const smartSearch = async ({ query, cwd = '.', intent, _testForceWalk = f
|
|
|
532
517
|
|
|
533
518
|
const result = {
|
|
534
519
|
query,
|
|
535
|
-
root,
|
|
536
|
-
engine,
|
|
537
|
-
retrievalConfidence,
|
|
538
520
|
indexFreshness,
|
|
539
|
-
sourceBreakdown: breakdown,
|
|
540
|
-
confidence,
|
|
541
521
|
...(validIntent ? { intent: validIntent } : {}),
|
|
542
522
|
...(indexHits ? { indexBoosted: indexHits.size } : {}),
|
|
543
523
|
totalMatches: dedupedMatches.length,
|
|
544
524
|
matchedFiles: groups.length,
|
|
545
525
|
topFiles: groups.slice(0, 10).map((group) => ({ file: group.file, count: group.count, score: group.score })),
|
|
546
526
|
matches: compressedText,
|
|
547
|
-
metrics,
|
|
548
|
-
metricsDisplay,
|
|
549
527
|
};
|
|
550
528
|
|
|
551
|
-
if (provenance) result.
|
|
529
|
+
if (provenance?.fallbackReason) result.searchMode = provenance.fallbackReason;
|
|
530
|
+
if (retrievalConfidence !== 'high') result.retrievalConfidence = retrievalConfidence;
|
|
552
531
|
|
|
553
532
|
return result;
|
|
554
533
|
};
|
package/src/tools/smart-shell.js
CHANGED
|
@@ -7,10 +7,13 @@ import { pickRelevantLines, truncate, uniqueLines } from '../utils/text.js';
|
|
|
7
7
|
import { recordToolUsage } from '../usage-feedback.js';
|
|
8
8
|
import { recordDecision, DECISION_REASONS, EXPECTED_BENEFITS } from '../decision-explainer.js';
|
|
9
9
|
import { recordDevctxOperation } from '../missed-opportunities.js';
|
|
10
|
-
import { buildMetricsDisplay } from '../utils/metrics-display.js';
|
|
11
|
-
|
|
12
10
|
const execFile = promisify(execFileCallback);
|
|
13
11
|
const isShellDisabled = () => process.env.DEVCTX_SHELL_DISABLED === 'true';
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
13
|
+
const getTimeoutMs = () => {
|
|
14
|
+
const env = parseInt(process.env.DEVCTX_SHELL_TIMEOUT_MS, 10);
|
|
15
|
+
return Number.isFinite(env) && env > 0 ? env : DEFAULT_TIMEOUT_MS;
|
|
16
|
+
};
|
|
14
17
|
const allowedCommands = new Set(['pwd', 'ls', 'find', 'rg', 'git', 'npm', 'pnpm', 'yarn', 'bun']);
|
|
15
18
|
const allowedGitSubcommands = new Set(['status', 'diff', 'show', 'log', 'branch', 'rev-parse', 'blame']);
|
|
16
19
|
const allowedPackageManagerSubcommands = new Set(['test', 'run', 'lint', 'build', 'typecheck', 'check']);
|
|
@@ -176,6 +179,52 @@ const validateCommand = (command, tokens) => {
|
|
|
176
179
|
return null;
|
|
177
180
|
};
|
|
178
181
|
|
|
182
|
+
const DIFF_FILE_HEADER = /^diff --git a\/.+ b\/.+/;
|
|
183
|
+
const DIFF_HUNK_HEADER = /^@@ /;
|
|
184
|
+
const MAX_DIFF_FILES = 8;
|
|
185
|
+
const MAX_LINES_PER_FILE = 60;
|
|
186
|
+
const DIFF_TOTAL_LIMIT = 4000;
|
|
187
|
+
|
|
188
|
+
const splitDiffByFile = (text) => {
|
|
189
|
+
const files = [];
|
|
190
|
+
let current = null;
|
|
191
|
+
|
|
192
|
+
for (const line of text.split('\n')) {
|
|
193
|
+
if (DIFF_FILE_HEADER.test(line)) {
|
|
194
|
+
if (current) files.push(current);
|
|
195
|
+
current = { header: line, lines: [] };
|
|
196
|
+
} else if (current) {
|
|
197
|
+
current.lines.push(line);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (current) files.push(current);
|
|
201
|
+
return files;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const compressDiff = (text) => {
|
|
205
|
+
if (!DIFF_FILE_HEADER.test(text)) return text;
|
|
206
|
+
|
|
207
|
+
const files = splitDiffByFile(text);
|
|
208
|
+
if (files.length === 0) return text;
|
|
209
|
+
|
|
210
|
+
const shown = files.slice(0, MAX_DIFF_FILES);
|
|
211
|
+
const skipped = files.length - shown.length;
|
|
212
|
+
|
|
213
|
+
const parts = shown.map(({ header, lines }) => {
|
|
214
|
+
const truncatedLines = lines.slice(0, MAX_LINES_PER_FILE);
|
|
215
|
+
const skippedLines = lines.length - truncatedLines.length;
|
|
216
|
+
const hunkCount = lines.filter((l) => DIFF_HUNK_HEADER.test(l)).length;
|
|
217
|
+
const suffix = skippedLines > 0 ? [`... (${skippedLines} more lines — use smart_read(symbol) for full body)`] : [];
|
|
218
|
+
return [header, `# ${hunkCount} hunk(s)`, ...truncatedLines, ...suffix].join('\n');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const footer = skipped > 0
|
|
222
|
+
? `\n# ${skipped} more file(s) not shown — run git show -- <file> for each`
|
|
223
|
+
: '';
|
|
224
|
+
|
|
225
|
+
return truncate(parts.join('\n\n'), DIFF_TOTAL_LIMIT) + footer;
|
|
226
|
+
};
|
|
227
|
+
|
|
179
228
|
const buildBlockedResult = async (command, message) => {
|
|
180
229
|
const metrics = buildMetrics({
|
|
181
230
|
tool: 'smart_shell',
|
|
@@ -191,8 +240,6 @@ const buildBlockedResult = async (command, message) => {
|
|
|
191
240
|
exitCode: 126,
|
|
192
241
|
blocked: true,
|
|
193
242
|
output: message,
|
|
194
|
-
confidence: { blocked: true, timedOut: false },
|
|
195
|
-
metrics,
|
|
196
243
|
};
|
|
197
244
|
};
|
|
198
245
|
|
|
@@ -227,16 +274,17 @@ export const smartShell = async ({ command }) => {
|
|
|
227
274
|
}
|
|
228
275
|
|
|
229
276
|
const resolvedFile = file === 'rg' ? rgPath : file;
|
|
277
|
+
const timeoutMs = getTimeoutMs();
|
|
230
278
|
const execution = await execFile(resolvedFile, args, {
|
|
231
279
|
cwd: projectRoot,
|
|
232
280
|
maxBuffer: 1024 * 1024 * 10,
|
|
233
|
-
timeout:
|
|
281
|
+
timeout: timeoutMs,
|
|
234
282
|
}).then(
|
|
235
283
|
({ stdout, stderr }) => ({ stdout, stderr, code: 0 }),
|
|
236
284
|
(error) => ({
|
|
237
285
|
stdout: error.stdout ?? '',
|
|
238
286
|
stderr: error.killed
|
|
239
|
-
? `Command timed out after
|
|
287
|
+
? `Command timed out after ${timeoutMs / 1000}s: ${command}`
|
|
240
288
|
: (error.stderr ?? error.message ?? ''),
|
|
241
289
|
code: Number.isInteger(error.code) ? error.code : 1,
|
|
242
290
|
timedOut: !!error.killed,
|
|
@@ -254,7 +302,7 @@ export const smartShell = async ({ command }) => {
|
|
|
254
302
|
]);
|
|
255
303
|
const shouldPrioritizeRelevant = execution.code !== 0 || execution.timedOut;
|
|
256
304
|
const compressedSource = shouldPrioritizeRelevant && relevant ? relevant : rawText;
|
|
257
|
-
const compressedText = truncate(uniqueLines(compressedSource), 5000);
|
|
305
|
+
const compressedText = truncate(compressDiff(uniqueLines(compressedSource)), 5000);
|
|
258
306
|
const metrics = buildMetrics({
|
|
259
307
|
tool: 'smart_shell',
|
|
260
308
|
target: command,
|
|
@@ -264,17 +312,12 @@ export const smartShell = async ({ command }) => {
|
|
|
264
312
|
|
|
265
313
|
await persistMetrics(metrics);
|
|
266
314
|
|
|
267
|
-
// Record usage for feedback
|
|
268
315
|
recordToolUsage({
|
|
269
316
|
tool: 'smart_shell',
|
|
270
317
|
savedTokens: metrics.savedTokens,
|
|
271
318
|
target: command,
|
|
272
319
|
});
|
|
273
|
-
|
|
274
|
-
// Record devctx operation for missed opportunity detection
|
|
275
320
|
recordDevctxOperation();
|
|
276
|
-
|
|
277
|
-
// Record decision explanation
|
|
278
321
|
const outputLines = rawText.split('\n').length;
|
|
279
322
|
let reason = DECISION_REASONS.COMMAND_OUTPUT;
|
|
280
323
|
if (shouldPrioritizeRelevant && relevant) {
|
|
@@ -290,24 +333,13 @@ export const smartShell = async ({ command }) => {
|
|
|
290
333
|
context: `${outputLines} lines → ${compressedText.split('\n').length} lines (relevant only)`,
|
|
291
334
|
});
|
|
292
335
|
|
|
293
|
-
const metricsDisplay = buildMetricsDisplay({
|
|
294
|
-
tool: 'smart_shell',
|
|
295
|
-
target: command,
|
|
296
|
-
metrics,
|
|
297
|
-
startTime: null,
|
|
298
|
-
});
|
|
299
|
-
|
|
300
336
|
const result = {
|
|
301
337
|
command,
|
|
302
338
|
exitCode: execution.code,
|
|
303
339
|
blocked: false,
|
|
304
340
|
output: compressedText,
|
|
305
|
-
|
|
306
|
-
metrics,
|
|
307
|
-
metricsDisplay,
|
|
341
|
+
...(execution.timedOut ? { timedOut: true } : {}),
|
|
308
342
|
};
|
|
309
343
|
|
|
310
|
-
if (execution.timedOut) result.timedOut = true;
|
|
311
|
-
|
|
312
344
|
return result;
|
|
313
345
|
};
|
package/src/tools/smart-turn.js
CHANGED
|
@@ -13,6 +13,9 @@ import { smartContext } from './smart-context.js';
|
|
|
13
13
|
import { smartMetrics } from './smart-metrics.js';
|
|
14
14
|
import { smartSummary } from './smart-summary.js';
|
|
15
15
|
|
|
16
|
+
const isStorageUnhealthy = (health) =>
|
|
17
|
+
health && health.status !== 'ok' && health.status !== null && health.status !== undefined;
|
|
18
|
+
|
|
16
19
|
const DEFAULT_START_MAX_TOKENS = 400;
|
|
17
20
|
const DEFAULT_END_MAX_TOKENS = 500;
|
|
18
21
|
const DEFAULT_END_EVENT = 'milestone';
|
|
@@ -129,10 +132,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
|
|
|
129
132
|
state: 'resume',
|
|
130
133
|
shouldReuseContext: true,
|
|
131
134
|
reason: 'A persisted session was found and no prompt terms were available for comparison.',
|
|
132
|
-
sharedTerms: [],
|
|
133
|
-
promptTermCount: 0,
|
|
134
|
-
summaryTermCount: 0,
|
|
135
|
-
matchScore: 1,
|
|
136
135
|
};
|
|
137
136
|
}
|
|
138
137
|
|
|
@@ -147,10 +146,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
|
|
|
147
146
|
state: 'aligned',
|
|
148
147
|
shouldReuseContext: true,
|
|
149
148
|
reason: 'Prompt terms align with persisted task context.',
|
|
150
|
-
sharedTerms: sharedTerms.slice(0, 8),
|
|
151
|
-
promptTermCount: promptTerms.length,
|
|
152
|
-
summaryTermCount: summaryTerms.length,
|
|
153
|
-
matchScore,
|
|
154
149
|
};
|
|
155
150
|
}
|
|
156
151
|
|
|
@@ -159,10 +154,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
|
|
|
159
154
|
state: 'possible_shift',
|
|
160
155
|
shouldReuseContext: true,
|
|
161
156
|
reason: 'Prompt partially overlaps the persisted context; review before continuing.',
|
|
162
|
-
sharedTerms: sharedTerms.slice(0, 8),
|
|
163
|
-
promptTermCount: promptTerms.length,
|
|
164
|
-
summaryTermCount: summaryTerms.length,
|
|
165
|
-
matchScore,
|
|
166
157
|
};
|
|
167
158
|
}
|
|
168
159
|
|
|
@@ -170,10 +161,6 @@ const classifyContinuity = ({ prompt, summaryResult }) => {
|
|
|
170
161
|
state: 'context_mismatch',
|
|
171
162
|
shouldReuseContext: false,
|
|
172
163
|
reason: 'Prompt terms do not align with the persisted session summary.',
|
|
173
|
-
sharedTerms: [],
|
|
174
|
-
promptTermCount: promptTerms.length,
|
|
175
|
-
summaryTermCount: summaryTerms.length,
|
|
176
|
-
matchScore,
|
|
177
164
|
};
|
|
178
165
|
};
|
|
179
166
|
|
|
@@ -342,7 +329,7 @@ const buildStartRecommendedPath = ({
|
|
|
342
329
|
autoCreated,
|
|
343
330
|
isolatedSession,
|
|
344
331
|
nextTools: [...new Set(nextTools)],
|
|
345
|
-
steps,
|
|
332
|
+
instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
|
|
346
333
|
};
|
|
347
334
|
};
|
|
348
335
|
|
|
@@ -389,7 +376,7 @@ const buildEndRecommendedPath = ({ event, checkpoint, mutationSafety, workflow }
|
|
|
389
376
|
: 'checkpointed',
|
|
390
377
|
checkpointEvent: event,
|
|
391
378
|
nextTools: [...new Set(nextTools)],
|
|
392
|
-
steps,
|
|
379
|
+
instructions: steps.map((s) => `${s.tool}: ${s.instruction}`).join(' | '),
|
|
393
380
|
};
|
|
394
381
|
};
|
|
395
382
|
|
|
@@ -579,7 +566,7 @@ const startTurn = async ({
|
|
|
579
566
|
...(summaryResult.candidates ? { candidates: summaryResult.candidates } : {}),
|
|
580
567
|
...(summaryResult.recommendedSessionId ? { recommendedSessionId: summaryResult.recommendedSessionId } : {}),
|
|
581
568
|
...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
|
|
582
|
-
storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth
|
|
569
|
+
...(isStorageUnhealthy(summaryResult.storageHealth ?? metrics?.storageHealth) ? { storageHealth: summaryResult.storageHealth ?? metrics?.storageHealth } : {}),
|
|
583
570
|
recommendedPath,
|
|
584
571
|
message: mutationSafety?.blocked
|
|
585
572
|
? mutationSafety.message
|
|
@@ -694,7 +681,7 @@ const endTurn = async ({
|
|
|
694
681
|
checkpoint,
|
|
695
682
|
...(workflow ? { workflow } : {}),
|
|
696
683
|
...(metrics ? { metrics: summarizeMetrics(metrics) } : {}),
|
|
697
|
-
storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth
|
|
684
|
+
...(isStorageUnhealthy(checkpoint.storageHealth ?? metrics?.storageHealth) ? { storageHealth: checkpoint.storageHealth ?? metrics?.storageHealth } : {}),
|
|
698
685
|
recommendedPath,
|
|
699
686
|
message: mutationSafety?.blocked ? mutationSafety.message : checkpoint.message,
|
|
700
687
|
}, {
|
package/src/usage-feedback.js
CHANGED
|
@@ -60,7 +60,6 @@ export const formatUsageFeedback = () => {
|
|
|
60
60
|
lines.push('');
|
|
61
61
|
lines.push('📊 **devctx usage this session:**');
|
|
62
62
|
|
|
63
|
-
// Sort by count descending
|
|
64
63
|
const sorted = usage.tools.sort((a, b) => b.count - a.count);
|
|
65
64
|
|
|
66
65
|
for (const { tool, count, savedTokens, targets } of sorted) {
|
|
@@ -77,9 +77,9 @@ export const attachSafetyMetadata = (
|
|
|
77
77
|
|
|
78
78
|
return {
|
|
79
79
|
...result,
|
|
80
|
-
...(mutationSafety ? { mutationSafety } : {}),
|
|
81
|
-
repoSafety,
|
|
82
|
-
sideEffectsSuppressed:
|
|
80
|
+
...(mutationSafety?.blocked ? { mutationSafety } : {}),
|
|
81
|
+
...(repoSafety && (mutationSafety?.blocked || sideEffectsSuppressed) ? { repoSafety } : {}),
|
|
82
|
+
...(sideEffectsSuppressed ? { sideEffectsSuppressed: true } : {}),
|
|
83
83
|
...(degraded ? { degradedMode: degraded } : {}),
|
|
84
84
|
};
|
|
85
85
|
};
|
package/src/utils/text.js
CHANGED
|
@@ -8,6 +8,7 @@ export const truncate = (text = '', maxChars = 4000) => {
|
|
|
8
8
|
|
|
9
9
|
export const uniqueLines = (text = '') => {
|
|
10
10
|
const seen = new Set();
|
|
11
|
+
let prevEmpty = false;
|
|
11
12
|
|
|
12
13
|
return text
|
|
13
14
|
.split('\n')
|
|
@@ -15,9 +16,13 @@ export const uniqueLines = (text = '') => {
|
|
|
15
16
|
const key = line.trim();
|
|
16
17
|
|
|
17
18
|
if (!key) {
|
|
19
|
+
if (prevEmpty) return false;
|
|
20
|
+
prevEmpty = true;
|
|
18
21
|
return true;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
prevEmpty = false;
|
|
25
|
+
|
|
21
26
|
if (seen.has(key)) {
|
|
22
27
|
return false;
|
|
23
28
|
}
|
package/src/workflow-tracker.js
CHANGED
|
@@ -183,7 +183,6 @@ export const endWorkflow = (workflowId) => {
|
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
// Get workflow start time and session
|
|
187
186
|
const workflow = db
|
|
188
187
|
.prepare(
|
|
189
188
|
`
|
|
@@ -203,7 +202,6 @@ export const endWorkflow = (workflowId) => {
|
|
|
203
202
|
const endTime = new Date(now);
|
|
204
203
|
const durationMs = endTime - startTime;
|
|
205
204
|
|
|
206
|
-
// Get all metrics for this session since workflow start
|
|
207
205
|
const metrics = db
|
|
208
206
|
.prepare(
|
|
209
207
|
`
|
|
@@ -215,7 +213,6 @@ export const endWorkflow = (workflowId) => {
|
|
|
215
213
|
)
|
|
216
214
|
.all(workflow.session_id, workflow.start_time);
|
|
217
215
|
|
|
218
|
-
// Calculate totals
|
|
219
216
|
const rawTokens = metrics.reduce((sum, m) => sum + (m.raw_tokens || 0), 0);
|
|
220
217
|
const compressedTokens = metrics.reduce((sum, m) => sum + (m.compressed_tokens || 0), 0);
|
|
221
218
|
const savedTokens = metrics.reduce((sum, m) => sum + (m.saved_tokens || 0), 0);
|
|
@@ -231,7 +228,6 @@ export const endWorkflow = (workflowId) => {
|
|
|
231
228
|
const savingsPct = rawTokens > 0 ? ((savedTokens / rawTokens) * 100).toFixed(2) : 0;
|
|
232
229
|
const netSavingsPct = rawTokens > 0 ? ((netSavedTokens / rawTokens) * 100).toFixed(2) : 0;
|
|
233
230
|
|
|
234
|
-
// Calculate vs baseline
|
|
235
231
|
const baselineTokens = workflow.baseline_tokens || 0;
|
|
236
232
|
const vsBaselinePct = baselineTokens > 0 ? (((baselineTokens - compressedTokens) / baselineTokens) * 100).toFixed(2) : 0;
|
|
237
233
|
const vsBaselineNetPct = baselineTokens > 0 ? (((baselineTokens - (compressedTokens + overheadTokens)) / baselineTokens) * 100).toFixed(2) : 0;
|
|
@@ -247,10 +243,7 @@ export const endWorkflow = (workflowId) => {
|
|
|
247
243
|
},
|
|
248
244
|
};
|
|
249
245
|
|
|
250
|
-
// Get unique tools used
|
|
251
246
|
const toolsUsed = [...new Set(metrics.map((m) => m.tool))];
|
|
252
|
-
|
|
253
|
-
// Update workflow
|
|
254
247
|
const stmt = db.prepare(`
|
|
255
248
|
UPDATE workflow_metrics
|
|
256
249
|
SET end_time = ?,
|
|
@@ -584,7 +577,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
|
|
|
584
577
|
return null;
|
|
585
578
|
}
|
|
586
579
|
|
|
587
|
-
// Check if workflow already tracked for this session
|
|
588
580
|
const existing = db
|
|
589
581
|
.prepare(
|
|
590
582
|
`
|
|
@@ -601,7 +593,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
|
|
|
601
593
|
return existing.workflow_id;
|
|
602
594
|
}
|
|
603
595
|
|
|
604
|
-
// Get tools used so far in this session
|
|
605
596
|
const metrics = db
|
|
606
597
|
.prepare(
|
|
607
598
|
`
|
|
@@ -621,7 +612,6 @@ export const autoTrackWorkflow = (sessionId, sessionGoal) => {
|
|
|
621
612
|
return null;
|
|
622
613
|
}
|
|
623
614
|
|
|
624
|
-
// Start tracking
|
|
625
615
|
return startWorkflow(workflowType, sessionId, { autoDetected: true, goal: sessionGoal });
|
|
626
616
|
});
|
|
627
617
|
} catch {
|