token-pilot 0.24.1 → 0.26.5

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.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +29 -12
  2. package/.claude-plugin/plugin.json +23 -5
  3. package/CHANGELOG.md +182 -0
  4. package/README.md +128 -15
  5. package/dist/agents/tp-api-surface-tracker.md +4 -3
  6. package/dist/agents/tp-audit-scanner.md +1 -1
  7. package/dist/agents/tp-commit-writer.md +1 -1
  8. package/dist/agents/tp-dead-code-finder.md +22 -7
  9. package/dist/agents/tp-debugger.md +1 -1
  10. package/dist/agents/tp-dep-health.md +3 -2
  11. package/dist/agents/tp-history-explorer.md +1 -1
  12. package/dist/agents/tp-impact-analyzer.md +1 -1
  13. package/dist/agents/tp-incident-timeline.md +1 -1
  14. package/dist/agents/tp-migration-scout.md +1 -1
  15. package/dist/agents/tp-onboard.md +1 -1
  16. package/dist/agents/tp-pr-reviewer.md +1 -1
  17. package/dist/agents/tp-refactor-planner.md +1 -1
  18. package/dist/agents/tp-review-impact.md +1 -1
  19. package/dist/agents/tp-run.md +1 -1
  20. package/dist/agents/tp-session-restorer.md +1 -1
  21. package/dist/agents/tp-test-coverage-gapper.md +1 -1
  22. package/dist/agents/tp-test-triage.md +1 -1
  23. package/dist/agents/tp-test-writer.md +1 -1
  24. package/dist/cli/detect-client.d.ts +39 -0
  25. package/dist/cli/detect-client.js +106 -0
  26. package/dist/cli/install-agents.d.ts +1 -0
  27. package/dist/cli/install-agents.js +31 -1
  28. package/dist/cli/tool-audit.d.ts +58 -0
  29. package/dist/cli/tool-audit.js +123 -0
  30. package/dist/cli/typo-guard.d.ts +1 -1
  31. package/dist/cli/typo-guard.js +1 -0
  32. package/dist/core/tool-call-log.d.ts +63 -0
  33. package/dist/core/tool-call-log.js +171 -0
  34. package/dist/handlers/read-symbols.js +23 -1
  35. package/dist/hooks/installer.js +27 -12
  36. package/dist/index.js +55 -0
  37. package/dist/server/profile-recommender.d.ts +48 -0
  38. package/dist/server/profile-recommender.js +102 -0
  39. package/dist/server/token-estimates.d.ts +17 -3
  40. package/dist/server/token-estimates.js +77 -45
  41. package/dist/server/tool-definitions.js +1 -1
  42. package/dist/server/tool-profiles.d.ts +46 -0
  43. package/dist/server/tool-profiles.js +81 -0
  44. package/dist/server.js +38 -1
  45. package/package.json +1 -1
  46. package/start.sh +0 -0
  47. package/.mcp.json +0 -8
@@ -0,0 +1,171 @@
1
+ /**
2
+ * v0.26.2 — persistent MCP tool-call log.
3
+ *
4
+ * Separate from `hook-events.jsonl` (which records Read-hook outcomes),
5
+ * this file accumulates every MCP tool invocation with its token
6
+ * accounting across ALL sessions. Used by `npx token-pilot tool-audit`
7
+ * to produce a per-tool savings distribution that survives `/clear`,
8
+ * session restarts, and even reboots — i.e. the data-driven base we
9
+ * need before pruning or modifying tools based on "savings".
10
+ *
11
+ * Why not piggy-back on hook-events.jsonl? Different data model: hook
12
+ * events are a denied/allowed bitstream keyed by filepath+lineCount,
13
+ * tool calls are rich records with tokensReturned, wouldBe, category,
14
+ * delegation status. Forcing both into one schema would hurt both
15
+ * readers.
16
+ *
17
+ * File path: `<projectRoot>/.token-pilot/tool-calls.jsonl`. Rotation,
18
+ * retention, and best-effort error handling follow the same contract
19
+ * as event-log.ts — identical 10 MB / 30-day / 100 MB caps so overall
20
+ * .token-pilot/ disk usage stays predictable.
21
+ */
22
+ import { promises as fs } from "node:fs";
23
+ import { join } from "node:path";
24
+ export const TOOL_LOG_ROTATION_BYTES = 10_000_000;
25
+ export const TOOL_LOG_RETENTION_MAX_AGE_DAYS = 30;
26
+ export const TOOL_LOG_RETENTION_MAX_TOTAL_BYTES = 100_000_000;
27
+ const CURRENT_FILE = "tool-calls.jsonl";
28
+ const ARCHIVE_RE = /^tool-calls\.\d+\.jsonl$/;
29
+ function toolLogDir(projectRoot) {
30
+ return join(projectRoot, ".token-pilot");
31
+ }
32
+ export function currentToolLogPath(projectRoot) {
33
+ return join(toolLogDir(projectRoot), CURRENT_FILE);
34
+ }
35
+ async function ensureDir(projectRoot) {
36
+ await fs.mkdir(toolLogDir(projectRoot), { recursive: true });
37
+ }
38
+ async function rotateIfNeeded(projectRoot, thresholdBytes = TOOL_LOG_ROTATION_BYTES) {
39
+ const current = currentToolLogPath(projectRoot);
40
+ try {
41
+ const s = await fs.stat(current);
42
+ if (s.size < thresholdBytes)
43
+ return;
44
+ }
45
+ catch {
46
+ return; // no file → nothing to rotate
47
+ }
48
+ const archive = join(toolLogDir(projectRoot), `tool-calls.${Date.now()}.jsonl`);
49
+ try {
50
+ await fs.rename(current, archive);
51
+ }
52
+ catch {
53
+ /* raced — next writer will append onto whichever file exists */
54
+ }
55
+ }
56
+ /**
57
+ * Append one tool call. Never throws — telemetry must not break the
58
+ * tool-response path (the caller awaits this but treats errors as
59
+ * silent via `.catch(() => undefined)` at the call site).
60
+ */
61
+ export async function appendToolCall(projectRoot, event) {
62
+ try {
63
+ await ensureDir(projectRoot);
64
+ await rotateIfNeeded(projectRoot);
65
+ await fs.appendFile(currentToolLogPath(projectRoot), JSON.stringify(event) + "\n");
66
+ }
67
+ catch {
68
+ /* silent */
69
+ }
70
+ }
71
+ /**
72
+ * Read every tool-call event from the current file + all archives.
73
+ * Malformed JSONL lines are skipped silently — one bad line should not
74
+ * poison the dataset. Returns events in *insertion order within each
75
+ * file*, which happens to be chronological because append-only.
76
+ */
77
+ export async function loadAllToolCalls(projectRoot) {
78
+ const dir = toolLogDir(projectRoot);
79
+ let entries;
80
+ try {
81
+ entries = await fs.readdir(dir);
82
+ }
83
+ catch {
84
+ return [];
85
+ }
86
+ const files = [];
87
+ if (entries.includes(CURRENT_FILE))
88
+ files.push(CURRENT_FILE);
89
+ for (const n of entries)
90
+ if (ARCHIVE_RE.test(n))
91
+ files.push(n);
92
+ const out = [];
93
+ for (const name of files) {
94
+ let raw;
95
+ try {
96
+ raw = await fs.readFile(join(dir, name), "utf-8");
97
+ }
98
+ catch {
99
+ continue;
100
+ }
101
+ for (const line of raw.split("\n")) {
102
+ if (!line.trim())
103
+ continue;
104
+ try {
105
+ out.push(JSON.parse(line));
106
+ }
107
+ catch {
108
+ /* skip malformed */
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ // ─── retention (mirrors event-log) ──────────────────────────────────────────
115
+ export function retentionDeletions(files, now, maxAgeDays = TOOL_LOG_RETENTION_MAX_AGE_DAYS, maxTotalBytes = TOOL_LOG_RETENTION_MAX_TOTAL_BYTES) {
116
+ const toDelete = new Set();
117
+ const maxAgeMs = maxAgeDays * 86_400_000;
118
+ const survivors = [];
119
+ for (const f of files) {
120
+ if (now.getTime() - f.mtime.getTime() > maxAgeMs) {
121
+ toDelete.add(f.path);
122
+ }
123
+ else {
124
+ survivors.push(f);
125
+ }
126
+ }
127
+ const totalSize = survivors.reduce((sum, f) => sum + f.size, 0);
128
+ if (totalSize > maxTotalBytes) {
129
+ const byOldest = [...survivors].sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
130
+ let trimmed = totalSize;
131
+ for (const f of byOldest) {
132
+ if (trimmed <= maxTotalBytes)
133
+ break;
134
+ toDelete.add(f.path);
135
+ trimmed -= f.size;
136
+ }
137
+ }
138
+ return [...toDelete];
139
+ }
140
+ export async function applyRetention(projectRoot, now = new Date()) {
141
+ const dir = toolLogDir(projectRoot);
142
+ let entries;
143
+ try {
144
+ entries = await fs.readdir(dir);
145
+ }
146
+ catch {
147
+ return;
148
+ }
149
+ const archives = [];
150
+ for (const name of entries) {
151
+ if (!ARCHIVE_RE.test(name))
152
+ continue;
153
+ const full = join(dir, name);
154
+ try {
155
+ const s = await fs.stat(full);
156
+ archives.push({ path: full, mtime: s.mtime, size: s.size });
157
+ }
158
+ catch {
159
+ /* unreadable → skip */
160
+ }
161
+ }
162
+ for (const p of retentionDeletions(archives, now)) {
163
+ try {
164
+ await fs.unlink(p);
165
+ }
166
+ catch {
167
+ /* ignore */
168
+ }
169
+ }
170
+ }
171
+ //# sourceMappingURL=tool-call-log.js.map
@@ -67,6 +67,15 @@ export async function handleReadSymbols(args, projectRoot, symbolResolver, fileC
67
67
  let anyTruncated = false;
68
68
  let anyResolved = false;
69
69
  let totalTokens = 0;
70
+ // v0.26.1 — overlap dedupe. The ast-index parser occasionally resolves
71
+ // two requested symbols to the exact same line range (arrow-function
72
+ // exports, Vue SFCs, type-vs-function ambiguity). Before this fix the
73
+ // handler emitted the same body N× — a 4× blow-up on Opus 4.7's field
74
+ // report, directly negating batch savings. We key by "startLine:endLine"
75
+ // and emit a short dedupe note instead of repeating the source. Caller
76
+ // still sees *which* names they asked for; token budget stays sane.
77
+ const seenRanges = new Map(); // "start:end" -> first name
78
+ let dedupedCount = 0;
70
79
  for (let i = 0; i < N; i++) {
71
80
  const symbolName = args.symbols[i];
72
81
  const idx = i + 1;
@@ -78,6 +87,16 @@ export async function handleReadSymbols(args, projectRoot, symbolResolver, fileC
78
87
  continue;
79
88
  }
80
89
  anyResolved = true;
90
+ const rangeKey = `${resolved.startLine}:${resolved.endLine}`;
91
+ const firstName = seenRanges.get(rangeKey);
92
+ if (firstName) {
93
+ dedupedCount++;
94
+ sections.push(`SYMBOL ${idx}/${N}: ${symbolName} — DEDUPED\n` +
95
+ `Same [L${resolved.startLine}-${resolved.endLine}] range as "${firstName}" ` +
96
+ `(ast-index parser overlap). See that section above for the source.`);
97
+ continue;
98
+ }
99
+ seenRanges.set(rangeKey, symbolName);
81
100
  const source = symbolResolver.extractSource(resolved, lines, {
82
101
  contextBefore: args.context_before ?? 2,
83
102
  contextAfter: args.context_after ?? 0,
@@ -164,7 +183,10 @@ export async function handleReadSymbols(args, projectRoot, symbolResolver, fileC
164
183
  if (cached?.hash) {
165
184
  contextRegistry.setContentHash(absPath, cached.hash);
166
185
  }
167
- const header = `FILE: ${args.path} | SYMBOLS: ${N} requested`;
186
+ const header = `FILE: ${args.path} | SYMBOLS: ${N} requested` +
187
+ (dedupedCount > 0
188
+ ? ` | DEDUPED: ${dedupedCount} (parser overlap — saved ~${dedupedCount}× body tokens)`
189
+ : "");
168
190
  const body = sections.join("\n\n---\n\n");
169
191
  const footer = "CONTEXT TRACKED: These symbols are now in your context.";
170
192
  const output = [header, "", body, "", footer].join("\n");
@@ -130,9 +130,18 @@ export async function installHook(projectRoot, options) {
130
130
  const hasEdit = existingHooks.some((h) => h.matcher === "Edit" && isTokenPilotHook(h));
131
131
  const hasSessionStart = Array.isArray(settings.hooks?.SessionStart) &&
132
132
  settings.hooks.SessionStart.some(isTokenPilotHook);
133
- const hasPostBashHook = Array.isArray(settings.hooks?.PostToolUse) &&
134
- settings.hooks.PostToolUse.some(isTokenPilotHook);
135
- if (hasRead && hasEdit && hasSessionStart && hasPostBashHook) {
133
+ // v0.25.0: check each PostToolUse matcher separately. Previously
134
+ // "any token-pilot hook in PostToolUse" counted the whole section
135
+ // as installed, so v0.21 users (Bash matcher only) missed the
136
+ // Task matcher added in v0.23 and their budget watchdog stayed
137
+ // silent. The required matchers are exactly what createHookConfig
138
+ // ships — derive from there so this stays in sync automatically.
139
+ const requiredPostMatchers = hookConfig.hooks.PostToolUse.map((h) => h.matcher);
140
+ const postMatchers = Array.isArray(settings.hooks?.PostToolUse)
141
+ ? settings.hooks.PostToolUse.filter(isTokenPilotHook).map((h) => h.matcher)
142
+ : [];
143
+ const hasAllPostMatchers = requiredPostMatchers.every((m) => postMatchers.includes(m));
144
+ if (hasRead && hasEdit && hasSessionStart && hasAllPostMatchers) {
136
145
  return {
137
146
  installed: false,
138
147
  fatal: false,
@@ -166,16 +175,22 @@ export async function installHook(projectRoot, options) {
166
175
  }
167
176
  settings.hooks.SessionStart.push(...hookConfig.hooks.SessionStart);
168
177
  }
169
- // Install PostToolUse (Bash advisor) hook idempotently
170
- const existingPost = settings.hooks?.PostToolUse;
171
- const hasPostBash = Array.isArray(existingPost) && existingPost.some(isTokenPilotHook);
172
- if (!hasPostBash) {
173
- if (!settings.hooks)
174
- settings.hooks = {};
175
- if (!Array.isArray(settings.hooks.PostToolUse)) {
176
- settings.hooks.PostToolUse = [];
178
+ // Install PostToolUse hooks idempotently per-matcher check.
179
+ // v0.25.0: earlier code treated the whole section as one unit, which
180
+ // meant users installed in v0.21 (only Bash matcher) never received
181
+ // the Task matcher added in v0.23. That silently broke the budget
182
+ // watchdog for anyone upgrading from an older version. Now we check
183
+ // each matcher individually, same as PreToolUse.
184
+ if (!settings.hooks)
185
+ settings.hooks = {};
186
+ if (!Array.isArray(settings.hooks.PostToolUse)) {
187
+ settings.hooks.PostToolUse = [];
188
+ }
189
+ for (const hookDef of hookConfig.hooks.PostToolUse) {
190
+ const exists = settings.hooks.PostToolUse.some((h) => h.matcher === hookDef.matcher && isTokenPilotHook(h));
191
+ if (!exists) {
192
+ settings.hooks.PostToolUse.push(hookDef);
177
193
  }
178
- settings.hooks.PostToolUse.push(...hookConfig.hooks.PostToolUse);
179
194
  }
180
195
  await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n");
181
196
  return {
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import { handleInstallAgents, maybeEmitStartupReminder, } from "./cli/install-ag
28
28
  import { handleUninstallAgents } from "./cli/uninstall-agents.js";
29
29
  import { appendEvent, applyRetention, } from "./core/event-log.js";
30
30
  import { handleStats } from "./cli/stats.js";
31
+ import { handleToolAudit } from "./cli/tool-audit.js";
31
32
  import { promptYesNo } from "./cli/install-agents.js";
32
33
  import { runClaudeCodeEnvCheck } from "./cli/doctor-env-check.js";
33
34
  import { claudeIgnoreStatus, writeDefaultClaudeIgnore, } from "./cli/claudeignore.js";
@@ -205,6 +206,11 @@ export async function main(cliArgs = process.argv.slice(2)) {
205
206
  process.exit(code);
206
207
  return;
207
208
  }
209
+ case "tool-audit": {
210
+ const code = await handleToolAudit(cliArgs.slice(1));
211
+ process.exit(code);
212
+ return;
213
+ }
208
214
  case "save-doc": {
209
215
  const code = await handleSaveDocCli(cliArgs.slice(1));
210
216
  process.exit(code);
@@ -527,6 +533,19 @@ export function handleHookEdit() {
527
533
  process.exit(0);
528
534
  }
529
535
  export async function handleInstallHook(projectRoot) {
536
+ // v0.26.5 — plugin-aware early-return. If we're running as a Claude
537
+ // Code plugin (CLAUDE_PLUGIN_ROOT set) the hooks are already declared
538
+ // in .claude-plugin/hooks/hooks.json and Claude Code wires them up
539
+ // on install. Calling install-hook in that context would write a
540
+ // duplicate entry to the user's settings.json and emit two hooks for
541
+ // every event. Bail early.
542
+ if (process.env.CLAUDE_PLUGIN_ROOT) {
543
+ console.log("token-pilot is running as a Claude Code plugin — hooks are already\n" +
544
+ "declared in .claude-plugin/hooks/hooks.json and registered by the\n" +
545
+ "plugin installer. `install-hook` is only needed for npm/npx setups.\n" +
546
+ "Skipping to avoid duplicate hook entries.");
547
+ process.exit(0);
548
+ }
530
549
  let hookOptions;
531
550
  try {
532
551
  const rawPath = fileURLToPath(new URL("./index.js", import.meta.url));
@@ -576,6 +595,22 @@ export async function handleDoctor() {
576
595
  const { join } = await import("node:path");
577
596
  const cwd = process.cwd();
578
597
  console.log(`token-pilot doctor v${version}\n`);
598
+ // ── Installation mode ──
599
+ // v0.26.5 — tell the user HOW token-pilot is installed. Matters
600
+ // because plugin users don't need `install-hook` (hooks come from
601
+ // .claude-plugin/hooks/hooks.json); npm users do. dev/worktree users
602
+ // are usually contributors running from a local checkout.
603
+ let installMode;
604
+ if (process.env.CLAUDE_PLUGIN_ROOT) {
605
+ installMode = `plugin (${process.env.CLAUDE_PLUGIN_ROOT})`;
606
+ }
607
+ else if (process.argv[1]?.includes("/.claude/worktrees/")) {
608
+ installMode = "dev / worktree (contributor)";
609
+ }
610
+ else {
611
+ installMode = "npm / npx";
612
+ }
613
+ console.log(`Install mode: ${installMode}`);
579
614
  // ── Environment ──
580
615
  const nodeVersion = process.version;
581
616
  const nodeMajor = parseInt(nodeVersion.slice(1), 10);
@@ -677,6 +712,26 @@ export async function handleDoctor() {
677
712
  catch {
678
713
  /* ignore */
679
714
  }
715
+ // ── profile recommendation ──
716
+ // v0.26.4 — data-driven. Reads cumulative tool-calls.jsonl and suggests
717
+ // the narrowest TOKEN_PILOT_PROFILE that wouldn't hide any tool the
718
+ // user actually invokes. Never auto-applies; doctor just prints the
719
+ // env snippet and why.
720
+ try {
721
+ const { loadAllToolCalls } = await import("./core/tool-call-log.js");
722
+ const { recommendProfile, formatRecommendation } = await import("./server/profile-recommender.js");
723
+ const events = await loadAllToolCalls(cwd);
724
+ const rec = recommendProfile(events);
725
+ // Only print when there's actionable signal OR a clear "stay on full"
726
+ // with enough data — skip the noise when the log is empty.
727
+ if (rec.totalCalls > 0) {
728
+ console.log(formatRecommendation(rec));
729
+ console.log("");
730
+ }
731
+ }
732
+ catch {
733
+ /* doctor must never crash over an optional check */
734
+ }
680
735
  // ── CLAUDE.md hygiene ──
681
736
  try {
682
737
  const r = await assessClaudeMd(cwd);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * v0.26.4 — data-driven profile recommendation.
3
+ *
4
+ * The user mandate: existing users who don't read CHANGELOG shouldn't
5
+ * be left carrying the full 22-tool tools/list payload forever just
6
+ * because we were afraid of a breaking default change.
7
+ *
8
+ * Approach: the doctor command reads cumulative .token-pilot/tool-calls.jsonl
9
+ * (introduced in v0.26.2) and classifies the user's actual usage:
10
+ *
11
+ * - Every tool they used fits in NAV_TOOLS → recommend `nav`
12
+ * - Uses NAV + a subset of EDIT_EXTRAS → recommend `edit`
13
+ * - Uses full-only tools (test_summary,
14
+ * code_audit, find_unused, session_*) → recommend `full`
15
+ *
16
+ * The recommendation is *advisory*. We never auto-flip anyone's default;
17
+ * a user running through a rare but real tool like code_audit would
18
+ * lose it silently. Doctor prints the recommendation + the exact env
19
+ * snippet to paste into .mcp.json, and the copy nudges the user to act.
20
+ */
21
+ import type { ToolCallEvent } from "../core/tool-call-log.js";
22
+ import { type ToolProfile } from "./tool-profiles.js";
23
+ export interface ProfileRecommendation {
24
+ /** The profile we'd pick if the user asked "what fits me?". */
25
+ recommended: ToolProfile;
26
+ /** One-sentence explanation printable by doctor. */
27
+ reason: string;
28
+ /** Number of distinct tools the user has ever called. */
29
+ uniqueToolsSeen: number;
30
+ /** Total calls in the log (across all sessions/projects). */
31
+ totalCalls: number;
32
+ /** Tools that would be filtered out under `recommended`. Empty when
33
+ * recommended=full. */
34
+ wouldHide: string[];
35
+ /** True when we don't have enough data to claim more than "full". */
36
+ lowConfidence: boolean;
37
+ }
38
+ /**
39
+ * Pure analysis — takes raw tool-call events, returns a recommendation.
40
+ * Safe to unit-test without filesystem, network, or config.
41
+ */
42
+ export declare function recommendProfile(events: readonly ToolCallEvent[]): ProfileRecommendation;
43
+ /**
44
+ * Render a doctor-style multi-line block the caller can print verbatim.
45
+ * Pure.
46
+ */
47
+ export declare function formatRecommendation(rec: ProfileRecommendation): string;
48
+ //# sourceMappingURL=profile-recommender.d.ts.map
@@ -0,0 +1,102 @@
1
+ /**
2
+ * v0.26.4 — data-driven profile recommendation.
3
+ *
4
+ * The user mandate: existing users who don't read CHANGELOG shouldn't
5
+ * be left carrying the full 22-tool tools/list payload forever just
6
+ * because we were afraid of a breaking default change.
7
+ *
8
+ * Approach: the doctor command reads cumulative .token-pilot/tool-calls.jsonl
9
+ * (introduced in v0.26.2) and classifies the user's actual usage:
10
+ *
11
+ * - Every tool they used fits in NAV_TOOLS → recommend `nav`
12
+ * - Uses NAV + a subset of EDIT_EXTRAS → recommend `edit`
13
+ * - Uses full-only tools (test_summary,
14
+ * code_audit, find_unused, session_*) → recommend `full`
15
+ *
16
+ * The recommendation is *advisory*. We never auto-flip anyone's default;
17
+ * a user running through a rare but real tool like code_audit would
18
+ * lose it silently. Doctor prints the recommendation + the exact env
19
+ * snippet to paste into .mcp.json, and the copy nudges the user to act.
20
+ */
21
+ import { NAV_TOOLS, EDIT_EXTRAS } from "./tool-profiles.js";
22
+ /** Below this the sample is too small to make a confident recommendation. */
23
+ const MIN_SAMPLE_CALLS = 20;
24
+ /**
25
+ * Pure analysis — takes raw tool-call events, returns a recommendation.
26
+ * Safe to unit-test without filesystem, network, or config.
27
+ */
28
+ export function recommendProfile(events) {
29
+ const used = new Set();
30
+ for (const e of events)
31
+ used.add(e.tool);
32
+ const totalCalls = events.length;
33
+ const uniqueToolsSeen = used.size;
34
+ if (totalCalls < MIN_SAMPLE_CALLS) {
35
+ return {
36
+ recommended: "full",
37
+ reason: `Only ${totalCalls} call(s) logged — need ≥${MIN_SAMPLE_CALLS} to recommend a narrower profile.`,
38
+ uniqueToolsSeen,
39
+ totalCalls,
40
+ wouldHide: [],
41
+ lowConfidence: true,
42
+ };
43
+ }
44
+ const allInNav = [...used].every((t) => NAV_TOOLS.has(t));
45
+ if (allInNav) {
46
+ return {
47
+ recommended: "nav",
48
+ reason: `Every tool you've used (${uniqueToolsSeen} distinct) is part of the nav subset. You're a read-only explorer.`,
49
+ uniqueToolsSeen,
50
+ totalCalls,
51
+ wouldHide: [
52
+ ...[...EDIT_EXTRAS].filter((t) => !used.has(t)),
53
+ /* full-only — we don't enumerate here, keep the list short */
54
+ ],
55
+ lowConfidence: false,
56
+ };
57
+ }
58
+ const allInEditOrBelow = [...used].every((t) => NAV_TOOLS.has(t) || EDIT_EXTRAS.has(t));
59
+ if (allInEditOrBelow) {
60
+ return {
61
+ recommended: "edit",
62
+ reason: `You use edit-preparation tools (read_for_edit, batch reads) but never reach for full-only tools like code_audit/test_summary/find_unused.`,
63
+ uniqueToolsSeen,
64
+ totalCalls,
65
+ wouldHide: [],
66
+ lowConfidence: false,
67
+ };
68
+ }
69
+ // Uses at least one full-only tool — stay on full.
70
+ const fullOnlyUsed = [...used].filter((t) => !NAV_TOOLS.has(t) && !EDIT_EXTRAS.has(t));
71
+ return {
72
+ recommended: "full",
73
+ reason: `You actually use full-only tools (${fullOnlyUsed.slice(0, 3).join(", ")}${fullOnlyUsed.length > 3 ? ", …" : ""}). Don't trim.`,
74
+ uniqueToolsSeen,
75
+ totalCalls,
76
+ wouldHide: [],
77
+ lowConfidence: false,
78
+ };
79
+ }
80
+ /**
81
+ * Render a doctor-style multi-line block the caller can print verbatim.
82
+ * Pure.
83
+ */
84
+ export function formatRecommendation(rec) {
85
+ const lines = [];
86
+ lines.push(`── profile recommendation ──`);
87
+ lines.push(` data: ${rec.totalCalls} calls, ${rec.uniqueToolsSeen} distinct tools`);
88
+ lines.push(` recommend: TOKEN_PILOT_PROFILE=${rec.recommended}`);
89
+ lines.push(` why: ${rec.reason}`);
90
+ if (rec.recommended !== "full") {
91
+ lines.push(` savings: ~${rec.recommended === "nav" ? "2200 tokens (−54%)" : "1000 tokens (−25%)"} on every tools/list response`);
92
+ lines.push(` apply: add "env": { "TOKEN_PILOT_PROFILE": "${rec.recommended}" } to your token-pilot entry in .mcp.json`);
93
+ }
94
+ else if (rec.lowConfidence) {
95
+ lines.push(` action: keep default (full). Re-run \`token-pilot doctor\` after a few real sessions for a data-backed suggestion.`);
96
+ }
97
+ else {
98
+ lines.push(` action: keep default (full). You're using what you have.`);
99
+ }
100
+ return lines.join("\n");
101
+ }
102
+ //# sourceMappingURL=profile-recommender.js.map
@@ -2,8 +2,22 @@
2
2
  * Token estimation functions for analytics.
3
3
  * Used to calculate "tokens would be" for honest savings reporting.
4
4
  */
5
- import type { FileCache } from '../core/file-cache.js';
6
- import type { SavingsCategory } from '../core/session-analytics.js';
5
+ import type { FileCache } from "../core/file-cache.js";
6
+ import type { SavingsCategory } from "../core/session-analytics.js";
7
+ /**
8
+ * Honest savings classification for a tool's text output.
9
+ *
10
+ * Standalone (pure) so it can be unit-tested without spinning up the
11
+ * full token-estimates closure. Kept here because it's semantically
12
+ * tied to how this module reports wouldBe/returned pairs.
13
+ *
14
+ * v0.26.1 adds the 'none' branch: smart_read's small-file pass-through
15
+ * returns the file verbatim with a tiny header. Claiming wouldBe =
16
+ * fullFile for those calls was the root cause of the -2% "negative
17
+ * savings" line Opus 4.7 reported. With 'none', the recorder sets
18
+ * wouldBe = returned → 0% savings, no ghost overhead.
19
+ */
20
+ export declare function detectSavingsCategoryPure(text: string): SavingsCategory;
7
21
  /**
8
22
  * Creates token estimation functions bound to a project context.
9
23
  * Uses getter for projectRoot since it may change on auto-detect.
@@ -26,6 +40,6 @@ export declare function createTokenEstimates(getProjectRoot: () => string, fileC
26
40
  externalDeps?: string[];
27
41
  changeCount?: number;
28
42
  }) => Promise<number>;
29
- detectSavingsCategory: (text: string) => SavingsCategory;
43
+ detectSavingsCategory: typeof detectSavingsCategoryPure;
30
44
  };
31
45
  //# sourceMappingURL=token-estimates.d.ts.map