token-pilot 0.13.0 → 0.14.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/.claude-plugin/hooks/hooks.json +9 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +29 -0
- package/README.md +28 -7
- package/dist/config/defaults.js +12 -0
- package/dist/core/architecture-fingerprint.d.ts +34 -0
- package/dist/core/architecture-fingerprint.js +127 -0
- package/dist/core/budget-planner.d.ts +21 -0
- package/dist/core/budget-planner.js +68 -0
- package/dist/core/confidence.d.ts +31 -0
- package/dist/core/confidence.js +99 -0
- package/dist/core/context-registry.d.ts +14 -0
- package/dist/core/context-registry.js +55 -0
- package/dist/core/decision-trace.d.ts +31 -0
- package/dist/core/decision-trace.js +45 -0
- package/dist/core/intent-classifier.d.ts +13 -0
- package/dist/core/intent-classifier.js +44 -0
- package/dist/core/policy-engine.d.ts +41 -0
- package/dist/core/policy-engine.js +76 -0
- package/dist/core/session-analytics.d.ts +8 -0
- package/dist/core/session-analytics.js +86 -7
- package/dist/core/session-cache.d.ts +74 -0
- package/dist/core/session-cache.js +162 -0
- package/dist/core/validation.d.ts +3 -0
- package/dist/core/validation.js +3 -0
- package/dist/git/file-watcher.d.ts +6 -0
- package/dist/git/file-watcher.js +18 -2
- package/dist/git/watcher.d.ts +3 -0
- package/dist/git/watcher.js +6 -0
- package/dist/handlers/code-audit.d.ts +7 -2
- package/dist/handlers/code-audit.js +19 -5
- package/dist/handlers/explore-area.d.ts +10 -0
- package/dist/handlers/explore-area.js +39 -13
- package/dist/handlers/find-unused.d.ts +3 -0
- package/dist/handlers/find-unused.js +3 -2
- package/dist/handlers/find-usages.d.ts +7 -0
- package/dist/handlers/find-usages.js +36 -5
- package/dist/handlers/module-info.d.ts +3 -0
- package/dist/handlers/module-info.js +22 -2
- package/dist/handlers/project-overview.d.ts +1 -1
- package/dist/handlers/project-overview.js +18 -2
- package/dist/handlers/read-for-edit.d.ts +3 -0
- package/dist/handlers/read-for-edit.js +185 -3
- package/dist/handlers/read-range.d.ts +1 -1
- package/dist/handlers/read-range.js +16 -1
- package/dist/handlers/read-symbol.d.ts +1 -1
- package/dist/handlers/read-symbol.js +26 -2
- package/dist/handlers/related-files.d.ts +11 -0
- package/dist/handlers/related-files.js +178 -42
- package/dist/handlers/smart-read-many.js +70 -16
- package/dist/handlers/smart-read.js +10 -1
- package/dist/handlers/test-summary.js +26 -3
- package/dist/hooks/installer.d.ts +12 -8
- package/dist/hooks/installer.js +24 -8
- package/dist/index.d.ts +16 -1
- package/dist/index.js +61 -55
- package/dist/server.js +395 -30
- package/dist/types.d.ts +12 -0
- package/package.json +5 -3
- package/start.sh +28 -27
- package/dist/handlers/class-hierarchy.d.ts +0 -11
- package/dist/handlers/class-hierarchy.js +0 -28
- package/dist/handlers/export-ast-index.d.ts +0 -22
- package/dist/handlers/export-ast-index.js +0 -175
- package/dist/handlers/find-implementations.d.ts +0 -11
- package/dist/handlers/find-implementations.js +0 -27
- package/dist/handlers/search-code.d.ts +0 -14
- package/dist/handlers/search-code.js +0 -32
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision trace — captures pre/post-execution metadata for analytics.
|
|
3
|
+
* Provides instrumentation data for budget planner advisory.
|
|
4
|
+
*/
|
|
5
|
+
import type { ContextRegistry } from './context-registry.js';
|
|
6
|
+
import type { FileCache } from './file-cache.js';
|
|
7
|
+
export interface DecisionTrace {
|
|
8
|
+
fileSize?: number;
|
|
9
|
+
fileTotalLines?: number;
|
|
10
|
+
alreadyInContext: boolean;
|
|
11
|
+
estimatedCost: number;
|
|
12
|
+
actualCost: number;
|
|
13
|
+
cheaperAlternative?: string;
|
|
14
|
+
cheaperEstimate?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface BuildTraceOptions {
|
|
17
|
+
absPath?: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
args: Record<string, unknown>;
|
|
20
|
+
contextRegistry: ContextRegistry;
|
|
21
|
+
fileCache: FileCache;
|
|
22
|
+
tokensReturned: number;
|
|
23
|
+
tokensWouldBe: number;
|
|
24
|
+
recentlyEdited?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Build a decision trace for a tool call.
|
|
28
|
+
* Gathers file metadata, context state, and budget planner advice.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildDecisionTrace(opts: BuildTraceOptions): DecisionTrace;
|
|
31
|
+
//# sourceMappingURL=decision-trace.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision trace — captures pre/post-execution metadata for analytics.
|
|
3
|
+
* Provides instrumentation data for budget planner advisory.
|
|
4
|
+
*/
|
|
5
|
+
import { suggestCheaperAlternative } from './budget-planner.js';
|
|
6
|
+
/**
|
|
7
|
+
* Build a decision trace for a tool call.
|
|
8
|
+
* Gathers file metadata, context state, and budget planner advice.
|
|
9
|
+
*/
|
|
10
|
+
export function buildDecisionTrace(opts) {
|
|
11
|
+
const { absPath, tool, args, contextRegistry, fileCache, tokensReturned, tokensWouldBe } = opts;
|
|
12
|
+
let fileSize;
|
|
13
|
+
let fileTotalLines;
|
|
14
|
+
let alreadyInContext = false;
|
|
15
|
+
if (absPath) {
|
|
16
|
+
// Get file info from cache if available
|
|
17
|
+
const cached = fileCache.get(absPath);
|
|
18
|
+
if (cached) {
|
|
19
|
+
fileSize = cached.structure?.meta?.bytes;
|
|
20
|
+
fileTotalLines = cached.lines?.length ?? cached.structure?.meta?.lines;
|
|
21
|
+
}
|
|
22
|
+
alreadyInContext = contextRegistry.hasAnyLoaded(absPath);
|
|
23
|
+
}
|
|
24
|
+
const trace = {
|
|
25
|
+
fileSize,
|
|
26
|
+
fileTotalLines,
|
|
27
|
+
alreadyInContext,
|
|
28
|
+
estimatedCost: tokensWouldBe,
|
|
29
|
+
actualCost: tokensReturned,
|
|
30
|
+
};
|
|
31
|
+
// Budget planner advisory
|
|
32
|
+
const symbolKnown = !!(args.symbol || args.name);
|
|
33
|
+
const suggestion = suggestCheaperAlternative(tool, args, {
|
|
34
|
+
fileLines: fileTotalLines,
|
|
35
|
+
alreadyInContext,
|
|
36
|
+
symbolKnown,
|
|
37
|
+
recentlyEdited: opts.recentlyEdited ?? false,
|
|
38
|
+
});
|
|
39
|
+
if (suggestion) {
|
|
40
|
+
trace.cheaperAlternative = suggestion.tool;
|
|
41
|
+
trace.cheaperEstimate = suggestion.estimatedTokens;
|
|
42
|
+
}
|
|
43
|
+
return trace;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=decision-trace.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent classifier — maps tool + args to a task intent category.
|
|
3
|
+
* Used for per-intent analytics: which workflows save most tokens?
|
|
4
|
+
*/
|
|
5
|
+
export type Intent = 'edit' | 'debug' | 'explore' | 'review' | 'analyze' | 'search' | 'read';
|
|
6
|
+
/**
|
|
7
|
+
* Classify the intent of a tool call based on tool name and optional args.
|
|
8
|
+
* Returns a stable intent category for analytics grouping.
|
|
9
|
+
*/
|
|
10
|
+
export declare function classifyIntent(tool: string, _args?: Record<string, unknown>): Intent;
|
|
11
|
+
/** Get all known intents for iteration in reports. */
|
|
12
|
+
export declare const ALL_INTENTS: readonly Intent[];
|
|
13
|
+
//# sourceMappingURL=intent-classifier.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intent classifier — maps tool + args to a task intent category.
|
|
3
|
+
* Used for per-intent analytics: which workflows save most tokens?
|
|
4
|
+
*/
|
|
5
|
+
const TOOL_INTENT_MAP = {
|
|
6
|
+
// Edit workflow
|
|
7
|
+
read_for_edit: 'edit',
|
|
8
|
+
// Review workflow
|
|
9
|
+
smart_diff: 'review',
|
|
10
|
+
smart_log: 'review',
|
|
11
|
+
read_diff: 'review',
|
|
12
|
+
// Explore workflow
|
|
13
|
+
project_overview: 'explore',
|
|
14
|
+
explore_area: 'explore',
|
|
15
|
+
outline: 'explore',
|
|
16
|
+
// Search workflow
|
|
17
|
+
find_usages: 'search',
|
|
18
|
+
related_files: 'search',
|
|
19
|
+
// Analyze workflow
|
|
20
|
+
code_audit: 'analyze',
|
|
21
|
+
find_unused: 'analyze',
|
|
22
|
+
module_info: 'analyze',
|
|
23
|
+
// Debug workflow
|
|
24
|
+
test_summary: 'debug',
|
|
25
|
+
// Read workflow (default for reading tools)
|
|
26
|
+
smart_read: 'read',
|
|
27
|
+
read_symbol: 'read',
|
|
28
|
+
read_range: 'read',
|
|
29
|
+
smart_read_many: 'read',
|
|
30
|
+
// Analytics (meta — classify as explore)
|
|
31
|
+
session_analytics: 'explore',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Classify the intent of a tool call based on tool name and optional args.
|
|
35
|
+
* Returns a stable intent category for analytics grouping.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyIntent(tool, _args) {
|
|
38
|
+
return TOOL_INTENT_MAP[tool] ?? 'read';
|
|
39
|
+
}
|
|
40
|
+
/** Get all known intents for iteration in reports. */
|
|
41
|
+
export const ALL_INTENTS = [
|
|
42
|
+
'edit', 'debug', 'explore', 'review', 'analyze', 'search', 'read',
|
|
43
|
+
];
|
|
44
|
+
//# sourceMappingURL=intent-classifier.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy engine — configurable team policies for consistent token savings.
|
|
3
|
+
* Phase 1: advisory-only warnings, no blocking.
|
|
4
|
+
* Track 10: Team Policy Mode
|
|
5
|
+
*/
|
|
6
|
+
export interface PolicyConfig {
|
|
7
|
+
/** Advisory hints when an expensive tool is used where a cheaper alternative exists */
|
|
8
|
+
preferCheapReads: boolean;
|
|
9
|
+
/** Track if read_for_edit was called before edit (advisory) */
|
|
10
|
+
requireReadForEditBeforeEdit: boolean;
|
|
11
|
+
/** Always cache project overview in session cache */
|
|
12
|
+
cacheProjectOverview: boolean;
|
|
13
|
+
/** Warn after N full-file reads in a session */
|
|
14
|
+
maxFullFileReads: number;
|
|
15
|
+
/** Warn when a single response exceeds this token threshold */
|
|
16
|
+
warnOnLargeReads: boolean;
|
|
17
|
+
/** Token threshold for large read warning */
|
|
18
|
+
largeReadThreshold: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const DEFAULT_POLICIES: PolicyConfig;
|
|
21
|
+
export interface PolicyCheckContext {
|
|
22
|
+
fullFileReadsCount: number;
|
|
23
|
+
tokensReturned: number;
|
|
24
|
+
readForEditCalled?: Set<string>;
|
|
25
|
+
editTargetPath?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface PolicyAdvisory {
|
|
28
|
+
level: 'info' | 'warn';
|
|
29
|
+
message: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Check policy rules and return advisory messages.
|
|
33
|
+
* Returns null if no policy violation detected.
|
|
34
|
+
*/
|
|
35
|
+
export declare function checkPolicy(policy: PolicyConfig, tool: string, context: PolicyCheckContext): PolicyAdvisory | null;
|
|
36
|
+
/**
|
|
37
|
+
* Count how many full-file reads a tool represents.
|
|
38
|
+
* Returns 1 for full-read tools, 0 for targeted tools.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isFullReadTool(tool: string): boolean;
|
|
41
|
+
//# sourceMappingURL=policy-engine.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy engine — configurable team policies for consistent token savings.
|
|
3
|
+
* Phase 1: advisory-only warnings, no blocking.
|
|
4
|
+
* Track 10: Team Policy Mode
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_POLICIES = {
|
|
7
|
+
preferCheapReads: true,
|
|
8
|
+
requireReadForEditBeforeEdit: true,
|
|
9
|
+
cacheProjectOverview: true,
|
|
10
|
+
maxFullFileReads: 10,
|
|
11
|
+
warnOnLargeReads: true,
|
|
12
|
+
largeReadThreshold: 2000,
|
|
13
|
+
};
|
|
14
|
+
/** Full-file read tools that count toward maxFullFileReads */
|
|
15
|
+
const FULL_READ_TOOLS = new Set([
|
|
16
|
+
'smart_read',
|
|
17
|
+
'smart_read_many',
|
|
18
|
+
]);
|
|
19
|
+
/** Tools that indicate a cheaper alternative may exist */
|
|
20
|
+
const EXPENSIVE_TOOLS = {
|
|
21
|
+
smart_read: 'Consider read_symbol() or read_range() for targeted reads',
|
|
22
|
+
smart_read_many: 'Consider reading files individually with read_symbol()',
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Check policy rules and return advisory messages.
|
|
26
|
+
* Returns null if no policy violation detected.
|
|
27
|
+
*/
|
|
28
|
+
export function checkPolicy(policy, tool, context) {
|
|
29
|
+
// 1. Max full-file reads exceeded
|
|
30
|
+
if (policy.maxFullFileReads > 0 &&
|
|
31
|
+
FULL_READ_TOOLS.has(tool) &&
|
|
32
|
+
context.fullFileReadsCount >= policy.maxFullFileReads) {
|
|
33
|
+
return {
|
|
34
|
+
level: 'warn',
|
|
35
|
+
message: `POLICY: ${context.fullFileReadsCount} full-file reads this session (limit: ${policy.maxFullFileReads}). Consider read_symbol() or read_range() for targeted access.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// 2. Large read warning
|
|
39
|
+
if (policy.warnOnLargeReads &&
|
|
40
|
+
context.tokensReturned > policy.largeReadThreshold) {
|
|
41
|
+
return {
|
|
42
|
+
level: 'info',
|
|
43
|
+
message: `POLICY: Large response (~${context.tokensReturned} tokens). Future reads on this file: use read_symbol() or read_range() for targeted access.`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// 3. Prefer cheap reads advisory
|
|
47
|
+
if (policy.preferCheapReads && EXPENSIVE_TOOLS[tool]) {
|
|
48
|
+
// Only advise when token count is high enough to matter
|
|
49
|
+
if (context.tokensReturned > 500) {
|
|
50
|
+
return {
|
|
51
|
+
level: 'info',
|
|
52
|
+
message: `POLICY: ${EXPENSIVE_TOOLS[tool]}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// 4. Require read_for_edit before edit
|
|
57
|
+
if (policy.requireReadForEditBeforeEdit &&
|
|
58
|
+
tool === 'edit' &&
|
|
59
|
+
context.editTargetPath &&
|
|
60
|
+
context.readForEditCalled &&
|
|
61
|
+
!context.readForEditCalled.has(context.editTargetPath)) {
|
|
62
|
+
return {
|
|
63
|
+
level: 'info',
|
|
64
|
+
message: `POLICY: Consider using read_for_edit("${context.editTargetPath}") before editing to get precise edit context.`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Count how many full-file reads a tool represents.
|
|
71
|
+
* Returns 1 for full-read tools, 0 for targeted tools.
|
|
72
|
+
*/
|
|
73
|
+
export function isFullReadTool(tool) {
|
|
74
|
+
return FULL_READ_TOOLS.has(tool);
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=policy-engine.js.map
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ContextModeStatus } from '../integration/context-mode-detector.js';
|
|
2
|
+
import type { Intent } from './intent-classifier.js';
|
|
3
|
+
import type { DecisionTrace } from './decision-trace.js';
|
|
4
|
+
export type SavingsCategory = 'compression' | 'cache' | 'dedup' | 'none';
|
|
2
5
|
export interface ToolCall {
|
|
3
6
|
tool: string;
|
|
4
7
|
path?: string;
|
|
@@ -6,7 +9,12 @@ export interface ToolCall {
|
|
|
6
9
|
tokensWouldBe: number;
|
|
7
10
|
timestamp: number;
|
|
8
11
|
delegatedToContextMode?: boolean;
|
|
12
|
+
sessionCacheHit?: boolean;
|
|
13
|
+
savingsCategory?: SavingsCategory;
|
|
14
|
+
intent?: Intent;
|
|
15
|
+
decisionTrace?: DecisionTrace;
|
|
9
16
|
}
|
|
17
|
+
export type { Intent, DecisionTrace };
|
|
10
18
|
/**
|
|
11
19
|
* Tracks token savings and tool usage across a session.
|
|
12
20
|
* When context-mode is detected, includes unified reporting.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { formatDuration } from './format-duration.js';
|
|
2
|
+
import { ALL_INTENTS } from './intent-classifier.js';
|
|
2
3
|
/**
|
|
3
4
|
* Tracks token savings and tool usage across a session.
|
|
4
5
|
* When context-mode is detected, includes unified reporting.
|
|
@@ -24,10 +25,11 @@ export class SessionAnalytics {
|
|
|
24
25
|
// Group by tool
|
|
25
26
|
const byTool = new Map();
|
|
26
27
|
for (const c of this.calls) {
|
|
27
|
-
const existing = byTool.get(c.tool) ?? { count: 0, tokens: 0, saved: 0 };
|
|
28
|
+
const existing = byTool.get(c.tool) ?? { count: 0, tokens: 0, saved: 0, wouldBe: 0 };
|
|
28
29
|
existing.count++;
|
|
29
30
|
existing.tokens += c.tokensReturned;
|
|
30
31
|
existing.saved += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
32
|
+
existing.wouldBe += c.tokensWouldBe;
|
|
31
33
|
byTool.set(c.tool, existing);
|
|
32
34
|
}
|
|
33
35
|
const lines = [
|
|
@@ -39,8 +41,12 @@ export class SessionAnalytics {
|
|
|
39
41
|
'',
|
|
40
42
|
'By tool:',
|
|
41
43
|
];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const sortedTools = Array.from(byTool.entries()).sort((a, b) => b[1].saved - a[1].saved);
|
|
45
|
+
for (const [tool, stats] of sortedTools) {
|
|
46
|
+
const reduction = stats.wouldBe > 0
|
|
47
|
+
? Math.round((1 - stats.tokens / stats.wouldBe) * 100)
|
|
48
|
+
: 0;
|
|
49
|
+
lines.push(` ${tool}: ${stats.count} calls, ~${stats.tokens} tokens returned, ~${stats.saved} saved (${reduction}% reduction)`);
|
|
44
50
|
}
|
|
45
51
|
// Top files by savings
|
|
46
52
|
const byFile = new Map();
|
|
@@ -60,11 +66,48 @@ export class SessionAnalytics {
|
|
|
60
66
|
lines.push(` ${file}: ~${saved} tokens saved`);
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
69
|
+
const lowValueTools = sortedTools
|
|
70
|
+
.map(([tool, stats]) => ({
|
|
71
|
+
tool,
|
|
72
|
+
reduction: stats.wouldBe > 0 ? Math.round((1 - stats.tokens / stats.wouldBe) * 100) : 0,
|
|
73
|
+
count: stats.count,
|
|
74
|
+
}))
|
|
75
|
+
.filter((tool) => tool.reduction < 20);
|
|
76
|
+
if (lowValueTools.length > 0) {
|
|
66
77
|
lines.push('');
|
|
67
|
-
lines.push(
|
|
78
|
+
lines.push('Needs improvement:');
|
|
79
|
+
for (const tool of lowValueTools.slice(0, 5)) {
|
|
80
|
+
lines.push(` ${tool.tool}: only ${tool.reduction}% reduction across ${tool.count} call${tool.count === 1 ? '' : 's'}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Savings breakdown by category
|
|
84
|
+
const byCategory = { compression: 0, cache: 0, dedup: 0, none: 0 };
|
|
85
|
+
for (const c of this.calls) {
|
|
86
|
+
const cat = c.savingsCategory ?? 'none';
|
|
87
|
+
byCategory[cat] += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
88
|
+
}
|
|
89
|
+
if (totalWouldBe > totalReturned) {
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push('Savings breakdown:');
|
|
92
|
+
if (byCategory.compression > 0)
|
|
93
|
+
lines.push(` Compression (AST/structured): ~${byCategory.compression} tokens`);
|
|
94
|
+
if (byCategory.cache > 0)
|
|
95
|
+
lines.push(` Cache hits (session cache): ~${byCategory.cache} tokens`);
|
|
96
|
+
if (byCategory.dedup > 0)
|
|
97
|
+
lines.push(` Dedup (already in context): ~${byCategory.dedup} tokens`);
|
|
98
|
+
}
|
|
99
|
+
// Session cache hits
|
|
100
|
+
const cacheHits = this.calls.filter(c => c.sessionCacheHit);
|
|
101
|
+
if (cacheHits.length > 0) {
|
|
102
|
+
const cacheTokensSaved = cacheHits.reduce((s, c) => s + Math.max(0, c.tokensWouldBe - c.tokensReturned), 0);
|
|
103
|
+
lines.push('');
|
|
104
|
+
lines.push(`Session cache: ${cacheHits.length} hits / ${this.calls.length} calls (${Math.round(cacheHits.length / this.calls.length * 100)}% hit rate, ~${cacheTokensSaved} tokens saved)`);
|
|
105
|
+
}
|
|
106
|
+
// Dedup reminders served
|
|
107
|
+
const dedupCalls = this.calls.filter(c => c.savingsCategory === 'dedup');
|
|
108
|
+
if (dedupCalls.length > 0) {
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(`Compact reminders/dedup: ${dedupCalls.length} calls (avoided full re-reads)`);
|
|
68
111
|
}
|
|
69
112
|
// Delegation stats
|
|
70
113
|
const delegated = this.calls.filter(c => c.delegatedToContextMode);
|
|
@@ -72,6 +115,42 @@ export class SessionAnalytics {
|
|
|
72
115
|
lines.push('');
|
|
73
116
|
lines.push(`Delegated to context-mode: ${delegated.length} calls`);
|
|
74
117
|
}
|
|
118
|
+
// Per-intent breakdown (Track 2)
|
|
119
|
+
const callsWithIntent = this.calls.filter(c => c.intent);
|
|
120
|
+
if (callsWithIntent.length > 0) {
|
|
121
|
+
const byIntent = new Map();
|
|
122
|
+
for (const c of callsWithIntent) {
|
|
123
|
+
const intent = c.intent;
|
|
124
|
+
const existing = byIntent.get(intent) ?? { count: 0, saved: 0 };
|
|
125
|
+
existing.count++;
|
|
126
|
+
existing.saved += Math.max(0, c.tokensWouldBe - c.tokensReturned);
|
|
127
|
+
byIntent.set(intent, existing);
|
|
128
|
+
}
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push('Per-intent breakdown:');
|
|
131
|
+
for (const intent of ALL_INTENTS) {
|
|
132
|
+
const stats = byIntent.get(intent);
|
|
133
|
+
if (stats) {
|
|
134
|
+
lines.push(` ${intent}: ${stats.count} call${stats.count === 1 ? '' : 's'}, ~${stats.saved} tokens saved`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Decision insights (Track 0)
|
|
139
|
+
const tracedCalls = this.calls.filter(c => c.decisionTrace);
|
|
140
|
+
if (tracedCalls.length > 0) {
|
|
141
|
+
const alreadyInContextCount = tracedCalls.filter(c => c.decisionTrace.alreadyInContext).length;
|
|
142
|
+
const totalEstimated = tracedCalls.reduce((s, c) => s + c.decisionTrace.estimatedCost, 0);
|
|
143
|
+
const totalActual = tracedCalls.reduce((s, c) => s + c.decisionTrace.actualCost, 0);
|
|
144
|
+
const avgReduction = totalEstimated > 0 ? Math.round((1 - totalActual / totalEstimated) * 100) : 0;
|
|
145
|
+
const missedSavings = tracedCalls.filter(c => c.decisionTrace.cheaperAlternative).length;
|
|
146
|
+
lines.push('');
|
|
147
|
+
lines.push('Decision insights:');
|
|
148
|
+
lines.push(` Files already in context: ${alreadyInContextCount} of ${tracedCalls.length} calls (${Math.round(alreadyInContextCount / tracedCalls.length * 100)}%)`);
|
|
149
|
+
lines.push(` Avg cost reduction: ${avgReduction}% (estimated → actual)`);
|
|
150
|
+
if (missedSavings > 0) {
|
|
151
|
+
lines.push(` Missed savings opportunities: ${missedSavings} call${missedSavings === 1 ? '' : 's'} could have used cheaper tools`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
75
154
|
// Context-mode companion status
|
|
76
155
|
if (this.contextModeStatus.detected) {
|
|
77
156
|
lines.push('');
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export interface SessionCacheEntry {
|
|
2
|
+
/** Cached handler result */
|
|
3
|
+
result: {
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
/** Absolute file paths (or dir prefixes ending with '/') this result depends on */
|
|
11
|
+
fileDeps: Set<string>;
|
|
12
|
+
/** Invalidate when AST index rebuilds */
|
|
13
|
+
dependsOnAst: boolean;
|
|
14
|
+
/** Invalidate when git state changes (branch switch, new commits) */
|
|
15
|
+
dependsOnGit: boolean;
|
|
16
|
+
/** When this entry was cached */
|
|
17
|
+
cachedAt: number;
|
|
18
|
+
/** Estimated token count of the result */
|
|
19
|
+
tokenEstimate: number;
|
|
20
|
+
/** Original tokensWouldBe from the first (non-cached) call — for honest cache savings */
|
|
21
|
+
tokensWouldBe?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SessionCacheDeps {
|
|
24
|
+
files?: string[];
|
|
25
|
+
dependsOnAst?: boolean;
|
|
26
|
+
dependsOnGit?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare class SessionCache {
|
|
29
|
+
private maxEntries;
|
|
30
|
+
private entries;
|
|
31
|
+
/** Reverse index: file path → set of cache keys that depend on it */
|
|
32
|
+
private fileDepsIndex;
|
|
33
|
+
private hits;
|
|
34
|
+
private misses;
|
|
35
|
+
private invalidations;
|
|
36
|
+
constructor(maxEntries: number);
|
|
37
|
+
/**
|
|
38
|
+
* Generate deterministic cache key from tool name + args.
|
|
39
|
+
* Sorts args keys for consistency regardless of insertion order.
|
|
40
|
+
*/
|
|
41
|
+
static makeCacheKey(tool: string, args: object): string;
|
|
42
|
+
/** Try to get a cached result. Returns null on miss. */
|
|
43
|
+
get(tool: string, args: object): SessionCacheEntry | null;
|
|
44
|
+
/** Store a result with its dependency metadata. */
|
|
45
|
+
set(tool: string, args: object, result: {
|
|
46
|
+
content: Array<{
|
|
47
|
+
type: string;
|
|
48
|
+
text: string;
|
|
49
|
+
}>;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}, deps: SessionCacheDeps, tokenEstimate: number, tokensWouldBe?: number): void;
|
|
52
|
+
/**
|
|
53
|
+
* Invalidate all entries that depend on any of the given files.
|
|
54
|
+
* Checks both exact path match and directory prefix match.
|
|
55
|
+
*/
|
|
56
|
+
invalidateByFiles(filePaths: string[]): number;
|
|
57
|
+
/** Invalidate all entries that depend on AST index state. */
|
|
58
|
+
invalidateByAst(): number;
|
|
59
|
+
/** Invalidate all entries that depend on git state. */
|
|
60
|
+
invalidateByGit(): number;
|
|
61
|
+
/** Clear all entries. */
|
|
62
|
+
invalidateAll(): void;
|
|
63
|
+
/** Cache statistics for analytics. */
|
|
64
|
+
stats(): {
|
|
65
|
+
entries: number;
|
|
66
|
+
hits: number;
|
|
67
|
+
misses: number;
|
|
68
|
+
hitRate: number;
|
|
69
|
+
invalidations: number;
|
|
70
|
+
};
|
|
71
|
+
private deleteEntry;
|
|
72
|
+
private evictOldest;
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=session-cache.d.ts.map
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export class SessionCache {
|
|
3
|
+
maxEntries;
|
|
4
|
+
entries = new Map();
|
|
5
|
+
/** Reverse index: file path → set of cache keys that depend on it */
|
|
6
|
+
fileDepsIndex = new Map();
|
|
7
|
+
hits = 0;
|
|
8
|
+
misses = 0;
|
|
9
|
+
invalidations = 0;
|
|
10
|
+
constructor(maxEntries) {
|
|
11
|
+
this.maxEntries = maxEntries;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Generate deterministic cache key from tool name + args.
|
|
15
|
+
* Sorts args keys for consistency regardless of insertion order.
|
|
16
|
+
*/
|
|
17
|
+
static makeCacheKey(tool, args) {
|
|
18
|
+
const sorted = JSON.stringify(args, Object.keys(args).sort());
|
|
19
|
+
const hash = createHash('sha256').update(sorted).digest('hex').slice(0, 16);
|
|
20
|
+
return `${tool}:${hash}`;
|
|
21
|
+
}
|
|
22
|
+
/** Try to get a cached result. Returns null on miss. */
|
|
23
|
+
get(tool, args) {
|
|
24
|
+
const key = SessionCache.makeCacheKey(tool, args);
|
|
25
|
+
const entry = this.entries.get(key);
|
|
26
|
+
if (entry) {
|
|
27
|
+
this.hits++;
|
|
28
|
+
return entry;
|
|
29
|
+
}
|
|
30
|
+
this.misses++;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** Store a result with its dependency metadata. */
|
|
34
|
+
set(tool, args, result, deps, tokenEstimate, tokensWouldBe) {
|
|
35
|
+
// LRU eviction if full
|
|
36
|
+
if (this.entries.size >= this.maxEntries) {
|
|
37
|
+
this.evictOldest();
|
|
38
|
+
}
|
|
39
|
+
const key = SessionCache.makeCacheKey(tool, args);
|
|
40
|
+
const fileDeps = new Set(deps.files ?? []);
|
|
41
|
+
const entry = {
|
|
42
|
+
result,
|
|
43
|
+
fileDeps,
|
|
44
|
+
dependsOnAst: deps.dependsOnAst ?? false,
|
|
45
|
+
dependsOnGit: deps.dependsOnGit ?? false,
|
|
46
|
+
cachedAt: Date.now(),
|
|
47
|
+
tokenEstimate,
|
|
48
|
+
tokensWouldBe,
|
|
49
|
+
};
|
|
50
|
+
this.entries.set(key, entry);
|
|
51
|
+
// Update reverse index
|
|
52
|
+
for (const dep of fileDeps) {
|
|
53
|
+
let keys = this.fileDepsIndex.get(dep);
|
|
54
|
+
if (!keys) {
|
|
55
|
+
keys = new Set();
|
|
56
|
+
this.fileDepsIndex.set(dep, keys);
|
|
57
|
+
}
|
|
58
|
+
keys.add(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Invalidate all entries that depend on any of the given files.
|
|
63
|
+
* Checks both exact path match and directory prefix match.
|
|
64
|
+
*/
|
|
65
|
+
invalidateByFiles(filePaths) {
|
|
66
|
+
let count = 0;
|
|
67
|
+
const keysToDelete = new Set();
|
|
68
|
+
for (const changedFile of filePaths) {
|
|
69
|
+
// Exact match from reverse index
|
|
70
|
+
const exactKeys = this.fileDepsIndex.get(changedFile);
|
|
71
|
+
if (exactKeys) {
|
|
72
|
+
for (const key of exactKeys)
|
|
73
|
+
keysToDelete.add(key);
|
|
74
|
+
}
|
|
75
|
+
// Directory prefix match: check if changedFile is under any cached dir dep
|
|
76
|
+
for (const [dep, keys] of this.fileDepsIndex) {
|
|
77
|
+
if (dep.endsWith('/') && changedFile.startsWith(dep)) {
|
|
78
|
+
for (const key of keys)
|
|
79
|
+
keysToDelete.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const key of keysToDelete) {
|
|
84
|
+
this.deleteEntry(key);
|
|
85
|
+
count++;
|
|
86
|
+
}
|
|
87
|
+
this.invalidations += count;
|
|
88
|
+
return count;
|
|
89
|
+
}
|
|
90
|
+
/** Invalidate all entries that depend on AST index state. */
|
|
91
|
+
invalidateByAst() {
|
|
92
|
+
let count = 0;
|
|
93
|
+
for (const [key, entry] of this.entries) {
|
|
94
|
+
if (entry.dependsOnAst) {
|
|
95
|
+
this.deleteEntry(key);
|
|
96
|
+
count++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this.invalidations += count;
|
|
100
|
+
return count;
|
|
101
|
+
}
|
|
102
|
+
/** Invalidate all entries that depend on git state. */
|
|
103
|
+
invalidateByGit() {
|
|
104
|
+
let count = 0;
|
|
105
|
+
for (const [key, entry] of this.entries) {
|
|
106
|
+
if (entry.dependsOnGit) {
|
|
107
|
+
this.deleteEntry(key);
|
|
108
|
+
count++;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.invalidations += count;
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
/** Clear all entries. */
|
|
115
|
+
invalidateAll() {
|
|
116
|
+
const count = this.entries.size;
|
|
117
|
+
this.entries.clear();
|
|
118
|
+
this.fileDepsIndex.clear();
|
|
119
|
+
this.invalidations += count;
|
|
120
|
+
}
|
|
121
|
+
/** Cache statistics for analytics. */
|
|
122
|
+
stats() {
|
|
123
|
+
const total = this.hits + this.misses;
|
|
124
|
+
return {
|
|
125
|
+
entries: this.entries.size,
|
|
126
|
+
hits: this.hits,
|
|
127
|
+
misses: this.misses,
|
|
128
|
+
hitRate: total > 0 ? Math.round((this.hits / total) * 100) : 0,
|
|
129
|
+
invalidations: this.invalidations,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// --- Private helpers ---
|
|
133
|
+
deleteEntry(key) {
|
|
134
|
+
const entry = this.entries.get(key);
|
|
135
|
+
if (!entry)
|
|
136
|
+
return;
|
|
137
|
+
// Clean up reverse index
|
|
138
|
+
for (const dep of entry.fileDeps) {
|
|
139
|
+
const keys = this.fileDepsIndex.get(dep);
|
|
140
|
+
if (keys) {
|
|
141
|
+
keys.delete(key);
|
|
142
|
+
if (keys.size === 0)
|
|
143
|
+
this.fileDepsIndex.delete(dep);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
this.entries.delete(key);
|
|
147
|
+
}
|
|
148
|
+
evictOldest() {
|
|
149
|
+
let oldestKey = null;
|
|
150
|
+
let oldestTime = Infinity;
|
|
151
|
+
for (const [key, entry] of this.entries) {
|
|
152
|
+
if (entry.cachedAt < oldestTime) {
|
|
153
|
+
oldestTime = entry.cachedAt;
|
|
154
|
+
oldestKey = key;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (oldestKey) {
|
|
158
|
+
this.deleteEntry(oldestKey);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=session-cache.js.map
|
|
@@ -64,6 +64,9 @@ export declare function validateReadForEditArgs(args: unknown): {
|
|
|
64
64
|
symbol?: string;
|
|
65
65
|
line?: number;
|
|
66
66
|
context?: number;
|
|
67
|
+
include_callers?: boolean;
|
|
68
|
+
include_tests?: boolean;
|
|
69
|
+
include_changes?: boolean;
|
|
67
70
|
};
|
|
68
71
|
/**
|
|
69
72
|
* Validate related_files arguments.
|
package/dist/core/validation.js
CHANGED
|
@@ -184,6 +184,9 @@ export function validateReadForEditArgs(args) {
|
|
|
184
184
|
symbol: optionalString(a.symbol, 'symbol'),
|
|
185
185
|
line: optionalNumber(a.line, 'line'),
|
|
186
186
|
context: optionalNumber(a.context, 'context'),
|
|
187
|
+
include_callers: optionalBool(a.include_callers, 'include_callers'),
|
|
188
|
+
include_tests: optionalBool(a.include_tests, 'include_tests'),
|
|
189
|
+
include_changes: optionalBool(a.include_changes, 'include_changes'),
|
|
187
190
|
};
|
|
188
191
|
}
|
|
189
192
|
/**
|
|
@@ -18,10 +18,16 @@ export declare class FileWatcher {
|
|
|
18
18
|
private watcher;
|
|
19
19
|
private watchedFiles;
|
|
20
20
|
private updateTimer;
|
|
21
|
+
private fileChangeCallback;
|
|
22
|
+
private astUpdateCallback;
|
|
21
23
|
constructor(_projectRoot: string, fileCache: FileCache, contextRegistry: ContextRegistry, _ignore: string[], astIndex?: AstIndexClient);
|
|
22
24
|
start(): void;
|
|
23
25
|
/** Debounced ast-index incremental update after file changes */
|
|
24
26
|
private scheduleIndexUpdate;
|
|
27
|
+
/** Register callback for file change/unlink events. */
|
|
28
|
+
onFileChange(callback: (absPath: string) => void): void;
|
|
29
|
+
/** Register callback for after AST index incremental update completes. */
|
|
30
|
+
onAstUpdate(callback: () => void): void;
|
|
25
31
|
/** Add a specific file to watch. Called after smart_read/read_symbol loads a file. */
|
|
26
32
|
watchFile(filePath: string): void;
|
|
27
33
|
stop(): void;
|