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 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
- const content = readFileSync(filePath, 'utf-8');
176
- lineCount = content.split('\n').length;
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.0",
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",