token-pilot 0.16.0 → 0.16.2
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 +6 -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 +2 -0
- package/dist/core/session-analytics.js +30 -0
- package/dist/index.js +18 -3
- package/dist/server.js +1 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ All notable changes to Token Pilot will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.16.1] - 2026-03-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Hook interception tracking** — PreToolUse hook now records denied Read calls (file path, line count, estimated tokens) to `.token-pilot/hook-denied.jsonl`. Session analytics shows how many tokens the hook saved by intercepting unbounded reads on large code files.
|
|
12
|
+
- **`session_analytics` hook savings** — compact report adds "Hook: intercepted N reads, saved ~X tokens" line. Verbose mode shows per-file breakdown of intercepted reads.
|
|
13
|
+
|
|
8
14
|
## [0.16.0] - 2026-03-21
|
|
9
15
|
|
|
10
16
|
### Added
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface DeniedRead {
|
|
2
|
+
filePath: string;
|
|
3
|
+
lineCount: number;
|
|
4
|
+
estimatedTokens: number;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Called from hook-read process when a Read is denied.
|
|
9
|
+
* Appends to a shared JSONL file so the MCP server can read it.
|
|
10
|
+
*/
|
|
11
|
+
export declare function appendDeniedRead(filePath: string, lineCount: number, fileContent: string, projectRoot?: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Called from MCP server to load denied reads for analytics.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadDeniedReads(projectRoot?: string): DeniedRead[];
|
|
16
|
+
/**
|
|
17
|
+
* Clear denied reads (called on session reset or after reporting).
|
|
18
|
+
*/
|
|
19
|
+
export declare function clearDeniedReads(projectRoot?: string): void;
|
|
20
|
+
//# sourceMappingURL=hook-tracker.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { appendFileSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
function getDeniedReadsPath(projectRoot) {
|
|
4
|
+
const root = projectRoot || process.cwd();
|
|
5
|
+
return join(root, '.token-pilot', 'hook-denied.jsonl');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Called from hook-read process when a Read is denied.
|
|
9
|
+
* Appends to a shared JSONL file so the MCP server can read it.
|
|
10
|
+
*/
|
|
11
|
+
export function appendDeniedRead(filePath, lineCount, fileContent, projectRoot) {
|
|
12
|
+
try {
|
|
13
|
+
const charEstimate = Math.ceil(fileContent.length / 4);
|
|
14
|
+
const whitespaceRatio = (fileContent.match(/\s/g)?.length ?? 0) / fileContent.length;
|
|
15
|
+
const estimatedTokens = Math.ceil(charEstimate * (1 - whitespaceRatio * 0.3));
|
|
16
|
+
const entry = {
|
|
17
|
+
filePath,
|
|
18
|
+
lineCount,
|
|
19
|
+
estimatedTokens,
|
|
20
|
+
timestamp: Date.now(),
|
|
21
|
+
};
|
|
22
|
+
const outPath = getDeniedReadsPath(projectRoot);
|
|
23
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
24
|
+
appendFileSync(outPath, JSON.stringify(entry) + '\n');
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Silent fail — hook must not break
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Called from MCP server to load denied reads for analytics.
|
|
32
|
+
*/
|
|
33
|
+
export function loadDeniedReads(projectRoot) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = readFileSync(getDeniedReadsPath(projectRoot), 'utf-8');
|
|
36
|
+
return raw
|
|
37
|
+
.trim()
|
|
38
|
+
.split('\n')
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map(line => JSON.parse(line));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Clear denied reads (called on session reset or after reporting).
|
|
48
|
+
*/
|
|
49
|
+
export function clearDeniedReads(projectRoot) {
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(getDeniedReadsPath(projectRoot));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// File may not exist
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=hook-tracker.js.map
|
|
@@ -23,6 +23,8 @@ export declare class SessionAnalytics {
|
|
|
23
23
|
private calls;
|
|
24
24
|
private sessionStart;
|
|
25
25
|
private contextModeStatus;
|
|
26
|
+
private projectRoot?;
|
|
27
|
+
setProjectRoot(root: string): void;
|
|
26
28
|
setContextModeStatus(status: ContextModeStatus): void;
|
|
27
29
|
record(call: ToolCall): void;
|
|
28
30
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { formatDuration } from './format-duration.js';
|
|
2
2
|
import { ALL_INTENTS } from './intent-classifier.js';
|
|
3
|
+
import { loadDeniedReads, clearDeniedReads } from './hook-tracker.js';
|
|
3
4
|
/**
|
|
4
5
|
* Tracks token savings and tool usage across a session.
|
|
5
6
|
* When context-mode is detected, includes unified reporting.
|
|
@@ -8,6 +9,10 @@ export class SessionAnalytics {
|
|
|
8
9
|
calls = [];
|
|
9
10
|
sessionStart = Date.now();
|
|
10
11
|
contextModeStatus = { detected: false, source: 'none', toolPrefix: '' };
|
|
12
|
+
projectRoot;
|
|
13
|
+
setProjectRoot(root) {
|
|
14
|
+
this.projectRoot = root;
|
|
15
|
+
}
|
|
11
16
|
setContextModeStatus(status) {
|
|
12
17
|
this.contextModeStatus = status;
|
|
13
18
|
}
|
|
@@ -69,6 +74,13 @@ export class SessionAnalytics {
|
|
|
69
74
|
if (extras.length > 0) {
|
|
70
75
|
lines.push(extras.join(' · '));
|
|
71
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
|
+
}
|
|
72
84
|
if (!verbose)
|
|
73
85
|
return lines.join('\n');
|
|
74
86
|
// --- Verbose additions ---
|
|
@@ -169,6 +181,23 @@ export class SessionAnalytics {
|
|
|
169
181
|
lines.push(` Missed savings: ${missedSavings} call${missedSavings === 1 ? '' : 's'} could have used cheaper tools`);
|
|
170
182
|
}
|
|
171
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
|
+
}
|
|
172
201
|
// Context-mode
|
|
173
202
|
if (this.contextModeStatus.detected) {
|
|
174
203
|
lines.push('');
|
|
@@ -182,6 +211,7 @@ export class SessionAnalytics {
|
|
|
182
211
|
reset() {
|
|
183
212
|
this.calls = [];
|
|
184
213
|
this.sessionStart = Date.now();
|
|
214
|
+
clearDeniedReads(this.projectRoot);
|
|
185
215
|
}
|
|
186
216
|
}
|
|
187
217
|
//# sourceMappingURL=session-analytics.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { readFileSync, realpathSync, appendFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
4
5
|
import { execFile } from 'node:child_process';
|
|
5
6
|
import { promisify } from 'node:util';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
@@ -171,9 +172,10 @@ export function handleHookRead(filePathArg, denyThreshold = 300) {
|
|
|
171
172
|
}
|
|
172
173
|
// Check file size
|
|
173
174
|
let lineCount = 0;
|
|
175
|
+
let fileContent = '';
|
|
174
176
|
try {
|
|
175
|
-
|
|
176
|
-
lineCount =
|
|
177
|
+
fileContent = readFileSync(filePath, 'utf-8');
|
|
178
|
+
lineCount = fileContent.split('\n').length;
|
|
177
179
|
if (lineCount <= denyThreshold) {
|
|
178
180
|
process.exit(0);
|
|
179
181
|
}
|
|
@@ -181,6 +183,19 @@ export function handleHookRead(filePathArg, denyThreshold = 300) {
|
|
|
181
183
|
catch {
|
|
182
184
|
process.exit(0);
|
|
183
185
|
}
|
|
186
|
+
// Track denied read for session analytics
|
|
187
|
+
try {
|
|
188
|
+
const charEst = Math.ceil(fileContent.length / 4);
|
|
189
|
+
const wsRatio = (fileContent.match(/\s/g)?.length ?? 0) / fileContent.length;
|
|
190
|
+
const estTokens = Math.ceil(charEst * (1 - wsRatio * 0.3));
|
|
191
|
+
const entry = JSON.stringify({ filePath, lineCount, estimatedTokens: estTokens, timestamp: Date.now() });
|
|
192
|
+
const dir = join(process.cwd(), '.token-pilot');
|
|
193
|
+
mkdirSync(dir, { recursive: true });
|
|
194
|
+
appendFileSync(join(dir, 'hook-denied.jsonl'), entry + '\n');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Silent fail — hook must not break
|
|
198
|
+
}
|
|
184
199
|
// Large code file, unbounded Read → DENY
|
|
185
200
|
// permissionDecisionReason is shown to Claude (not user) per official docs
|
|
186
201
|
const deny = JSON.stringify({
|
package/dist/server.js
CHANGED
|
@@ -146,6 +146,7 @@ export async function createServer(projectRoot, options) {
|
|
|
146
146
|
}
|
|
147
147
|
// Session analytics
|
|
148
148
|
const analytics = new SessionAnalytics();
|
|
149
|
+
analytics.setProjectRoot(projectRoot);
|
|
149
150
|
// Session cache (tool-result-level caching, invalidated by file/AST/git changes)
|
|
150
151
|
const sessionCache = config.sessionCache.enabled
|
|
151
152
|
? new SessionCache(config.sessionCache.maxEntries)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-pilot",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.2",
|
|
4
4
|
"description": "Save up to 80% tokens when AI reads code — MCP server for token-efficient code navigation, AST-aware structural reading instead of dumping full files into context window",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"bugs": {
|
|
59
59
|
"url": "https://github.com/Digital-Threads/token-pilot/issues"
|
|
60
60
|
},
|
|
61
|
+
"mcpName": "io.github.Digital-Threads/token-pilot",
|
|
61
62
|
"license": "MIT",
|
|
62
63
|
"dependencies": {
|
|
63
64
|
"@modelcontextprotocol/sdk": "^1.12.0",
|