preflight-dev 3.1.0 → 3.2.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.
- package/README.md +77 -16
- package/dist/cli/init.js +0 -48
- package/dist/cli/init.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/contracts.d.ts +27 -0
- package/dist/lib/contracts.js +309 -0
- package/dist/lib/contracts.js.map +1 -0
- package/dist/lib/patterns.d.ts +38 -0
- package/dist/lib/patterns.js +176 -0
- package/dist/lib/patterns.js.map +1 -0
- package/dist/lib/triage.d.ts +2 -0
- package/dist/lib/triage.js.map +1 -1
- package/dist/profiles.js +4 -0
- package/dist/profiles.js.map +1 -1
- package/dist/tools/check-patterns.d.ts +2 -0
- package/dist/tools/check-patterns.js +33 -0
- package/dist/tools/check-patterns.js.map +1 -0
- package/dist/tools/clarify-intent.js +9 -1
- package/dist/tools/clarify-intent.js.map +1 -1
- package/dist/tools/enrich-agent-task.js +132 -3
- package/dist/tools/enrich-agent-task.js.map +1 -1
- package/dist/tools/estimate-cost.d.ts +2 -0
- package/dist/tools/estimate-cost.js +261 -0
- package/dist/tools/estimate-cost.js.map +1 -0
- package/dist/tools/generate-scorecard.js +466 -14
- package/dist/tools/generate-scorecard.js.map +1 -1
- package/dist/tools/log-correction.js +7 -1
- package/dist/tools/log-correction.js.map +1 -1
- package/dist/tools/onboard-project.js +10 -1
- package/dist/tools/onboard-project.js.map +1 -1
- package/dist/tools/preflight-check.js +16 -0
- package/dist/tools/preflight-check.js.map +1 -1
- package/dist/tools/scope-work.js +6 -0
- package/dist/tools/scope-work.js.map +1 -1
- package/dist/tools/search-contracts.d.ts +2 -0
- package/dist/tools/search-contracts.js +46 -0
- package/dist/tools/search-contracts.js.map +1 -0
- package/dist/tools/session-stats.js +2 -0
- package/dist/tools/session-stats.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +7 -0
- package/src/lib/contracts.ts +354 -0
- package/src/lib/patterns.ts +210 -0
- package/src/lib/triage.ts +2 -0
- package/src/profiles.ts +4 -0
- package/src/tools/check-patterns.ts +43 -0
- package/src/tools/clarify-intent.ts +10 -1
- package/src/tools/enrich-agent-task.ts +150 -3
- package/src/tools/estimate-cost.ts +332 -0
- package/src/tools/generate-scorecard.ts +541 -14
- package/src/tools/log-correction.ts +8 -1
- package/src/tools/onboard-project.ts +10 -1
- package/src/tools/preflight-check.ts +19 -0
- package/src/tools/scope-work.ts +7 -0
- package/src/tools/search-contracts.ts +61 -0
- package/src/tools/session-stats.ts +2 -0
|
@@ -2,8 +2,11 @@ import { z } from "zod";
|
|
|
2
2
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { run, getDiffFiles } from "../lib/git.js";
|
|
4
4
|
import { PROJECT_DIR } from "../lib/files.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { getConfig, type RelatedProject } from "../lib/config.js";
|
|
6
|
+
import { existsSync, readFileSync } from "fs";
|
|
7
|
+
import { execFileSync } from "child_process";
|
|
8
|
+
import { join, basename } from "path";
|
|
9
|
+
import { createHash } from "crypto";
|
|
7
10
|
|
|
8
11
|
/** Sanitize user input for safe use in shell commands */
|
|
9
12
|
function shellEscape(s: string): string {
|
|
@@ -53,6 +56,148 @@ function getExamplePattern(files: string): string {
|
|
|
53
56
|
return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`);
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Cross-service awareness helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
interface ContractEntry {
|
|
64
|
+
name: string;
|
|
65
|
+
kind: string; // e.g. "interface", "enum", "function", "type"
|
|
66
|
+
file: string;
|
|
67
|
+
summary?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ContractFile {
|
|
71
|
+
entries?: ContractEntry[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Extract meaningful keywords from a task description */
|
|
75
|
+
function extractKeywords(text: string): string[] {
|
|
76
|
+
const stopWords = new Set([
|
|
77
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
78
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
79
|
+
"should", "may", "might", "shall", "can", "need", "must", "to", "of",
|
|
80
|
+
"in", "for", "on", "with", "at", "by", "from", "as", "into", "about",
|
|
81
|
+
"that", "this", "it", "and", "or", "but", "not", "if", "then", "else",
|
|
82
|
+
"when", "up", "out", "so", "no", "all", "any", "each", "every",
|
|
83
|
+
"add", "create", "update", "delete", "remove", "fix", "implement",
|
|
84
|
+
"make", "change", "modify", "file", "files", "code", "test", "tests",
|
|
85
|
+
]);
|
|
86
|
+
return text
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
89
|
+
.split(/\s+/)
|
|
90
|
+
.filter(w => w.length > 2 && !stopWords.has(w));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Hash a project path the same way preflight indexes projects */
|
|
94
|
+
function projectHash(projectPath: string): string {
|
|
95
|
+
return createHash("sha256").update(projectPath).digest("hex").slice(0, 12);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Load contracts for a project if they exist */
|
|
99
|
+
function loadContracts(projectPath: string): ContractEntry[] {
|
|
100
|
+
const hash = projectHash(projectPath);
|
|
101
|
+
const homedir = process.env.HOME || process.env.USERPROFILE || "";
|
|
102
|
+
const contractsPath = join(homedir, ".preflight", "projects", hash, "contracts.json");
|
|
103
|
+
if (!existsSync(contractsPath)) return [];
|
|
104
|
+
try {
|
|
105
|
+
const data = JSON.parse(readFileSync(contractsPath, "utf-8")) as ContractFile;
|
|
106
|
+
return data.entries ?? [];
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Search git-tracked files in a related project for keyword matches */
|
|
113
|
+
function searchRelatedProjectFiles(projectPath: string, keywords: string[]): string[] {
|
|
114
|
+
if (!existsSync(projectPath)) return [];
|
|
115
|
+
try {
|
|
116
|
+
const allFiles = execFileSync("git", ["ls-files"], {
|
|
117
|
+
cwd: projectPath,
|
|
118
|
+
encoding: "utf-8",
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
121
|
+
}).trim();
|
|
122
|
+
if (!allFiles) return [];
|
|
123
|
+
const fileList = allFiles.split("\n");
|
|
124
|
+
const matches: string[] = [];
|
|
125
|
+
for (const f of fileList) {
|
|
126
|
+
const lower = f.toLowerCase();
|
|
127
|
+
if (keywords.some(kw => lower.includes(kw))) {
|
|
128
|
+
matches.push(f);
|
|
129
|
+
if (matches.length >= 10) break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return matches;
|
|
133
|
+
} catch {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build cross-service context string for related projects */
|
|
139
|
+
function buildCrossServiceContext(taskDescription: string): string {
|
|
140
|
+
let relatedProjects: RelatedProject[];
|
|
141
|
+
try {
|
|
142
|
+
relatedProjects = getConfig().related_projects;
|
|
143
|
+
} catch {
|
|
144
|
+
relatedProjects = [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fallback to env var if no config-based projects
|
|
148
|
+
if (relatedProjects.length === 0) {
|
|
149
|
+
const envRelated = process.env.PREFLIGHT_RELATED;
|
|
150
|
+
if (envRelated) {
|
|
151
|
+
relatedProjects = envRelated
|
|
152
|
+
.split(",")
|
|
153
|
+
.map(p => p.trim())
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.map(p => ({ path: p, alias: basename(p) }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (relatedProjects.length === 0) return "";
|
|
160
|
+
|
|
161
|
+
const keywords = extractKeywords(taskDescription);
|
|
162
|
+
if (keywords.length === 0) return "";
|
|
163
|
+
|
|
164
|
+
const sections: string[] = [];
|
|
165
|
+
|
|
166
|
+
for (const project of relatedProjects) {
|
|
167
|
+
const items: string[] = [];
|
|
168
|
+
|
|
169
|
+
// Search contracts
|
|
170
|
+
const contracts = loadContracts(project.path);
|
|
171
|
+
for (const entry of contracts) {
|
|
172
|
+
const nameLower = entry.name.toLowerCase();
|
|
173
|
+
const fileLower = (entry.file || "").toLowerCase();
|
|
174
|
+
const summaryLower = (entry.summary || "").toLowerCase();
|
|
175
|
+
if (keywords.some(kw => nameLower.includes(kw) || fileLower.includes(kw) || summaryLower.includes(kw))) {
|
|
176
|
+
const label = entry.kind ? `${entry.kind} ${entry.name}` : entry.name;
|
|
177
|
+
items.push(` - ${label}${entry.file ? ` (${entry.file})` : ""}`);
|
|
178
|
+
if (items.length >= 8) break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Search files
|
|
183
|
+
const matchedFiles = searchRelatedProjectFiles(project.path, keywords);
|
|
184
|
+
for (const f of matchedFiles) {
|
|
185
|
+
const already = items.some(i => i.includes(f));
|
|
186
|
+
if (!already) {
|
|
187
|
+
items.push(` - file: ${f}`);
|
|
188
|
+
if (items.length >= 12) break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (items.length > 0) {
|
|
193
|
+
sections.push(`From ${project.alias}:\n${items.join("\n")}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (sections.length === 0) return "";
|
|
198
|
+
return `\n\n📡 Cross-service context:\n${sections.join("\n\n")}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
56
201
|
export function registerEnrichAgentTask(server: McpServer): void {
|
|
57
202
|
server.tool(
|
|
58
203
|
"enrich_agent_task",
|
|
@@ -68,6 +213,8 @@ export function registerEnrichAgentTask(server: McpServer): void {
|
|
|
68
213
|
const testFiles = findRelatedTests(area);
|
|
69
214
|
const pattern = getExamplePattern(area.includes("test") ? testFiles : fileList);
|
|
70
215
|
|
|
216
|
+
const crossServiceContext = buildCrossServiceContext(task_description);
|
|
217
|
+
|
|
71
218
|
const fileSummary = fileList
|
|
72
219
|
? fileList.split("\n").filter(Boolean).slice(0, 5).join(", ")
|
|
73
220
|
: "Specify exact files";
|
|
@@ -100,7 +247,7 @@ Original: "${task_description}"
|
|
|
100
247
|
- **Pattern**: Follow existing pattern above
|
|
101
248
|
- **Tests**: ${testSummary}
|
|
102
249
|
- **Scope**: Do NOT modify files outside target area
|
|
103
|
-
- **Done when**: All relevant tests pass + \`${pm} tsc --noEmit\` clean`,
|
|
250
|
+
- **Done when**: All relevant tests pass + \`${pm} tsc --noEmit\` clean${crossServiceContext}`,
|
|
104
251
|
}],
|
|
105
252
|
};
|
|
106
253
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// estimate_cost — Estimate token usage and cost for a Claude Code session
|
|
3
|
+
// =============================================================================
|
|
4
|
+
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
import { findSessionDirs, findSessionFiles } from "../lib/session-parser.js";
|
|
10
|
+
|
|
11
|
+
// ── Pricing (per 1M tokens) ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const PRICING: Record<string, { input: number; output: number }> = {
|
|
14
|
+
"claude-sonnet-4": { input: 3.0, output: 15.0 },
|
|
15
|
+
"claude-opus-4": { input: 15.0, output: 75.0 },
|
|
16
|
+
"claude-haiku-3.5": { input: 0.8, output: 4.0 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL = "claude-sonnet-4";
|
|
20
|
+
|
|
21
|
+
const CORRECTION_SIGNALS = /\b(no[,.\s]|wrong|not that|i meant|actually|try again|revert|undo|that's not|not what i)\b/i;
|
|
22
|
+
|
|
23
|
+
const PREFLIGHT_TOOLS = new Set([
|
|
24
|
+
"preflight_check",
|
|
25
|
+
"clarify_intent",
|
|
26
|
+
"scope_work",
|
|
27
|
+
"sharpen_followup",
|
|
28
|
+
"token_audit",
|
|
29
|
+
"prompt_score",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function estimateTokens(text: string): number {
|
|
35
|
+
return Math.ceil(text.length / 4);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractText(content: unknown): string {
|
|
39
|
+
if (typeof content === "string") return content;
|
|
40
|
+
if (Array.isArray(content)) {
|
|
41
|
+
return content
|
|
42
|
+
.filter((b: any) => typeof b.text === "string")
|
|
43
|
+
.map((b: any) => b.text)
|
|
44
|
+
.join("\n");
|
|
45
|
+
}
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractToolNames(content: unknown): string[] {
|
|
50
|
+
if (!Array.isArray(content)) return [];
|
|
51
|
+
return content
|
|
52
|
+
.filter((b: any) => b.type === "tool_use" && b.name)
|
|
53
|
+
.map((b: any) => b.name as string);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatTokens(n: number): string {
|
|
57
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
58
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
59
|
+
return String(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatCost(dollars: number): string {
|
|
63
|
+
if (dollars < 0.01) return `<$0.01`;
|
|
64
|
+
return `$${dollars.toFixed(2)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatDuration(ms: number): string {
|
|
68
|
+
const mins = Math.floor(ms / 60_000);
|
|
69
|
+
if (mins < 60) return `${mins}m`;
|
|
70
|
+
const hours = Math.floor(mins / 60);
|
|
71
|
+
const rem = mins % 60;
|
|
72
|
+
return `${hours}h ${rem}m`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SessionAnalysis {
|
|
76
|
+
inputTokens: number;
|
|
77
|
+
outputTokens: number;
|
|
78
|
+
promptCount: number;
|
|
79
|
+
toolCallCount: number;
|
|
80
|
+
corrections: number;
|
|
81
|
+
wastedOutputTokens: number;
|
|
82
|
+
preflightCalls: number;
|
|
83
|
+
preflightTokens: number;
|
|
84
|
+
firstTimestamp: string | null;
|
|
85
|
+
lastTimestamp: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function analyzeSessionFile(filePath: string): SessionAnalysis {
|
|
89
|
+
const content = readFileSync(filePath, "utf-8");
|
|
90
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
91
|
+
|
|
92
|
+
const result: SessionAnalysis = {
|
|
93
|
+
inputTokens: 0,
|
|
94
|
+
outputTokens: 0,
|
|
95
|
+
promptCount: 0,
|
|
96
|
+
toolCallCount: 0,
|
|
97
|
+
corrections: 0,
|
|
98
|
+
wastedOutputTokens: 0,
|
|
99
|
+
preflightCalls: 0,
|
|
100
|
+
preflightTokens: 0,
|
|
101
|
+
firstTimestamp: null,
|
|
102
|
+
lastTimestamp: null,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let lastType = "";
|
|
106
|
+
let lastAssistantTokens = 0;
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
let obj: any;
|
|
110
|
+
try {
|
|
111
|
+
obj = JSON.parse(line);
|
|
112
|
+
} catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Track timestamps
|
|
117
|
+
const ts = obj.timestamp;
|
|
118
|
+
if (ts) {
|
|
119
|
+
const tsStr = typeof ts === "string" ? ts : new Date(ts < 1e12 ? ts * 1000 : ts).toISOString();
|
|
120
|
+
if (!result.firstTimestamp) result.firstTimestamp = tsStr;
|
|
121
|
+
result.lastTimestamp = tsStr;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (obj.type === "user") {
|
|
125
|
+
const text = extractText(obj.message?.content);
|
|
126
|
+
const tokens = estimateTokens(text);
|
|
127
|
+
result.inputTokens += tokens;
|
|
128
|
+
result.promptCount++;
|
|
129
|
+
|
|
130
|
+
// Correction detection
|
|
131
|
+
if (lastType === "assistant" && CORRECTION_SIGNALS.test(text)) {
|
|
132
|
+
result.corrections++;
|
|
133
|
+
result.wastedOutputTokens += lastAssistantTokens;
|
|
134
|
+
}
|
|
135
|
+
lastType = "user";
|
|
136
|
+
} else if (obj.type === "assistant") {
|
|
137
|
+
const msgContent = obj.message?.content;
|
|
138
|
+
const text = extractText(msgContent);
|
|
139
|
+
const tokens = estimateTokens(text);
|
|
140
|
+
result.outputTokens += tokens;
|
|
141
|
+
lastAssistantTokens = tokens;
|
|
142
|
+
|
|
143
|
+
// Tool calls
|
|
144
|
+
const toolNames = extractToolNames(msgContent);
|
|
145
|
+
result.toolCallCount += toolNames.length;
|
|
146
|
+
|
|
147
|
+
for (const name of toolNames) {
|
|
148
|
+
if (PREFLIGHT_TOOLS.has(name)) {
|
|
149
|
+
result.preflightCalls++;
|
|
150
|
+
// Estimate tool call tokens (name + args)
|
|
151
|
+
const toolBlocks = (msgContent as any[]).filter(
|
|
152
|
+
(b: any) => b.type === "tool_use" && b.name === name,
|
|
153
|
+
);
|
|
154
|
+
for (const tb of toolBlocks) {
|
|
155
|
+
result.preflightTokens += estimateTokens(
|
|
156
|
+
JSON.stringify(tb.input ?? {}),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
lastType = "assistant";
|
|
162
|
+
} else if (obj.type === "tool_result") {
|
|
163
|
+
const text = extractText(obj.content);
|
|
164
|
+
const tokens = estimateTokens(text);
|
|
165
|
+
result.inputTokens += tokens;
|
|
166
|
+
|
|
167
|
+
// Check if this is a preflight tool result
|
|
168
|
+
if (obj.tool_use_id) {
|
|
169
|
+
// We can't perfectly match tool_use_id to name, so count tokens as preflight
|
|
170
|
+
// if they're small (typical preflight responses)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Registration ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export function registerEstimateCost(server: McpServer): void {
|
|
181
|
+
server.tool(
|
|
182
|
+
"estimate_cost",
|
|
183
|
+
"Estimate token usage and cost for the current session. Shows waste from vague prompts and savings from preflight checks.",
|
|
184
|
+
{
|
|
185
|
+
session_dir: z
|
|
186
|
+
.string()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe("Path to session JSONL file (optional, uses latest if omitted)"),
|
|
189
|
+
model: z
|
|
190
|
+
.string()
|
|
191
|
+
.optional()
|
|
192
|
+
.describe(
|
|
193
|
+
"Pricing model to use (claude-sonnet-4, claude-opus-4, claude-haiku-3.5). Default: claude-sonnet-4",
|
|
194
|
+
),
|
|
195
|
+
},
|
|
196
|
+
async ({ session_dir, model }) => {
|
|
197
|
+
const pricingModel = model && PRICING[model] ? model : DEFAULT_MODEL;
|
|
198
|
+
const pricing = PRICING[pricingModel]!;
|
|
199
|
+
|
|
200
|
+
// Find session file
|
|
201
|
+
let filePath: string;
|
|
202
|
+
if (session_dir) {
|
|
203
|
+
filePath = session_dir;
|
|
204
|
+
} else {
|
|
205
|
+
// Find latest session file
|
|
206
|
+
const dirs = findSessionDirs();
|
|
207
|
+
let latest: { path: string; mtime: Date } | null = null;
|
|
208
|
+
for (const dir of dirs) {
|
|
209
|
+
const files = findSessionFiles(dir.sessionDir);
|
|
210
|
+
for (const f of files) {
|
|
211
|
+
if (!latest || f.mtime > latest.mtime) {
|
|
212
|
+
latest = f;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!latest) {
|
|
217
|
+
return {
|
|
218
|
+
content: [
|
|
219
|
+
{
|
|
220
|
+
type: "text" as const,
|
|
221
|
+
text: "No session files found in ~/.claude/projects/",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
filePath = latest.path;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Verify file exists
|
|
230
|
+
try {
|
|
231
|
+
statSync(filePath);
|
|
232
|
+
} catch {
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: "text" as const,
|
|
237
|
+
text: `Session file not found: ${filePath}`,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const analysis = analyzeSessionFile(filePath);
|
|
244
|
+
const totalTokens = analysis.inputTokens + analysis.outputTokens;
|
|
245
|
+
const inputCost = (analysis.inputTokens / 1_000_000) * pricing.input;
|
|
246
|
+
const outputCost = (analysis.outputTokens / 1_000_000) * pricing.output;
|
|
247
|
+
const totalCost = inputCost + outputCost;
|
|
248
|
+
const wasteCost =
|
|
249
|
+
(analysis.wastedOutputTokens / 1_000_000) * pricing.output;
|
|
250
|
+
const wastePercent =
|
|
251
|
+
totalCost > 0 ? ((wasteCost / totalCost) * 100).toFixed(1) : "0";
|
|
252
|
+
|
|
253
|
+
// Duration
|
|
254
|
+
let durationStr = "unknown";
|
|
255
|
+
if (analysis.firstTimestamp && analysis.lastTimestamp) {
|
|
256
|
+
const ms =
|
|
257
|
+
new Date(analysis.lastTimestamp).getTime() -
|
|
258
|
+
new Date(analysis.firstTimestamp).getTime();
|
|
259
|
+
if (ms > 0) durationStr = formatDuration(ms);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Preflight impact
|
|
263
|
+
const preflightCost =
|
|
264
|
+
(analysis.preflightTokens / 1_000_000) * pricing.input;
|
|
265
|
+
// Estimate: each preflight check prevents ~0.5 corrections on average
|
|
266
|
+
const estimatedPrevented = Math.round(analysis.preflightCalls * 0.5);
|
|
267
|
+
// Average wasted tokens per correction
|
|
268
|
+
const avgWastePerCorrection =
|
|
269
|
+
analysis.corrections > 0
|
|
270
|
+
? analysis.wastedOutputTokens / analysis.corrections
|
|
271
|
+
: 500;
|
|
272
|
+
const estimatedSavingsTokens = estimatedPrevented * avgWastePerCorrection;
|
|
273
|
+
const estimatedSavingsCost =
|
|
274
|
+
(estimatedSavingsTokens / 1_000_000) * pricing.output;
|
|
275
|
+
|
|
276
|
+
// Build report
|
|
277
|
+
const lines: string[] = [
|
|
278
|
+
`📊 Session Cost Estimate`,
|
|
279
|
+
`━━━━━━━━━━━━━━━━━━━━━━`,
|
|
280
|
+
`Duration: ${durationStr} | ${analysis.promptCount} prompts | ${analysis.toolCallCount} tool calls`,
|
|
281
|
+
`File: ${basename(filePath)}`,
|
|
282
|
+
``,
|
|
283
|
+
`Token Usage (estimated):`,
|
|
284
|
+
` Input: ~${formatTokens(analysis.inputTokens)} tokens`,
|
|
285
|
+
` Output: ~${formatTokens(analysis.outputTokens)} tokens`,
|
|
286
|
+
` Total: ~${formatTokens(totalTokens)} tokens`,
|
|
287
|
+
``,
|
|
288
|
+
`Estimated Cost: ~${formatCost(totalCost)} (${pricingModel.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())})`,
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
if (analysis.corrections > 0) {
|
|
292
|
+
lines.push(
|
|
293
|
+
``,
|
|
294
|
+
`Waste Analysis:`,
|
|
295
|
+
` Corrections detected: ${analysis.corrections}`,
|
|
296
|
+
` Wasted output tokens: ~${formatTokens(analysis.wastedOutputTokens)}`,
|
|
297
|
+
` Estimated waste: ~${formatCost(wasteCost)} (${wastePercent}% of total)`,
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
lines.push(``, `Waste Analysis:`, ` No corrections detected 🎯`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (analysis.preflightCalls > 0) {
|
|
304
|
+
lines.push(
|
|
305
|
+
``,
|
|
306
|
+
`Preflight Impact:`,
|
|
307
|
+
` Preflight checks: ${analysis.preflightCalls} calls (~${formatTokens(analysis.preflightTokens)} tokens)`,
|
|
308
|
+
` Preflight cost: ~${formatCost(preflightCost)}`,
|
|
309
|
+
);
|
|
310
|
+
if (estimatedPrevented > 0) {
|
|
311
|
+
lines.push(
|
|
312
|
+
` Estimated corrections prevented: ${estimatedPrevented}`,
|
|
313
|
+
` Estimated savings: ~${formatCost(estimatedSavingsCost)}`,
|
|
314
|
+
``,
|
|
315
|
+
`💡 Net benefit: preflight saved ~${formatCost(estimatedSavingsCost - preflightCost)} this session`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
lines.push(
|
|
320
|
+
``,
|
|
321
|
+
`Preflight Impact:`,
|
|
322
|
+
` No preflight checks used this session`,
|
|
323
|
+
` 💡 Tip: Use preflight_check to catch issues before they cost tokens`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
329
|
+
};
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
}
|