token-pilot 0.15.0 → 0.16.1
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/CHANGELOG.md +26 -3
- package/README.md +25 -8
- package/dist/ast-index/client.d.ts +2 -0
- package/dist/ast-index/client.js +26 -2
- package/dist/ast-index/regex-parser-python.d.ts +8 -0
- package/dist/ast-index/regex-parser-python.js +132 -0
- package/dist/core/hook-tracker.d.ts +20 -0
- package/dist/core/hook-tracker.js +57 -0
- package/dist/core/session-analytics.d.ts +4 -2
- package/dist/core/session-analytics.js +164 -28
- package/dist/core/validation.d.ts +12 -0
- package/dist/core/validation.js +62 -2
- package/dist/handlers/find-usages.d.ts +1 -1
- package/dist/handlers/find-usages.js +64 -4
- package/dist/handlers/read-for-edit.d.ts +1 -0
- package/dist/handlers/read-for-edit.js +65 -0
- package/dist/handlers/read-symbols.d.ts +18 -0
- package/dist/handlers/read-symbols.js +142 -0
- package/dist/handlers/smart-diff.js +23 -0
- package/dist/index.js +18 -3
- package/dist/server/tool-definitions.d.ts +113 -1
- package/dist/server/tool-definitions.js +33 -5
- package/dist/server.js +17 -4
- package/package.json +1 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { formatDuration } from './format-duration.js';
|
|
2
|
+
import { ALL_INTENTS } from './intent-classifier.js';
|
|
3
|
+
import { loadDeniedReads, clearDeniedReads } from './hook-tracker.js';
|
|
2
4
|
/**
|
|
3
5
|
* Tracks token savings and tool usage across a session.
|
|
4
6
|
* When context-mode is detected, includes unified reporting.
|
|
@@ -7,6 +9,10 @@ export class SessionAnalytics {
|
|
|
7
9
|
calls = [];
|
|
8
10
|
sessionStart = Date.now();
|
|
9
11
|
contextModeStatus = { detected: false, source: 'none', toolPrefix: '' };
|
|
12
|
+
projectRoot;
|
|
13
|
+
setProjectRoot(root) {
|
|
14
|
+
this.projectRoot = root;
|
|
15
|
+
}
|
|
10
16
|
setContextModeStatus(status) {
|
|
11
17
|
this.contextModeStatus = status;
|
|
12
18
|
}
|
|
@@ -14,53 +20,50 @@ export class SessionAnalytics {
|
|
|
14
20
|
this.calls.push(call);
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
|
-
* Generate
|
|
23
|
+
* Generate session report. Compact by default (~5 lines), verbose=true for full breakdown.
|
|
18
24
|
*/
|
|
19
|
-
report() {
|
|
25
|
+
report(verbose = false) {
|
|
20
26
|
const duration = formatDuration(Date.now() - this.sessionStart);
|
|
21
27
|
const totalReturned = this.calls.reduce((s, c) => s + c.tokensReturned, 0);
|
|
22
28
|
const totalWouldBe = this.calls.reduce((s, c) => s + c.tokensWouldBe, 0);
|
|
23
29
|
const saved = totalWouldBe > 0 ? Math.round((1 - totalReturned / totalWouldBe) * 100) : 0;
|
|
30
|
+
// --- Shared data ---
|
|
31
|
+
const byTool = new Map();
|
|
32
|
+
for (const c of this.calls) {
|
|
33
|
+
const e = byTool.get(c.tool) ?? { count: 0, tokens: 0, saved: 0, wouldBe: 0 };
|
|
34
|
+
e.count++;
|
|
35
|
+
e.tokens += c.tokensReturned;
|
|
36
|
+
e.saved += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
37
|
+
e.wouldBe += c.tokensWouldBe;
|
|
38
|
+
byTool.set(c.tool, e);
|
|
39
|
+
}
|
|
40
|
+
const sortedTools = Array.from(byTool.entries()).sort((a, b) => b[1].saved - a[1].saved);
|
|
41
|
+
const byFile = new Map();
|
|
42
|
+
for (const c of this.calls) {
|
|
43
|
+
if (c.path) {
|
|
44
|
+
byFile.set(c.path, (byFile.get(c.path) ?? 0) + Math.max(0, c.tokensWouldBe - c.tokensReturned));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const cacheHits = this.calls.filter(c => c.sessionCacheHit);
|
|
48
|
+
// --- Compact report ---
|
|
49
|
+
const tokensSaved = totalWouldBe - totalReturned;
|
|
24
50
|
const lines = [
|
|
25
51
|
`SESSION ANALYTICS (${duration})`,
|
|
26
|
-
`Calls: ${this.calls.length} · Tokens returned: ~${totalReturned} · Saved: ~${
|
|
52
|
+
`Calls: ${this.calls.length} · Tokens returned: ~${totalReturned} · Saved: ~${tokensSaved} (${saved}%)`,
|
|
27
53
|
];
|
|
28
|
-
// By tool — top 5 by savings, all on one line
|
|
29
54
|
if (this.calls.length > 0) {
|
|
30
|
-
const
|
|
31
|
-
for (const c of this.calls) {
|
|
32
|
-
const e = byTool.get(c.tool) ?? { count: 0, tokens: 0, wouldBe: 0 };
|
|
33
|
-
e.count++;
|
|
34
|
-
e.tokens += c.tokensReturned;
|
|
35
|
-
e.wouldBe += c.tokensWouldBe;
|
|
36
|
-
byTool.set(c.tool, e);
|
|
37
|
-
}
|
|
38
|
-
const sorted = Array.from(byTool.entries())
|
|
39
|
-
.sort((a, b) => (b[1].wouldBe - b[1].tokens) - (a[1].wouldBe - a[1].tokens))
|
|
40
|
-
.slice(0, 5);
|
|
41
|
-
const toolParts = sorted.map(([tool, s]) => {
|
|
55
|
+
const toolParts = sortedTools.slice(0, 5).map(([tool, s]) => {
|
|
42
56
|
const pct = s.wouldBe > 0 ? Math.round((1 - s.tokens / s.wouldBe) * 100) : 0;
|
|
43
57
|
return `${tool} ${s.count}× (${pct}%)`;
|
|
44
58
|
});
|
|
45
59
|
lines.push(`Tools: ${toolParts.join(' · ')}`);
|
|
46
60
|
}
|
|
47
|
-
|
|
48
|
-
const byFile = new Map();
|
|
49
|
-
for (const c of this.calls) {
|
|
50
|
-
if (c.path) {
|
|
51
|
-
byFile.set(c.path, (byFile.get(c.path) ?? 0) + Math.max(0, c.tokensWouldBe - c.tokensReturned));
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
const topFiles = Array.from(byFile.entries())
|
|
55
|
-
.sort((a, b) => b[1] - a[1])
|
|
56
|
-
.slice(0, 3);
|
|
61
|
+
const topFiles = Array.from(byFile.entries()).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
57
62
|
if (topFiles.length > 0) {
|
|
58
63
|
const fileParts = topFiles.map(([f, s]) => `${f} ~${s}`);
|
|
59
64
|
lines.push(`Top files: ${fileParts.join(' · ')}`);
|
|
60
65
|
}
|
|
61
|
-
// Cache hits + context-mode on one line
|
|
62
66
|
const extras = [];
|
|
63
|
-
const cacheHits = this.calls.filter(c => c.sessionCacheHit);
|
|
64
67
|
if (cacheHits.length > 0) {
|
|
65
68
|
const hitRate = Math.round(cacheHits.length / this.calls.length * 100);
|
|
66
69
|
extras.push(`Cache: ${cacheHits.length}/${this.calls.length} hits (${hitRate}%)`);
|
|
@@ -71,11 +74,144 @@ export class SessionAnalytics {
|
|
|
71
74
|
if (extras.length > 0) {
|
|
72
75
|
lines.push(extras.join(' · '));
|
|
73
76
|
}
|
|
77
|
+
// Hook interception savings
|
|
78
|
+
const deniedReads = loadDeniedReads(this.projectRoot);
|
|
79
|
+
const sessionDenied = deniedReads.filter(d => d.timestamp >= this.sessionStart);
|
|
80
|
+
if (sessionDenied.length > 0) {
|
|
81
|
+
const hookTokensSaved = sessionDenied.reduce((s, d) => s + d.estimatedTokens, 0);
|
|
82
|
+
lines.push(`Hook: intercepted ${sessionDenied.length} unbounded Read${sessionDenied.length === 1 ? '' : 's'}, saved ~${hookTokensSaved} tokens`);
|
|
83
|
+
}
|
|
84
|
+
if (!verbose)
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
// --- Verbose additions ---
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('--- DETAILED BREAKDOWN ---');
|
|
89
|
+
// Full per-tool table
|
|
90
|
+
if (sortedTools.length > 0) {
|
|
91
|
+
lines.push('');
|
|
92
|
+
lines.push('By tool:');
|
|
93
|
+
for (const [tool, stats] of sortedTools) {
|
|
94
|
+
const reduction = stats.wouldBe > 0 ? Math.round((1 - stats.tokens / stats.wouldBe) * 100) : 0;
|
|
95
|
+
lines.push(` ${tool}: ${stats.count} calls, ~${stats.tokens} tokens returned, ~${stats.saved} saved (${reduction}%)`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Top 5 files
|
|
99
|
+
const allTopFiles = Array.from(byFile.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
100
|
+
if (allTopFiles.length > 0) {
|
|
101
|
+
lines.push('');
|
|
102
|
+
lines.push('Top files by savings:');
|
|
103
|
+
for (const [file, fileSaved] of allTopFiles) {
|
|
104
|
+
lines.push(` ${file}: ~${fileSaved} tokens saved`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Low-value tools
|
|
108
|
+
const lowValue = sortedTools
|
|
109
|
+
.map(([tool, stats]) => ({
|
|
110
|
+
tool,
|
|
111
|
+
reduction: stats.wouldBe > 0 ? Math.round((1 - stats.tokens / stats.wouldBe) * 100) : 0,
|
|
112
|
+
count: stats.count,
|
|
113
|
+
}))
|
|
114
|
+
.filter(t => t.reduction < 20);
|
|
115
|
+
if (lowValue.length > 0) {
|
|
116
|
+
lines.push('');
|
|
117
|
+
lines.push('Needs improvement:');
|
|
118
|
+
for (const t of lowValue.slice(0, 5)) {
|
|
119
|
+
lines.push(` ${t.tool}: only ${t.reduction}% reduction across ${t.count} call${t.count === 1 ? '' : 's'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Savings by category
|
|
123
|
+
const byCategory = { compression: 0, cache: 0, dedup: 0, none: 0 };
|
|
124
|
+
for (const c of this.calls) {
|
|
125
|
+
byCategory[c.savingsCategory ?? 'none'] += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
126
|
+
}
|
|
127
|
+
if (totalWouldBe > totalReturned) {
|
|
128
|
+
lines.push('');
|
|
129
|
+
lines.push('Savings breakdown:');
|
|
130
|
+
if (byCategory.compression > 0)
|
|
131
|
+
lines.push(` Compression (AST/structured): ~${byCategory.compression} tokens`);
|
|
132
|
+
if (byCategory.cache > 0)
|
|
133
|
+
lines.push(` Cache hits (session cache): ~${byCategory.cache} tokens`);
|
|
134
|
+
if (byCategory.dedup > 0)
|
|
135
|
+
lines.push(` Dedup (already in context): ~${byCategory.dedup} tokens`);
|
|
136
|
+
}
|
|
137
|
+
// Session cache detail
|
|
138
|
+
if (cacheHits.length > 0) {
|
|
139
|
+
const cacheTokensSaved = cacheHits.reduce((s, c) => s + Math.max(0, c.tokensWouldBe - c.tokensReturned), 0);
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(`Session cache: ${cacheHits.length} hits / ${this.calls.length} calls (${Math.round(cacheHits.length / this.calls.length * 100)}% hit rate, ~${cacheTokensSaved} tokens saved)`);
|
|
142
|
+
}
|
|
143
|
+
// Delegation
|
|
144
|
+
const delegated = this.calls.filter(c => c.delegatedToContextMode);
|
|
145
|
+
if (delegated.length > 0) {
|
|
146
|
+
lines.push(`Delegated to context-mode: ${delegated.length} calls`);
|
|
147
|
+
}
|
|
148
|
+
// Per-intent breakdown
|
|
149
|
+
const callsWithIntent = this.calls.filter(c => c.intent);
|
|
150
|
+
if (callsWithIntent.length > 0) {
|
|
151
|
+
const byIntent = new Map();
|
|
152
|
+
for (const c of callsWithIntent) {
|
|
153
|
+
const intent = c.intent;
|
|
154
|
+
const e = byIntent.get(intent) ?? { count: 0, saved: 0 };
|
|
155
|
+
e.count++;
|
|
156
|
+
e.saved += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
157
|
+
byIntent.set(intent, e);
|
|
158
|
+
}
|
|
159
|
+
lines.push('');
|
|
160
|
+
lines.push('Per-intent breakdown:');
|
|
161
|
+
for (const intent of ALL_INTENTS) {
|
|
162
|
+
const stats = byIntent.get(intent);
|
|
163
|
+
if (stats) {
|
|
164
|
+
lines.push(` ${intent}: ${stats.count} call${stats.count === 1 ? '' : 's'}, ~${stats.saved} tokens saved`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Decision insights
|
|
169
|
+
const tracedCalls = this.calls.filter(c => c.decisionTrace);
|
|
170
|
+
if (tracedCalls.length > 0) {
|
|
171
|
+
const alreadyInContext = tracedCalls.filter(c => c.decisionTrace.alreadyInContext).length;
|
|
172
|
+
const totalEstimated = tracedCalls.reduce((s, c) => s + c.decisionTrace.estimatedCost, 0);
|
|
173
|
+
const totalActual = tracedCalls.reduce((s, c) => s + c.decisionTrace.actualCost, 0);
|
|
174
|
+
const avgReduction = totalEstimated > 0 ? Math.round((1 - totalActual / totalEstimated) * 100) : 0;
|
|
175
|
+
const missedSavings = tracedCalls.filter(c => c.decisionTrace.cheaperAlternative).length;
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push('Decision insights:');
|
|
178
|
+
lines.push(` Files already in context: ${alreadyInContext} of ${tracedCalls.length} calls (${Math.round(alreadyInContext / tracedCalls.length * 100)}%)`);
|
|
179
|
+
lines.push(` Avg cost reduction: ${avgReduction}% (estimated → actual)`);
|
|
180
|
+
if (missedSavings > 0) {
|
|
181
|
+
lines.push(` Missed savings: ${missedSavings} call${missedSavings === 1 ? '' : 's'} could have used cheaper tools`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Hook interception details
|
|
185
|
+
if (sessionDenied.length > 0) {
|
|
186
|
+
const hookTokensSaved = sessionDenied.reduce((s, d) => s + d.estimatedTokens, 0);
|
|
187
|
+
lines.push('');
|
|
188
|
+
lines.push(`Hook interceptions: ${sessionDenied.length} unbounded Read calls denied → ~${hookTokensSaved} tokens saved`);
|
|
189
|
+
const byHookFile = new Map();
|
|
190
|
+
for (const d of sessionDenied) {
|
|
191
|
+
const e = byHookFile.get(d.filePath) ?? { count: 0, tokens: 0 };
|
|
192
|
+
e.count++;
|
|
193
|
+
e.tokens += d.estimatedTokens;
|
|
194
|
+
byHookFile.set(d.filePath, e);
|
|
195
|
+
}
|
|
196
|
+
const topHookFiles = Array.from(byHookFile.entries()).sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 5);
|
|
197
|
+
for (const [file, stats] of topHookFiles) {
|
|
198
|
+
lines.push(` ${file}: ${stats.count}× denied, ~${stats.tokens} tokens saved`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Context-mode
|
|
202
|
+
if (this.contextModeStatus.detected) {
|
|
203
|
+
lines.push('');
|
|
204
|
+
lines.push('--- Combined Architecture ---');
|
|
205
|
+
lines.push(`context-mode: active (detected via ${this.contextModeStatus.source})`);
|
|
206
|
+
lines.push('Token Pilot handles: code files (AST-level structural reading)');
|
|
207
|
+
lines.push('context-mode handles: shell output, logs, large data files (BM25-indexed)');
|
|
208
|
+
}
|
|
74
209
|
return lines.join('\n');
|
|
75
210
|
}
|
|
76
211
|
reset() {
|
|
77
212
|
this.calls = [];
|
|
78
213
|
this.sessionStart = Date.now();
|
|
214
|
+
clearDeniedReads(this.projectRoot);
|
|
79
215
|
}
|
|
80
216
|
}
|
|
81
217
|
//# sourceMappingURL=session-analytics.js.map
|
|
@@ -23,6 +23,16 @@ export declare function validateReadSymbolArgs(args: unknown): {
|
|
|
23
23
|
context_after?: number;
|
|
24
24
|
show?: 'full' | 'head' | 'tail' | 'outline';
|
|
25
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Validate read_symbols arguments (batch multi-symbol read).
|
|
28
|
+
*/
|
|
29
|
+
export declare function validateReadSymbolsArgs(args: unknown): {
|
|
30
|
+
path: string;
|
|
31
|
+
symbols: string[];
|
|
32
|
+
context_before?: number;
|
|
33
|
+
context_after?: number;
|
|
34
|
+
show?: 'full' | 'head' | 'tail' | 'outline';
|
|
35
|
+
};
|
|
26
36
|
/**
|
|
27
37
|
* Validate read_range arguments.
|
|
28
38
|
*/
|
|
@@ -48,6 +58,7 @@ export interface FindUsagesArgs {
|
|
|
48
58
|
kind?: 'definitions' | 'imports' | 'usages' | 'all';
|
|
49
59
|
limit?: number;
|
|
50
60
|
lang?: string;
|
|
61
|
+
context_lines?: number;
|
|
51
62
|
}
|
|
52
63
|
export declare function validateFindUsagesArgs(args: unknown): FindUsagesArgs;
|
|
53
64
|
/**
|
|
@@ -62,6 +73,7 @@ export declare function validateSmartReadManyArgs(args: unknown): {
|
|
|
62
73
|
export declare function validateReadForEditArgs(args: unknown): {
|
|
63
74
|
path: string;
|
|
64
75
|
symbol?: string;
|
|
76
|
+
symbols?: string[];
|
|
65
77
|
line?: number;
|
|
66
78
|
context?: number;
|
|
67
79
|
include_callers?: boolean;
|
package/dist/core/validation.js
CHANGED
|
@@ -60,6 +60,44 @@ export function validateReadSymbolArgs(args) {
|
|
|
60
60
|
show,
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate read_symbols arguments (batch multi-symbol read).
|
|
65
|
+
*/
|
|
66
|
+
export function validateReadSymbolsArgs(args) {
|
|
67
|
+
if (!args || typeof args !== 'object') {
|
|
68
|
+
throw new Error('Arguments must be an object.');
|
|
69
|
+
}
|
|
70
|
+
const a = args;
|
|
71
|
+
if (typeof a.path !== 'string' || a.path.length === 0) {
|
|
72
|
+
throw new Error('Required parameter "path" must be a non-empty string.');
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(a.symbols) || a.symbols.length === 0) {
|
|
75
|
+
throw new Error('Required parameter "symbols" must be a non-empty array of strings.');
|
|
76
|
+
}
|
|
77
|
+
if (a.symbols.length > 10) {
|
|
78
|
+
throw new Error('"symbols" can contain at most 10 symbols.');
|
|
79
|
+
}
|
|
80
|
+
for (const s of a.symbols) {
|
|
81
|
+
if (typeof s !== 'string' || s.length === 0) {
|
|
82
|
+
throw new Error('Each symbol in "symbols" must be a non-empty string.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let show;
|
|
86
|
+
if (a.show !== undefined && a.show !== null) {
|
|
87
|
+
const valid = ['full', 'head', 'tail', 'outline'];
|
|
88
|
+
if (typeof a.show !== 'string' || !valid.includes(a.show)) {
|
|
89
|
+
throw new Error('"show" must be one of: full, head, tail, outline.');
|
|
90
|
+
}
|
|
91
|
+
show = a.show;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
path: a.path,
|
|
95
|
+
symbols: a.symbols,
|
|
96
|
+
context_before: optionalNumber(a.context_before, 'context_before'),
|
|
97
|
+
context_after: optionalNumber(a.context_after, 'context_after'),
|
|
98
|
+
show,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
63
101
|
/**
|
|
64
102
|
* Validate read_range arguments.
|
|
65
103
|
*/
|
|
@@ -118,12 +156,17 @@ export function validateFindUsagesArgs(args) {
|
|
|
118
156
|
if (limit !== undefined && (limit < 1 || limit > 500)) {
|
|
119
157
|
throw new Error('"limit" must be between 1 and 500.');
|
|
120
158
|
}
|
|
159
|
+
const context_lines = optionalNumber(a.context_lines, 'context_lines');
|
|
160
|
+
if (context_lines !== undefined && (context_lines < 0 || context_lines > 10)) {
|
|
161
|
+
throw new Error('"context_lines" must be between 0 and 10.');
|
|
162
|
+
}
|
|
121
163
|
return {
|
|
122
164
|
symbol: a.symbol,
|
|
123
165
|
scope: optionalString(a.scope, 'scope'),
|
|
124
166
|
kind,
|
|
125
167
|
limit,
|
|
126
168
|
lang: optionalString(a.lang, 'lang'),
|
|
169
|
+
context_lines,
|
|
127
170
|
};
|
|
128
171
|
}
|
|
129
172
|
/**
|
|
@@ -176,12 +219,29 @@ export function validateReadForEditArgs(args) {
|
|
|
176
219
|
if (typeof a.path !== 'string' || a.path.length === 0) {
|
|
177
220
|
throw new Error('Required parameter "path" must be a non-empty string.');
|
|
178
221
|
}
|
|
179
|
-
if (!a.symbol && !a.line) {
|
|
180
|
-
throw new Error('Either "symbol" or "line" must be provided.');
|
|
222
|
+
if (!a.symbol && !a.line && (!Array.isArray(a.symbols) || a.symbols.length === 0)) {
|
|
223
|
+
throw new Error('Either "symbol", "symbols", or "line" must be provided.');
|
|
224
|
+
}
|
|
225
|
+
// Validate symbols array (batch mode)
|
|
226
|
+
let symbols;
|
|
227
|
+
if (a.symbols !== undefined && a.symbols !== null) {
|
|
228
|
+
if (!Array.isArray(a.symbols)) {
|
|
229
|
+
throw new Error('"symbols" must be an array of strings.');
|
|
230
|
+
}
|
|
231
|
+
if (a.symbols.length > 10) {
|
|
232
|
+
throw new Error('"symbols" can contain at most 10 symbols.');
|
|
233
|
+
}
|
|
234
|
+
for (const s of a.symbols) {
|
|
235
|
+
if (typeof s !== 'string' || s.length === 0) {
|
|
236
|
+
throw new Error('Each symbol in "symbols" must be a non-empty string.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
symbols = a.symbols;
|
|
181
240
|
}
|
|
182
241
|
return {
|
|
183
242
|
path: a.path,
|
|
184
243
|
symbol: optionalString(a.symbol, 'symbol'),
|
|
244
|
+
symbols,
|
|
185
245
|
line: optionalNumber(a.line, 'line'),
|
|
186
246
|
context: optionalNumber(a.context, 'context'),
|
|
187
247
|
include_callers: optionalBool(a.include_callers, 'include_callers'),
|
|
@@ -10,7 +10,7 @@ import type { FindUsagesArgs } from '../core/validation.js';
|
|
|
10
10
|
*
|
|
11
11
|
* v1.1: added scope, kind, limit, lang post-filters.
|
|
12
12
|
*/
|
|
13
|
-
export declare function handleFindUsages(args: FindUsagesArgs, astIndex: AstIndexClient): Promise<{
|
|
13
|
+
export declare function handleFindUsages(args: FindUsagesArgs, astIndex: AstIndexClient, projectRoot?: string): Promise<{
|
|
14
14
|
content: Array<{
|
|
15
15
|
type: 'text';
|
|
16
16
|
text: string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { assessConfidence, formatConfidence } from '../core/confidence.js';
|
|
2
4
|
/**
|
|
3
5
|
* Escape special regex characters in a string.
|
|
@@ -51,6 +53,52 @@ function renderSection(title, items) {
|
|
|
51
53
|
lines.push('');
|
|
52
54
|
return lines;
|
|
53
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Render a section with surrounding source context lines.
|
|
58
|
+
*/
|
|
59
|
+
async function renderSectionWithContext(title, items, contextLines, projectRoot) {
|
|
60
|
+
if (items.length === 0)
|
|
61
|
+
return [];
|
|
62
|
+
const lines = [`${title}:`];
|
|
63
|
+
const byFile = new Map();
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
const arr = byFile.get(item.file) ?? [];
|
|
66
|
+
arr.push({ line: item.line, text: item.text });
|
|
67
|
+
byFile.set(item.file, arr);
|
|
68
|
+
}
|
|
69
|
+
for (const [file, matches] of byFile) {
|
|
70
|
+
matches.sort((a, b) => a.line - b.line);
|
|
71
|
+
lines.push(` ${file}:`);
|
|
72
|
+
// Read file for context
|
|
73
|
+
let fileLines = null;
|
|
74
|
+
try {
|
|
75
|
+
const content = await readFile(resolve(projectRoot, file), 'utf-8');
|
|
76
|
+
fileLines = content.split('\n');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// File unreadable — fall back to text-only
|
|
80
|
+
for (const m of matches) {
|
|
81
|
+
lines.push(` :${m.line} ${m.text}`);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (let mi = 0; mi < matches.length; mi++) {
|
|
86
|
+
const m = matches[mi];
|
|
87
|
+
const start = Math.max(0, m.line - 1 - contextLines);
|
|
88
|
+
const end = Math.min(fileLines.length, m.line + contextLines);
|
|
89
|
+
for (let i = start; i < end; i++) {
|
|
90
|
+
const lineNum = i + 1;
|
|
91
|
+
const marker = lineNum === m.line ? '>' : ' ';
|
|
92
|
+
lines.push(` ${marker} ${lineNum} | ${fileLines[i]}`);
|
|
93
|
+
}
|
|
94
|
+
if (mi < matches.length - 1) {
|
|
95
|
+
lines.push('');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
lines.push('');
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
54
102
|
/**
|
|
55
103
|
* Find all usages of a symbol across the project.
|
|
56
104
|
*
|
|
@@ -61,7 +109,7 @@ function renderSection(title, items) {
|
|
|
61
109
|
*
|
|
62
110
|
* v1.1: added scope, kind, limit, lang post-filters.
|
|
63
111
|
*/
|
|
64
|
-
export async function handleFindUsages(args, astIndex) {
|
|
112
|
+
export async function handleFindUsages(args, astIndex, projectRoot) {
|
|
65
113
|
if (astIndex.isDisabled() || astIndex.isOversized()) {
|
|
66
114
|
return {
|
|
67
115
|
content: [{
|
|
@@ -183,9 +231,21 @@ export async function handleFindUsages(args, astIndex) {
|
|
|
183
231
|
`REFS: "${args.symbol}" (${totalCount} total: ${definitions.length} def · ${allImports.length} imports · ${allUsages.length} usages)${filterStr}`,
|
|
184
232
|
'',
|
|
185
233
|
];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
234
|
+
if (args.context_lines !== undefined && args.context_lines > 0 && projectRoot) {
|
|
235
|
+
const [defSection, impSection, useSection] = await Promise.all([
|
|
236
|
+
renderSectionWithContext('DEFINITIONS', definitions, args.context_lines, projectRoot),
|
|
237
|
+
renderSectionWithContext('IMPORTS', allImports, args.context_lines, projectRoot),
|
|
238
|
+
renderSectionWithContext('USAGES', allUsages, args.context_lines, projectRoot),
|
|
239
|
+
]);
|
|
240
|
+
lines.push(...defSection);
|
|
241
|
+
lines.push(...impSection);
|
|
242
|
+
lines.push(...useSection);
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
lines.push(...renderSection('DEFINITIONS', definitions));
|
|
246
|
+
lines.push(...renderSection('IMPORTS', allImports));
|
|
247
|
+
lines.push(...renderSection('USAGES', allUsages));
|
|
248
|
+
}
|
|
189
249
|
lines.push('HINT: Use read_symbol() or read_range() to load specific results.');
|
|
190
250
|
// Confidence metadata
|
|
191
251
|
const confidenceMeta = assessConfidence({
|
|
@@ -39,6 +39,71 @@ export async function handleReadForEdit(args, projectRoot, symbolResolver, fileC
|
|
|
39
39
|
lastAccess: Date.now(),
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
|
+
// --- Batch mode: multiple symbols ---
|
|
43
|
+
if (args.symbols && args.symbols.length > 0) {
|
|
44
|
+
let structure = cached?.structure;
|
|
45
|
+
if (!structure) {
|
|
46
|
+
structure = await astIndex.outline(absPath) ?? undefined;
|
|
47
|
+
}
|
|
48
|
+
const sections = [];
|
|
49
|
+
sections.push(`--- EDIT CONTEXT (BATCH: ${args.symbols.length} symbols) ---`);
|
|
50
|
+
sections.push(`FILE: ${args.path}`);
|
|
51
|
+
sections.push('');
|
|
52
|
+
let resolved_count = 0;
|
|
53
|
+
for (let i = 0; i < args.symbols.length; i++) {
|
|
54
|
+
const symName = args.symbols[i];
|
|
55
|
+
const resolved = await symbolResolver.resolve(symName, structure);
|
|
56
|
+
if (!resolved) {
|
|
57
|
+
sections.push(`=== SYMBOL ${i + 1}/${args.symbols.length}: ${symName} — NOT FOUND ===`);
|
|
58
|
+
sections.push('');
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
resolved_count++;
|
|
62
|
+
const symbolLines = resolved.endLine - resolved.startLine + 1;
|
|
63
|
+
const MAX_EDIT_LINES = 60;
|
|
64
|
+
let effStart = resolved.startLine;
|
|
65
|
+
let effEnd;
|
|
66
|
+
let label;
|
|
67
|
+
if (symbolLines <= MAX_EDIT_LINES) {
|
|
68
|
+
effEnd = resolved.endLine;
|
|
69
|
+
label = `${symName} [L${effStart}-${effEnd}] (${symbolLines} lines, full)`;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
effEnd = effStart + MAX_EDIT_LINES - 1;
|
|
73
|
+
label = `${symName} [L${effStart}-${resolved.endLine}] (showing first ${MAX_EDIT_LINES} of ${symbolLines} lines)`;
|
|
74
|
+
}
|
|
75
|
+
const rangeStart = Math.max(1, effStart - ctx);
|
|
76
|
+
const rangeEnd = Math.min(lines.length, effEnd + ctx);
|
|
77
|
+
const rawCode = lines.slice(rangeStart - 1, rangeEnd).join('\n');
|
|
78
|
+
sections.push(`=== SYMBOL ${i + 1}/${args.symbols.length}: ${label} ===`);
|
|
79
|
+
sections.push('');
|
|
80
|
+
sections.push(rawCode);
|
|
81
|
+
sections.push('');
|
|
82
|
+
// Track each symbol
|
|
83
|
+
contextRegistry.trackLoad(absPath, {
|
|
84
|
+
type: 'symbol',
|
|
85
|
+
symbolName: symName,
|
|
86
|
+
startLine: rangeStart,
|
|
87
|
+
endLine: rangeEnd,
|
|
88
|
+
tokens: estimateTokens(rawCode),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
sections.push('--- END EDIT CONTEXT ---');
|
|
92
|
+
sections.push('');
|
|
93
|
+
sections.push(`To edit: use exact text from each section as old_string in Edit tool.`);
|
|
94
|
+
if (resolved_count < args.symbols.length) {
|
|
95
|
+
sections.push(`WARNING: ${args.symbols.length - resolved_count} symbol(s) not found. Use smart_read to see available symbols.`);
|
|
96
|
+
}
|
|
97
|
+
const confidenceMeta = assessConfidence({
|
|
98
|
+
symbolResolved: resolved_count > 0,
|
|
99
|
+
fullFile: false,
|
|
100
|
+
truncated: false,
|
|
101
|
+
astAvailable: true,
|
|
102
|
+
});
|
|
103
|
+
sections.push(formatConfidence(confidenceMeta));
|
|
104
|
+
const output = sections.join('\n');
|
|
105
|
+
return { content: [{ type: 'text', text: output }] };
|
|
106
|
+
}
|
|
42
107
|
let startLine;
|
|
43
108
|
let endLine;
|
|
44
109
|
let targetLabel;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AstIndexClient } from '../ast-index/client.js';
|
|
2
|
+
import type { SymbolResolver } from '../core/symbol-resolver.js';
|
|
3
|
+
import type { FileCache } from '../core/file-cache.js';
|
|
4
|
+
import type { ContextRegistry } from '../core/context-registry.js';
|
|
5
|
+
export interface ReadSymbolsArgs {
|
|
6
|
+
path: string;
|
|
7
|
+
symbols: string[];
|
|
8
|
+
context_before?: number;
|
|
9
|
+
context_after?: number;
|
|
10
|
+
show?: 'full' | 'head' | 'tail' | 'outline';
|
|
11
|
+
}
|
|
12
|
+
export declare function handleReadSymbols(args: ReadSymbolsArgs, projectRoot: string, symbolResolver: SymbolResolver, fileCache: FileCache, contextRegistry: ContextRegistry, astIndex?: AstIndexClient, advisoryReminders?: boolean): Promise<{
|
|
13
|
+
content: Array<{
|
|
14
|
+
type: 'text';
|
|
15
|
+
text: string;
|
|
16
|
+
}>;
|
|
17
|
+
}>;
|
|
18
|
+
//# sourceMappingURL=read-symbols.d.ts.map
|