prjct-cli 0.44.1 → 0.45.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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Summary Tool - Intelligent file summarization
3
+ *
4
+ * Combines:
5
+ * - Code signatures (public API)
6
+ * - JSDoc/docstring extraction
7
+ * - Key dependencies
8
+ *
9
+ * Achieves high compression by returning only public-facing elements.
10
+ *
11
+ * @module context-tools/summary-tool
12
+ * @version 1.0.0
13
+ */
14
+
15
+ import fs from 'fs/promises'
16
+ import path from 'path'
17
+ import type { SummaryToolOutput, PublicAPIEntry, SignatureType } from './types'
18
+ import { measureCompression, noCompression, combineMetrics } from './token-counter'
19
+ import { extractSignatures } from './signatures-tool'
20
+ import { analyzeImports } from './imports-tool'
21
+ import { isNotFoundError } from '../types/fs'
22
+
23
+ // =============================================================================
24
+ // Docstring Patterns
25
+ // =============================================================================
26
+
27
+ interface DocstringPattern {
28
+ start: RegExp
29
+ end: RegExp | null
30
+ singleLine?: boolean
31
+ }
32
+
33
+ /**
34
+ * Docstring patterns by language
35
+ */
36
+ const DOCSTRING_PATTERNS: Record<string, DocstringPattern[]> = {
37
+ typescript: [
38
+ { start: /\/\*\*/, end: /\*\// }, // JSDoc
39
+ { start: /\/\/\//, end: null, singleLine: true }, // Triple-slash
40
+ ],
41
+ javascript: [
42
+ { start: /\/\*\*/, end: /\*\// }, // JSDoc
43
+ ],
44
+ python: [
45
+ { start: /"""/, end: /"""/ }, // Triple quotes
46
+ { start: /'''/, end: /'''/ }, // Single quotes
47
+ ],
48
+ go: [
49
+ { start: /\/\//, end: null, singleLine: true }, // Line comment (Go uses these as docs)
50
+ ],
51
+ rust: [
52
+ { start: /\/\/\//, end: null, singleLine: true }, // Doc comment
53
+ { start: /\/\/!/, end: null, singleLine: true }, // Inner doc comment
54
+ ],
55
+ }
56
+
57
+ /**
58
+ * Extension to language mapping
59
+ */
60
+ const EXT_TO_LANG: Record<string, string> = {
61
+ '.ts': 'typescript',
62
+ '.tsx': 'typescript',
63
+ '.js': 'javascript',
64
+ '.jsx': 'javascript',
65
+ '.mjs': 'javascript',
66
+ '.py': 'python',
67
+ '.go': 'go',
68
+ '.rs': 'rust',
69
+ }
70
+
71
+ // =============================================================================
72
+ // Main Functions
73
+ // =============================================================================
74
+
75
+ /**
76
+ * Generate an intelligent summary of a file
77
+ *
78
+ * @param filePath - Path to the file
79
+ * @param projectPath - Project root path
80
+ * @returns Summary with public API and metrics
81
+ */
82
+ export async function summarizeFile(
83
+ filePath: string,
84
+ projectPath: string = process.cwd()
85
+ ): Promise<SummaryToolOutput> {
86
+ const absolutePath = path.isAbsolute(filePath)
87
+ ? filePath
88
+ : path.join(projectPath, filePath)
89
+
90
+ // Read file content
91
+ let content: string
92
+ try {
93
+ content = await fs.readFile(absolutePath, 'utf-8')
94
+ } catch (error) {
95
+ if (isNotFoundError(error)) {
96
+ return {
97
+ file: filePath,
98
+ purpose: 'File not found',
99
+ publicAPI: [],
100
+ dependencies: [],
101
+ metrics: noCompression(''),
102
+ }
103
+ }
104
+ throw error
105
+ }
106
+
107
+ // Get language
108
+ const ext = path.extname(filePath).toLowerCase()
109
+ const language = EXT_TO_LANG[ext] || 'unknown'
110
+
111
+ // Extract signatures
112
+ const signaturesResult = await extractSignatures(filePath, projectPath)
113
+
114
+ // Extract imports for dependencies
115
+ const importsResult = await analyzeImports(filePath, projectPath)
116
+
117
+ // Extract file-level docstring (purpose)
118
+ const purpose = extractFilePurpose(content, language)
119
+
120
+ // Build public API from exported signatures
121
+ const publicAPI: PublicAPIEntry[] = signaturesResult.signatures
122
+ .filter((sig) => sig.exported)
123
+ .map((sig) => ({
124
+ name: sig.name,
125
+ type: sig.type,
126
+ signature: sig.signature,
127
+ description: sig.docstring
128
+ ? extractDescriptionFromDocstring(sig.docstring)
129
+ : undefined,
130
+ }))
131
+
132
+ // Get key dependencies (internal only, external are obvious from package.json)
133
+ const dependencies = importsResult.imports
134
+ .filter((imp) => !imp.isExternal && imp.resolved)
135
+ .map((imp) => imp.resolved!)
136
+ .slice(0, 10) // Limit to 10
137
+
138
+ // Build summary content for metrics
139
+ const summaryContent = buildSummaryText(purpose, publicAPI, dependencies)
140
+
141
+ return {
142
+ file: filePath,
143
+ purpose,
144
+ publicAPI,
145
+ dependencies,
146
+ metrics: measureCompression(content, summaryContent),
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Summarize all files in a directory
152
+ */
153
+ export async function summarizeDirectory(
154
+ dirPath: string,
155
+ projectPath: string = process.cwd(),
156
+ options: { recursive?: boolean } = {}
157
+ ): Promise<SummaryToolOutput[]> {
158
+ const absolutePath = path.isAbsolute(dirPath)
159
+ ? dirPath
160
+ : path.join(projectPath, dirPath)
161
+
162
+ const results: SummaryToolOutput[] = []
163
+
164
+ async function processDir(dir: string): Promise<void> {
165
+ const entries = await fs.readdir(dir, { withFileTypes: true })
166
+
167
+ for (const entry of entries) {
168
+ const fullPath = path.join(dir, entry.name)
169
+ const relativePath = path.relative(projectPath, fullPath)
170
+
171
+ if (entry.isDirectory()) {
172
+ // Skip common ignore patterns
173
+ if (
174
+ entry.name === 'node_modules' ||
175
+ entry.name === '.git' ||
176
+ entry.name.startsWith('.')
177
+ ) {
178
+ continue
179
+ }
180
+ if (options.recursive) {
181
+ await processDir(fullPath)
182
+ }
183
+ } else if (entry.isFile()) {
184
+ const ext = path.extname(entry.name).toLowerCase()
185
+ if (EXT_TO_LANG[ext]) {
186
+ const result = await summarizeFile(relativePath, projectPath)
187
+ results.push(result)
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ await processDir(absolutePath)
194
+ return results
195
+ }
196
+
197
+ // =============================================================================
198
+ // Helper Functions
199
+ // =============================================================================
200
+
201
+ /**
202
+ * Extract file-level purpose from first docstring
203
+ */
204
+ function extractFilePurpose(content: string, language: string): string {
205
+ const patterns = DOCSTRING_PATTERNS[language] || []
206
+ const lines = content.split('\n')
207
+
208
+ // Look for a file-level docstring in first 30 lines
209
+ for (let i = 0; i < Math.min(30, lines.length); i++) {
210
+ const line = lines[i].trim()
211
+
212
+ for (const pattern of patterns) {
213
+ if (pattern.start.test(line)) {
214
+ if (pattern.singleLine) {
215
+ // Single-line comment - grab consecutive lines
216
+ const commentLines: string[] = []
217
+ let j = i
218
+ while (j < lines.length && pattern.start.test(lines[j].trim())) {
219
+ commentLines.push(lines[j].trim().replace(pattern.start, '').trim())
220
+ j++
221
+ }
222
+ if (commentLines.length > 0) {
223
+ return commentLines.slice(0, 3).join(' ').trim()
224
+ }
225
+ } else if (pattern.end) {
226
+ // Multi-line comment - extract until end
227
+ let comment = ''
228
+ let j = i
229
+ while (j < lines.length) {
230
+ comment += lines[j] + '\n'
231
+ if (pattern.end.test(lines[j])) break
232
+ j++
233
+ }
234
+ // Extract first meaningful line
235
+ const meaningfulLines = comment
236
+ .replace(pattern.start, '')
237
+ .replace(pattern.end!, '')
238
+ .split('\n')
239
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
240
+ .filter((l) => l.length > 0 && !l.startsWith('@'))
241
+ if (meaningfulLines.length > 0) {
242
+ return meaningfulLines.slice(0, 2).join(' ').trim()
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ // Stop if we hit code (not comments or empty lines)
249
+ if (line.length > 0 && !line.startsWith('//') && !line.startsWith('#') && !line.startsWith('/*') && !line.startsWith('*') && !line.startsWith("'") && !line.startsWith('"')) {
250
+ break
251
+ }
252
+ }
253
+
254
+ // Fallback: derive from filename
255
+ const fileName = content.split('\n')[0] || ''
256
+ return `Module: ${path.basename(fileName, path.extname(fileName))}`
257
+ }
258
+
259
+ /**
260
+ * Extract description from a docstring line
261
+ */
262
+ function extractDescriptionFromDocstring(docstring: string): string {
263
+ // Remove comment markers and clean up
264
+ return docstring
265
+ .replace(/^\/\*\*\s*/, '')
266
+ .replace(/\*\/$/, '')
267
+ .replace(/^\/\/\/?\s*/, '')
268
+ .replace(/^#\s*/, '')
269
+ .replace(/^"""\s*/, '')
270
+ .replace(/"""\s*$/, '')
271
+ .trim()
272
+ .split('\n')[0] // First line only
273
+ .trim()
274
+ }
275
+
276
+ /**
277
+ * Build summary text for metrics calculation
278
+ */
279
+ function buildSummaryText(
280
+ purpose: string,
281
+ publicAPI: PublicAPIEntry[],
282
+ dependencies: string[]
283
+ ): string {
284
+ const parts: string[] = []
285
+
286
+ parts.push(`Purpose: ${purpose}`)
287
+ parts.push('')
288
+
289
+ if (publicAPI.length > 0) {
290
+ parts.push('Public API:')
291
+ for (const entry of publicAPI) {
292
+ const desc = entry.description ? ` - ${entry.description}` : ''
293
+ parts.push(` ${entry.type} ${entry.name}: ${entry.signature}${desc}`)
294
+ }
295
+ parts.push('')
296
+ }
297
+
298
+ if (dependencies.length > 0) {
299
+ parts.push(`Dependencies: ${dependencies.join(', ')}`)
300
+ }
301
+
302
+ return parts.join('\n')
303
+ }
304
+
305
+ // =============================================================================
306
+ // Exports
307
+ // =============================================================================
308
+
309
+ export default { summarizeFile, summarizeDirectory }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Token Counter - REAL token measurement for context tools
3
+ *
4
+ * Provides accurate token estimation for measuring compression rates.
5
+ * Uses industry-standard approximation: ~4 characters per token.
6
+ *
7
+ * This is critical for the Value Dashboard to show REAL savings,
8
+ * not estimated ones.
9
+ *
10
+ * @module context-tools/token-counter
11
+ * @version 1.0.0
12
+ */
13
+
14
+ import type { TokenMetrics } from './types'
15
+
16
+ // =============================================================================
17
+ // Constants
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Average characters per token
22
+ *
23
+ * Based on empirical analysis of Claude/GPT tokenizers:
24
+ * - Code averages ~3.5-4.5 chars/token
25
+ * - English text averages ~4-5 chars/token
26
+ * - We use 4 as a conservative middle ground
27
+ */
28
+ const CHARS_PER_TOKEN = 4
29
+
30
+ /**
31
+ * Model pricing per 1000 tokens (January 2026)
32
+ * Sources:
33
+ * - Anthropic: https://docs.anthropic.com/en/docs/about-claude/models
34
+ * - OpenAI: https://openai.com/pricing
35
+ * - Google: https://ai.google.dev/pricing
36
+ */
37
+ const MODEL_PRICING = {
38
+ // Anthropic Claude (2026)
39
+ 'claude-opus-4.5': { input: 0.005, output: 0.025 }, // $5/$25 per M
40
+ 'claude-sonnet-4.5': { input: 0.003, output: 0.015 }, // $3/$15 per M
41
+ 'claude-haiku-4.5': { input: 0.001, output: 0.005 }, // $1/$5 per M
42
+ 'claude-opus-4': { input: 0.015, output: 0.075 }, // $15/$75 per M (legacy)
43
+ // OpenAI
44
+ 'gpt-4o': { input: 0.0025, output: 0.01 }, // $2.50/$10 per M
45
+ 'gpt-4-turbo': { input: 0.01, output: 0.03 }, // $10/$30 per M
46
+ 'gpt-4o-mini': { input: 0.00015, output: 0.0006 }, // $0.15/$0.60 per M
47
+ // Google
48
+ 'gemini-1.5-pro': { input: 0.00125, output: 0.005 }, // $1.25/$5 per M
49
+ 'gemini-1.5-flash': { input: 0.000075, output: 0.0003 }, // $0.075/$0.30 per M
50
+ } as const
51
+
52
+ type ModelName = keyof typeof MODEL_PRICING
53
+
54
+ // Default model for cost calculations
55
+ const DEFAULT_MODEL: ModelName = 'claude-sonnet-4.5'
56
+
57
+ // =============================================================================
58
+ // Core Functions
59
+ // =============================================================================
60
+
61
+ /**
62
+ * Count tokens in a text string
63
+ *
64
+ * Uses character-based estimation that's accurate enough for
65
+ * measuring compression rates without requiring actual tokenizer.
66
+ *
67
+ * @param text - The text to count tokens for
68
+ * @returns Estimated token count
69
+ */
70
+ export function countTokens(text: string): number {
71
+ if (!text || text.length === 0) return 0
72
+ return Math.ceil(text.length / CHARS_PER_TOKEN)
73
+ }
74
+
75
+ /**
76
+ * Models to show in cost breakdown (most popular)
77
+ */
78
+ const BREAKDOWN_MODELS: ModelName[] = [
79
+ 'claude-sonnet-4.5',
80
+ 'claude-opus-4.5',
81
+ 'gpt-4o',
82
+ 'gemini-1.5-pro',
83
+ ]
84
+
85
+ /**
86
+ * Calculate cost breakdown for a model
87
+ * Output potential = estimated savings if response is proportionally shorter
88
+ */
89
+ function calculateModelCost(tokensSaved: number, model: ModelName): {
90
+ inputSaved: number
91
+ outputPotential: number
92
+ total: number
93
+ } {
94
+ const pricing = MODEL_PRICING[model]
95
+ const inputSaved = (tokensSaved / 1000) * pricing.input
96
+ // Conservative estimate: output savings ~30% of compression benefit
97
+ // (less context = more focused response, but not 1:1)
98
+ const outputPotential = (tokensSaved / 1000) * pricing.output * 0.3
99
+ return {
100
+ inputSaved,
101
+ outputPotential,
102
+ total: inputSaved + outputPotential,
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Format cost as currency string
108
+ */
109
+ function formatCostSaved(cost: number): string {
110
+ if (cost < 0.001) {
111
+ return '<$0.01'
112
+ }
113
+ if (cost < 0.01) {
114
+ return `$${cost.toFixed(3)}`
115
+ }
116
+ return `$${cost.toFixed(2)}`
117
+ }
118
+
119
+ /**
120
+ * Measure compression between original and filtered content
121
+ *
122
+ * @param original - Original content before filtering
123
+ * @param filtered - Filtered content after compression
124
+ * @returns Token metrics with compression rate and multi-model cost savings
125
+ */
126
+ export function measureCompression(original: string, filtered: string): TokenMetrics {
127
+ const originalTokens = countTokens(original)
128
+ const filteredTokens = countTokens(filtered)
129
+ const tokensSaved = Math.max(0, originalTokens - filteredTokens)
130
+
131
+ const compression =
132
+ originalTokens > 0 ? (originalTokens - filteredTokens) / originalTokens : 0
133
+
134
+ // Calculate cost for default model
135
+ const defaultCost = calculateModelCost(tokensSaved, DEFAULT_MODEL)
136
+
137
+ // Calculate breakdown for popular models
138
+ const byModel = BREAKDOWN_MODELS.map(model => ({
139
+ model,
140
+ ...calculateModelCost(tokensSaved, model),
141
+ }))
142
+
143
+ return {
144
+ tokens: {
145
+ original: originalTokens,
146
+ filtered: filteredTokens,
147
+ saved: tokensSaved,
148
+ },
149
+ compression: Math.max(0, Math.min(1, compression)),
150
+ cost: {
151
+ saved: defaultCost.total,
152
+ formatted: formatCostSaved(defaultCost.total),
153
+ byModel,
154
+ },
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Create metrics for a fallback (no compression) case
160
+ *
161
+ * @param content - The full content that couldn't be compressed
162
+ * @returns Token metrics with 0% compression
163
+ */
164
+ export function noCompression(content: string): TokenMetrics {
165
+ const tokens = countTokens(content)
166
+
167
+ return {
168
+ tokens: { original: tokens, filtered: tokens, saved: 0 },
169
+ compression: 0,
170
+ cost: {
171
+ saved: 0,
172
+ formatted: '$0.00',
173
+ byModel: BREAKDOWN_MODELS.map(model => ({
174
+ model,
175
+ inputSaved: 0,
176
+ outputPotential: 0,
177
+ total: 0,
178
+ })),
179
+ },
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Combine multiple token metrics into one
185
+ *
186
+ * Useful when processing multiple files and aggregating results.
187
+ *
188
+ * @param metrics - Array of token metrics to combine
189
+ * @returns Combined metrics
190
+ */
191
+ export function combineMetrics(metrics: TokenMetrics[]): TokenMetrics {
192
+ if (metrics.length === 0) {
193
+ return noCompression('')
194
+ }
195
+
196
+ // Sum tokens
197
+ const totalOriginal = metrics.reduce((sum, m) => sum + m.tokens.original, 0)
198
+ const totalFiltered = metrics.reduce((sum, m) => sum + m.tokens.filtered, 0)
199
+ const totalSaved = metrics.reduce((sum, m) => sum + m.tokens.saved, 0)
200
+
201
+ // Calculate overall compression
202
+ const compression = totalOriginal > 0
203
+ ? (totalOriginal - totalFiltered) / totalOriginal
204
+ : 0
205
+
206
+ // Sum costs by model
207
+ const byModel = BREAKDOWN_MODELS.map(model => {
208
+ const modelMetrics = metrics.map(m =>
209
+ m.cost.byModel.find(b => b.model === model) || { inputSaved: 0, outputPotential: 0, total: 0 }
210
+ )
211
+ return {
212
+ model,
213
+ inputSaved: modelMetrics.reduce((sum, m) => sum + m.inputSaved, 0),
214
+ outputPotential: modelMetrics.reduce((sum, m) => sum + m.outputPotential, 0),
215
+ total: modelMetrics.reduce((sum, m) => sum + m.total, 0),
216
+ }
217
+ })
218
+
219
+ const totalCost = metrics.reduce((sum, m) => sum + m.cost.saved, 0)
220
+
221
+ return {
222
+ tokens: {
223
+ original: totalOriginal,
224
+ filtered: totalFiltered,
225
+ saved: totalSaved,
226
+ },
227
+ compression,
228
+ cost: {
229
+ saved: totalCost,
230
+ formatted: formatCostSaved(totalCost),
231
+ byModel,
232
+ },
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Format token count for display
238
+ *
239
+ * @param tokens - Number of tokens
240
+ * @returns Human-readable string (e.g., "1.5K", "2.3M")
241
+ */
242
+ export function formatTokenCount(tokens: number): string {
243
+ if (tokens >= 1_000_000) {
244
+ return `${(tokens / 1_000_000).toFixed(1)}M`
245
+ }
246
+ if (tokens >= 1_000) {
247
+ return `${(tokens / 1_000).toFixed(1)}K`
248
+ }
249
+ return tokens.toLocaleString()
250
+ }
251
+
252
+ /**
253
+ * Format compression rate for display
254
+ *
255
+ * @param rate - Compression rate (0-1)
256
+ * @returns Human-readable string (e.g., "89%")
257
+ */
258
+ export function formatCompressionRate(rate: number): string {
259
+ return `${Math.round(rate * 100)}%`
260
+ }
261
+
262
+ // =============================================================================
263
+ // Exports
264
+ // =============================================================================
265
+
266
+ export { formatCostSaved }
267
+
268
+ export default {
269
+ countTokens,
270
+ measureCompression,
271
+ noCompression,
272
+ combineMetrics,
273
+ formatTokenCount,
274
+ formatCompressionRate,
275
+ formatCostSaved,
276
+ CHARS_PER_TOKEN,
277
+ MODEL_PRICING,
278
+ DEFAULT_MODEL,
279
+ }