token-pilot 0.15.0 → 0.16.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.
@@ -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
- lines.push(...renderSection('DEFINITIONS', definitions));
187
- lines.push(...renderSection('IMPORTS', allImports));
188
- lines.push(...renderSection('USAGES', allUsages));
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({
@@ -5,6 +5,7 @@ import type { ContextRegistry } from '../core/context-registry.js';
5
5
  export interface ReadForEditArgs {
6
6
  path: string;
7
7
  symbol?: string;
8
+ symbols?: string[];
8
9
  line?: number;
9
10
  context?: number;
10
11
  include_callers?: boolean;
@@ -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
@@ -0,0 +1,142 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { estimateTokens } from '../core/token-estimator.js';
3
+ import { resolveSafePath } from '../core/validation.js';
4
+ import { assessConfidence, formatConfidence } from '../core/confidence.js';
5
+ export async function handleReadSymbols(args, projectRoot, symbolResolver, fileCache, contextRegistry, astIndex, advisoryReminders = true) {
6
+ const absPath = resolveSafePath(projectRoot, args.path);
7
+ // Get file content ONCE
8
+ const cached = fileCache.get(absPath);
9
+ let lines;
10
+ if (cached) {
11
+ lines = cached.lines;
12
+ }
13
+ else {
14
+ const content = await readFile(absPath, 'utf-8');
15
+ lines = content.split('\n');
16
+ }
17
+ // Get AST structure ONCE
18
+ let structure = cached?.structure;
19
+ if (!structure && astIndex) {
20
+ structure = await astIndex.outline(absPath) ?? undefined;
21
+ }
22
+ const N = args.symbols.length;
23
+ const sections = [];
24
+ // Show mode constants (same as read_symbol.ts)
25
+ const MAX_SYMBOL_LINES = 300;
26
+ const MAX_FULL_LINES = 500;
27
+ const HEAD = 50;
28
+ const TAIL = 30;
29
+ let anyTruncated = false;
30
+ let anyResolved = false;
31
+ let totalTokens = 0;
32
+ for (let i = 0; i < N; i++) {
33
+ const symbolName = args.symbols[i];
34
+ const idx = i + 1;
35
+ const resolved = await symbolResolver.resolve(symbolName, structure);
36
+ if (!resolved) {
37
+ sections.push(`SYMBOL ${idx}/${N}: ${symbolName}\n` +
38
+ `ERROR: Symbol "${symbolName}" not found in ${args.path}.\n` +
39
+ `HINT: Use smart_read("${args.path}") to see available symbols.`);
40
+ continue;
41
+ }
42
+ anyResolved = true;
43
+ const source = symbolResolver.extractSource(resolved, lines, {
44
+ contextBefore: args.context_before ?? 2,
45
+ contextAfter: args.context_after ?? 0,
46
+ });
47
+ const loc = `[L${resolved.startLine}-${resolved.endLine}]`;
48
+ const lineCount = resolved.endLine - resolved.startLine + 1;
49
+ // Determine effective show mode
50
+ const showMode = args.show ?? (lineCount > MAX_SYMBOL_LINES ? 'outline' : 'full');
51
+ let displaySource = source;
52
+ let truncated = false;
53
+ if (showMode === 'full') {
54
+ if (lineCount > MAX_FULL_LINES) {
55
+ const sourceLines = source.split('\n');
56
+ displaySource = sourceLines.slice(0, MAX_FULL_LINES).join('\n');
57
+ displaySource += `\n\n ... truncated at ${MAX_FULL_LINES} lines (${lineCount - MAX_FULL_LINES} more). Use show="head"/"tail" for targeted view.`;
58
+ truncated = true;
59
+ }
60
+ }
61
+ else if (showMode === 'head') {
62
+ const sourceLines = source.split('\n');
63
+ displaySource = sourceLines.slice(0, HEAD).join('\n');
64
+ if (lineCount > HEAD) {
65
+ displaySource += `\n\n ... ${lineCount - HEAD} more lines. Use show="tail" or read_symbol("${args.path}", "MethodName") for specific parts.`;
66
+ truncated = true;
67
+ }
68
+ }
69
+ else if (showMode === 'tail') {
70
+ const sourceLines = source.split('\n');
71
+ displaySource = sourceLines.slice(-TAIL).join('\n');
72
+ if (lineCount > TAIL) {
73
+ displaySource = ` ... ${lineCount - TAIL} lines above ...\n\n` + displaySource;
74
+ truncated = true;
75
+ }
76
+ }
77
+ else {
78
+ // 'outline' mode: head + method list + tail
79
+ if (lineCount > HEAD + TAIL) {
80
+ const sourceLines = source.split('\n');
81
+ const head = sourceLines.slice(0, HEAD).join('\n');
82
+ const tail = sourceLines.slice(-TAIL).join('\n');
83
+ const omitted = sourceLines.length - HEAD - TAIL;
84
+ let methodOutline = '';
85
+ if (resolved.symbol.children && resolved.symbol.children.length > 0) {
86
+ const methodLines = resolved.symbol.children.map(c => {
87
+ const mLoc = `[L${c.location.startLine}-${c.location.endLine}]`;
88
+ return ` ${c.visibility === 'private' ? '🔒 ' : ''}${c.name}${c.kind === 'method' || c.kind === 'function' ? '()' : ''} ${mLoc} (${c.location.lineCount} lines)`;
89
+ });
90
+ methodOutline = `\nMETHODS (${resolved.symbol.children.length}):\n${methodLines.join('\n')}\n`;
91
+ }
92
+ displaySource = [
93
+ head,
94
+ '',
95
+ ` ... ${omitted} lines omitted — use read_symbol("${args.path}", "MethodName") to read specific methods ...`,
96
+ methodOutline,
97
+ tail,
98
+ ].join('\n');
99
+ truncated = true;
100
+ }
101
+ }
102
+ if (truncated)
103
+ anyTruncated = true;
104
+ const symbolLines = [
105
+ `SYMBOL ${idx}/${N}: ${symbolName} (${resolved.symbol.kind}) ${loc} (${lineCount} lines${truncated ? `, show=${showMode}` : ''})`,
106
+ '',
107
+ displaySource,
108
+ ];
109
+ if (resolved.symbol.references.length > 0) {
110
+ symbolLines.push('');
111
+ symbolLines.push(`REFERENCES: ${resolved.symbol.references.join(', ')}`);
112
+ }
113
+ sections.push(symbolLines.join('\n'));
114
+ // Track each symbol
115
+ const sectionTokens = estimateTokens(symbolLines.join('\n'));
116
+ totalTokens += sectionTokens;
117
+ contextRegistry.trackLoad(absPath, {
118
+ type: 'symbol',
119
+ symbolName,
120
+ startLine: resolved.startLine,
121
+ endLine: resolved.endLine,
122
+ tokens: sectionTokens,
123
+ });
124
+ }
125
+ if (cached?.hash) {
126
+ contextRegistry.setContentHash(absPath, cached.hash);
127
+ }
128
+ const header = `FILE: ${args.path} | SYMBOLS: ${N} requested`;
129
+ const body = sections.join('\n\n---\n\n');
130
+ const footer = 'CONTEXT TRACKED: These symbols are now in your context.';
131
+ const output = [header, '', body, '', footer].join('\n');
132
+ // Confidence metadata (aggregate)
133
+ const confidenceMeta = assessConfidence({
134
+ symbolResolved: anyResolved,
135
+ truncated: anyTruncated,
136
+ fullFile: false,
137
+ hasCallers: false,
138
+ astAvailable: !!structure,
139
+ });
140
+ return { content: [{ type: 'text', text: output + formatConfidence(confidenceMeta) }] };
141
+ }
142
+ //# sourceMappingURL=read-symbols.js.map
@@ -221,6 +221,29 @@ function formatSmartDiff(allFiles, processedFiles, symbolChanges, args, rawToken
221
221
  const lines = [];
222
222
  lines.push(`CHANGES: ${allFiles.length} file${allFiles.length !== 1 ? 's' : ''}, +${totalAdded} -${totalRemoved} (${scopeLabel})`);
223
223
  lines.push('');
224
+ // Affected symbols summary
225
+ const allSymbolChanges = [];
226
+ for (const [file, changes] of symbolChanges) {
227
+ for (const sc of changes) {
228
+ allSymbolChanges.push({ file, symbol: sc });
229
+ }
230
+ }
231
+ if (allSymbolChanges.length > 0) {
232
+ lines.push('AFFECTED SYMBOLS:');
233
+ // Group by changeType
234
+ const modified = allSymbolChanges.filter(s => s.symbol.changeType === 'MODIFIED');
235
+ const added = allSymbolChanges.filter(s => s.symbol.changeType === 'ADDED');
236
+ const removed = allSymbolChanges.filter(s => s.symbol.changeType === 'REMOVED');
237
+ for (const [label, group] of [['MODIFIED', modified], ['ADDED', added], ['REMOVED', removed]]) {
238
+ if (group.length === 0)
239
+ continue;
240
+ for (const item of group) {
241
+ const parens = ['function', 'method'].includes(item.symbol.kind) ? '()' : '';
242
+ lines.push(` ${label}: ${item.file}::${item.symbol.name}${parens}`);
243
+ }
244
+ }
245
+ lines.push('');
246
+ }
224
247
  for (const fd of processedFiles) {
225
248
  if (lines.length >= MAX_OUTPUT_LINES) {
226
249
  lines.push(`... truncated (${allFiles.length - processedFiles.indexOf(fd)} more files)`);