prjct-cli 0.44.1 → 0.45.3

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 (207) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/bin/prjct.ts +131 -10
  3. package/core/__tests__/agentic/memory-system.test.ts +39 -26
  4. package/core/__tests__/agentic/plan-mode.test.ts +64 -46
  5. package/core/__tests__/agentic/prompt-builder.test.ts +14 -14
  6. package/core/__tests__/services/project-index.test.ts +353 -0
  7. package/core/__tests__/types/fs.test.ts +3 -3
  8. package/core/__tests__/utils/date-helper.test.ts +10 -10
  9. package/core/__tests__/utils/output.test.ts +9 -6
  10. package/core/__tests__/utils/project-commands.test.ts +5 -6
  11. package/core/agentic/agent-router.ts +9 -10
  12. package/core/agentic/chain-of-thought.ts +16 -4
  13. package/core/agentic/command-executor.ts +66 -40
  14. package/core/agentic/context-builder.ts +8 -5
  15. package/core/agentic/ground-truth.ts +15 -9
  16. package/core/agentic/index.ts +145 -152
  17. package/core/agentic/loop-detector.ts +40 -11
  18. package/core/agentic/memory-system.ts +98 -35
  19. package/core/agentic/orchestrator-executor.ts +135 -71
  20. package/core/agentic/plan-mode.ts +46 -16
  21. package/core/agentic/prompt-builder.ts +108 -42
  22. package/core/agentic/services.ts +10 -9
  23. package/core/agentic/skill-loader.ts +9 -15
  24. package/core/agentic/smart-context.ts +129 -79
  25. package/core/agentic/template-executor.ts +13 -12
  26. package/core/agentic/template-loader.ts +7 -4
  27. package/core/agentic/tool-registry.ts +16 -13
  28. package/core/agents/index.ts +1 -1
  29. package/core/agents/performance.ts +10 -27
  30. package/core/ai-tools/formatters.ts +8 -6
  31. package/core/ai-tools/generator.ts +4 -4
  32. package/core/ai-tools/index.ts +1 -1
  33. package/core/ai-tools/registry.ts +21 -11
  34. package/core/bus/bus.ts +23 -16
  35. package/core/bus/index.ts +2 -2
  36. package/core/cli/linear.ts +3 -5
  37. package/core/cli/start.ts +28 -25
  38. package/core/commands/analysis.ts +287 -29
  39. package/core/commands/analytics.ts +52 -44
  40. package/core/commands/base.ts +15 -13
  41. package/core/commands/cleanup.ts +6 -13
  42. package/core/commands/command-data.ts +49 -8
  43. package/core/commands/commands.ts +60 -23
  44. package/core/commands/context.ts +4 -4
  45. package/core/commands/design.ts +3 -10
  46. package/core/commands/index.ts +5 -8
  47. package/core/commands/maintenance.ts +7 -4
  48. package/core/commands/planning.ts +179 -56
  49. package/core/commands/register.ts +14 -9
  50. package/core/commands/registry.ts +15 -14
  51. package/core/commands/setup.ts +26 -14
  52. package/core/commands/shipping.ts +11 -16
  53. package/core/commands/snapshots.ts +16 -32
  54. package/core/commands/uninstall.ts +541 -0
  55. package/core/commands/workflow.ts +24 -28
  56. package/core/constants/index.ts +10 -22
  57. package/core/context/generator.ts +82 -33
  58. package/core/context-tools/files-tool.ts +583 -0
  59. package/core/context-tools/imports-tool.ts +403 -0
  60. package/core/context-tools/index.ts +433 -0
  61. package/core/context-tools/recent-tool.ts +307 -0
  62. package/core/context-tools/signatures-tool.ts +501 -0
  63. package/core/context-tools/summary-tool.ts +307 -0
  64. package/core/context-tools/token-counter.ts +284 -0
  65. package/core/context-tools/types.ts +253 -0
  66. package/core/domain/agent-generator.ts +7 -5
  67. package/core/domain/agent-loader.ts +2 -2
  68. package/core/domain/analyzer.ts +19 -16
  69. package/core/domain/architecture-generator.ts +6 -3
  70. package/core/domain/context-estimator.ts +3 -4
  71. package/core/domain/snapshot-manager.ts +25 -22
  72. package/core/domain/task-stack.ts +24 -14
  73. package/core/errors.ts +1 -1
  74. package/core/events/events.ts +2 -4
  75. package/core/events/index.ts +1 -2
  76. package/core/index.ts +28 -12
  77. package/core/infrastructure/agent-detector.ts +3 -3
  78. package/core/infrastructure/ai-provider.ts +23 -20
  79. package/core/infrastructure/author-detector.ts +16 -10
  80. package/core/infrastructure/capability-installer.ts +2 -2
  81. package/core/infrastructure/claude-agent.ts +6 -6
  82. package/core/infrastructure/command-installer.ts +22 -17
  83. package/core/infrastructure/config-manager.ts +18 -14
  84. package/core/infrastructure/editors-config.ts +8 -4
  85. package/core/infrastructure/path-manager.ts +8 -6
  86. package/core/infrastructure/permission-manager.ts +20 -17
  87. package/core/infrastructure/setup.ts +42 -38
  88. package/core/infrastructure/update-checker.ts +5 -5
  89. package/core/integrations/issue-tracker/enricher.ts +8 -19
  90. package/core/integrations/issue-tracker/index.ts +2 -2
  91. package/core/integrations/issue-tracker/manager.ts +15 -15
  92. package/core/integrations/issue-tracker/types.ts +5 -22
  93. package/core/integrations/jira/client.ts +67 -59
  94. package/core/integrations/jira/index.ts +11 -14
  95. package/core/integrations/jira/mcp-adapter.ts +5 -10
  96. package/core/integrations/jira/service.ts +10 -10
  97. package/core/integrations/linear/client.ts +27 -18
  98. package/core/integrations/linear/index.ts +9 -12
  99. package/core/integrations/linear/service.ts +11 -11
  100. package/core/integrations/linear/sync.ts +8 -8
  101. package/core/outcomes/analyzer.ts +5 -18
  102. package/core/outcomes/index.ts +2 -2
  103. package/core/outcomes/recorder.ts +3 -3
  104. package/core/plugin/builtin/webhook.ts +19 -15
  105. package/core/plugin/hooks.ts +29 -21
  106. package/core/plugin/index.ts +7 -7
  107. package/core/plugin/loader.ts +19 -19
  108. package/core/plugin/registry.ts +12 -23
  109. package/core/schemas/agents.ts +1 -1
  110. package/core/schemas/analysis.ts +1 -1
  111. package/core/schemas/enriched-task.ts +62 -49
  112. package/core/schemas/ideas.ts +13 -13
  113. package/core/schemas/index.ts +17 -27
  114. package/core/schemas/issues.ts +40 -25
  115. package/core/schemas/metrics.ts +143 -0
  116. package/core/schemas/outcomes.ts +70 -62
  117. package/core/schemas/permissions.ts +15 -12
  118. package/core/schemas/prd.ts +27 -14
  119. package/core/schemas/project.ts +3 -3
  120. package/core/schemas/roadmap.ts +47 -34
  121. package/core/schemas/schemas.ts +3 -4
  122. package/core/schemas/shipped.ts +3 -3
  123. package/core/schemas/state.ts +43 -29
  124. package/core/server/index.ts +5 -6
  125. package/core/server/routes-extended.ts +68 -72
  126. package/core/server/routes.ts +3 -3
  127. package/core/server/server.ts +31 -26
  128. package/core/services/agent-generator.ts +237 -0
  129. package/core/services/agent-service.ts +2 -2
  130. package/core/services/breakdown-service.ts +2 -4
  131. package/core/services/context-generator.ts +299 -0
  132. package/core/services/context-selector.ts +420 -0
  133. package/core/services/doctor-service.ts +426 -0
  134. package/core/services/file-categorizer.ts +448 -0
  135. package/core/services/file-scorer.ts +270 -0
  136. package/core/services/git-analyzer.ts +267 -0
  137. package/core/services/index.ts +27 -10
  138. package/core/services/memory-service.ts +3 -4
  139. package/core/services/project-index.ts +911 -0
  140. package/core/services/project-service.ts +4 -4
  141. package/core/services/skill-installer.ts +14 -17
  142. package/core/services/skill-lock.ts +3 -3
  143. package/core/services/skill-service.ts +12 -6
  144. package/core/services/stack-detector.ts +245 -0
  145. package/core/services/sync-service.ts +170 -329
  146. package/core/services/watch-service.ts +294 -0
  147. package/core/session/compaction.ts +23 -31
  148. package/core/session/index.ts +11 -5
  149. package/core/session/log-migration.ts +3 -3
  150. package/core/session/metrics.ts +19 -14
  151. package/core/session/session-log-manager.ts +12 -17
  152. package/core/session/task-session-manager.ts +25 -25
  153. package/core/session/utils.ts +1 -1
  154. package/core/storage/ideas-storage.ts +41 -57
  155. package/core/storage/index-storage.ts +514 -0
  156. package/core/storage/index.ts +41 -13
  157. package/core/storage/metrics-storage.ts +320 -0
  158. package/core/storage/queue-storage.ts +35 -45
  159. package/core/storage/shipped-storage.ts +17 -20
  160. package/core/storage/state-storage.ts +50 -30
  161. package/core/storage/storage-manager.ts +6 -6
  162. package/core/storage/storage.ts +18 -15
  163. package/core/sync/auth-config.ts +3 -3
  164. package/core/sync/index.ts +13 -19
  165. package/core/sync/oauth-handler.ts +3 -3
  166. package/core/sync/sync-client.ts +4 -9
  167. package/core/sync/sync-manager.ts +12 -14
  168. package/core/types/commands.ts +42 -7
  169. package/core/types/index.ts +284 -302
  170. package/core/types/integrations.ts +3 -3
  171. package/core/types/storage.ts +49 -0
  172. package/core/types/utils.ts +3 -3
  173. package/core/utils/agent-stream.ts +3 -1
  174. package/core/utils/animations.ts +14 -11
  175. package/core/utils/branding.ts +7 -7
  176. package/core/utils/cache.ts +1 -3
  177. package/core/utils/collection-filters.ts +3 -15
  178. package/core/utils/date-helper.ts +2 -7
  179. package/core/utils/file-helper.ts +13 -8
  180. package/core/utils/jsonl-helper.ts +13 -10
  181. package/core/utils/keychain.ts +4 -8
  182. package/core/utils/logger.ts +1 -1
  183. package/core/utils/next-steps.ts +3 -3
  184. package/core/utils/output.ts +58 -11
  185. package/core/utils/project-commands.ts +6 -6
  186. package/core/utils/project-credentials.ts +5 -12
  187. package/core/utils/runtime.ts +2 -2
  188. package/core/utils/session-helper.ts +3 -4
  189. package/core/utils/version.ts +3 -3
  190. package/core/wizard/index.ts +13 -0
  191. package/core/wizard/onboarding.ts +633 -0
  192. package/core/workflow/state-machine.ts +7 -7
  193. package/dist/bin/prjct.mjs +18907 -13189
  194. package/dist/core/infrastructure/command-installer.js +96 -111
  195. package/dist/core/infrastructure/editors-config.js +6 -6
  196. package/dist/core/infrastructure/setup.js +256 -257
  197. package/dist/core/utils/version.js +9 -9
  198. package/package.json +11 -12
  199. package/scripts/build.js +3 -3
  200. package/scripts/postinstall.js +2 -2
  201. package/templates/mcp-config.json +6 -1
  202. package/templates/permissions/permissive.jsonc +1 -1
  203. package/templates/permissions/strict.jsonc +5 -9
  204. package/templates/global/docs/agents.md +0 -88
  205. package/templates/global/docs/architecture.md +0 -103
  206. package/templates/global/docs/commands.md +0 -96
  207. package/templates/global/docs/validation.md +0 -95
@@ -0,0 +1,307 @@
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 'node:fs/promises'
16
+ import path from 'node:path'
17
+ import { isNotFoundError } from '../types/fs'
18
+ import { analyzeImports } from './imports-tool'
19
+ import { extractSignatures } from './signatures-tool'
20
+ import { measureCompression, noCompression } from './token-counter'
21
+ import type { PublicAPIEntry, SummaryToolOutput } from './types'
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) ? filePath : path.join(projectPath, filePath)
87
+
88
+ // Read file content
89
+ let content: string
90
+ try {
91
+ content = await fs.readFile(absolutePath, 'utf-8')
92
+ } catch (error) {
93
+ if (isNotFoundError(error)) {
94
+ return {
95
+ file: filePath,
96
+ purpose: 'File not found',
97
+ publicAPI: [],
98
+ dependencies: [],
99
+ metrics: noCompression(''),
100
+ }
101
+ }
102
+ throw error
103
+ }
104
+
105
+ // Get language
106
+ const ext = path.extname(filePath).toLowerCase()
107
+ const language = EXT_TO_LANG[ext] || 'unknown'
108
+
109
+ // Extract signatures
110
+ const signaturesResult = await extractSignatures(filePath, projectPath)
111
+
112
+ // Extract imports for dependencies
113
+ const importsResult = await analyzeImports(filePath, projectPath)
114
+
115
+ // Extract file-level docstring (purpose)
116
+ const purpose = extractFilePurpose(content, language)
117
+
118
+ // Build public API from exported signatures
119
+ const publicAPI: PublicAPIEntry[] = signaturesResult.signatures
120
+ .filter((sig) => sig.exported)
121
+ .map((sig) => ({
122
+ name: sig.name,
123
+ type: sig.type,
124
+ signature: sig.signature,
125
+ description: sig.docstring ? extractDescriptionFromDocstring(sig.docstring) : undefined,
126
+ }))
127
+
128
+ // Get key dependencies (internal only, external are obvious from package.json)
129
+ const dependencies = importsResult.imports
130
+ .filter((imp) => !imp.isExternal && imp.resolved)
131
+ .map((imp) => imp.resolved!)
132
+ .slice(0, 10) // Limit to 10
133
+
134
+ // Build summary content for metrics
135
+ const summaryContent = buildSummaryText(purpose, publicAPI, dependencies)
136
+
137
+ return {
138
+ file: filePath,
139
+ purpose,
140
+ publicAPI,
141
+ dependencies,
142
+ metrics: measureCompression(content, summaryContent),
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Summarize all files in a directory
148
+ */
149
+ export async function summarizeDirectory(
150
+ dirPath: string,
151
+ projectPath: string = process.cwd(),
152
+ options: { recursive?: boolean } = {}
153
+ ): Promise<SummaryToolOutput[]> {
154
+ const absolutePath = path.isAbsolute(dirPath) ? dirPath : path.join(projectPath, dirPath)
155
+
156
+ const results: SummaryToolOutput[] = []
157
+
158
+ async function processDir(dir: string): Promise<void> {
159
+ const entries = await fs.readdir(dir, { withFileTypes: true })
160
+
161
+ for (const entry of entries) {
162
+ const fullPath = path.join(dir, entry.name)
163
+ const relativePath = path.relative(projectPath, fullPath)
164
+
165
+ if (entry.isDirectory()) {
166
+ // Skip common ignore patterns
167
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) {
168
+ continue
169
+ }
170
+ if (options.recursive) {
171
+ await processDir(fullPath)
172
+ }
173
+ } else if (entry.isFile()) {
174
+ const ext = path.extname(entry.name).toLowerCase()
175
+ if (EXT_TO_LANG[ext]) {
176
+ const result = await summarizeFile(relativePath, projectPath)
177
+ results.push(result)
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ await processDir(absolutePath)
184
+ return results
185
+ }
186
+
187
+ // =============================================================================
188
+ // Helper Functions
189
+ // =============================================================================
190
+
191
+ /**
192
+ * Extract file-level purpose from first docstring
193
+ */
194
+ function extractFilePurpose(content: string, language: string): string {
195
+ const patterns = DOCSTRING_PATTERNS[language] || []
196
+ const lines = content.split('\n')
197
+
198
+ // Look for a file-level docstring in first 30 lines
199
+ for (let i = 0; i < Math.min(30, lines.length); i++) {
200
+ const line = lines[i].trim()
201
+
202
+ for (const pattern of patterns) {
203
+ if (pattern.start.test(line)) {
204
+ if (pattern.singleLine) {
205
+ // Single-line comment - grab consecutive lines
206
+ const commentLines: string[] = []
207
+ let j = i
208
+ while (j < lines.length && pattern.start.test(lines[j].trim())) {
209
+ commentLines.push(lines[j].trim().replace(pattern.start, '').trim())
210
+ j++
211
+ }
212
+ if (commentLines.length > 0) {
213
+ return commentLines.slice(0, 3).join(' ').trim()
214
+ }
215
+ } else if (pattern.end) {
216
+ // Multi-line comment - extract until end
217
+ let comment = ''
218
+ let j = i
219
+ while (j < lines.length) {
220
+ comment += `${lines[j]}\n`
221
+ if (pattern.end.test(lines[j])) break
222
+ j++
223
+ }
224
+ // Extract first meaningful line
225
+ const meaningfulLines = comment
226
+ .replace(pattern.start, '')
227
+ .replace(pattern.end!, '')
228
+ .split('\n')
229
+ .map((l) => l.replace(/^\s*\*\s?/, '').trim())
230
+ .filter((l) => l.length > 0 && !l.startsWith('@'))
231
+ if (meaningfulLines.length > 0) {
232
+ return meaningfulLines.slice(0, 2).join(' ').trim()
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ // Stop if we hit code (not comments or empty lines)
239
+ if (
240
+ line.length > 0 &&
241
+ !line.startsWith('//') &&
242
+ !line.startsWith('#') &&
243
+ !line.startsWith('/*') &&
244
+ !line.startsWith('*') &&
245
+ !line.startsWith("'") &&
246
+ !line.startsWith('"')
247
+ ) {
248
+ break
249
+ }
250
+ }
251
+
252
+ // Fallback: derive from filename
253
+ const fileName = content.split('\n')[0] || ''
254
+ return `Module: ${path.basename(fileName, path.extname(fileName))}`
255
+ }
256
+
257
+ /**
258
+ * Extract description from a docstring line
259
+ */
260
+ function extractDescriptionFromDocstring(docstring: string): string {
261
+ // Remove comment markers and clean up
262
+ return docstring
263
+ .replace(/^\/\*\*\s*/, '')
264
+ .replace(/\*\/$/, '')
265
+ .replace(/^\/\/\/?\s*/, '')
266
+ .replace(/^#\s*/, '')
267
+ .replace(/^"""\s*/, '')
268
+ .replace(/"""\s*$/, '')
269
+ .trim()
270
+ .split('\n')[0] // First line only
271
+ .trim()
272
+ }
273
+
274
+ /**
275
+ * Build summary text for metrics calculation
276
+ */
277
+ function buildSummaryText(
278
+ purpose: string,
279
+ publicAPI: PublicAPIEntry[],
280
+ dependencies: string[]
281
+ ): string {
282
+ const parts: string[] = []
283
+
284
+ parts.push(`Purpose: ${purpose}`)
285
+ parts.push('')
286
+
287
+ if (publicAPI.length > 0) {
288
+ parts.push('Public API:')
289
+ for (const entry of publicAPI) {
290
+ const desc = entry.description ? ` - ${entry.description}` : ''
291
+ parts.push(` ${entry.type} ${entry.name}: ${entry.signature}${desc}`)
292
+ }
293
+ parts.push('')
294
+ }
295
+
296
+ if (dependencies.length > 0) {
297
+ parts.push(`Dependencies: ${dependencies.join(', ')}`)
298
+ }
299
+
300
+ return parts.join('\n')
301
+ }
302
+
303
+ // =============================================================================
304
+ // Exports
305
+ // =============================================================================
306
+
307
+ export default { summarizeFile, summarizeDirectory }
@@ -0,0 +1,284 @@
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(
90
+ tokensSaved: number,
91
+ model: ModelName
92
+ ): {
93
+ inputSaved: number
94
+ outputPotential: number
95
+ total: number
96
+ } {
97
+ const pricing = MODEL_PRICING[model]
98
+ const inputSaved = (tokensSaved / 1000) * pricing.input
99
+ // Conservative estimate: output savings ~30% of compression benefit
100
+ // (less context = more focused response, but not 1:1)
101
+ const outputPotential = (tokensSaved / 1000) * pricing.output * 0.3
102
+ return {
103
+ inputSaved,
104
+ outputPotential,
105
+ total: inputSaved + outputPotential,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Format cost as currency string
111
+ */
112
+ function formatCostSaved(cost: number): string {
113
+ if (cost < 0.001) {
114
+ return '<$0.01'
115
+ }
116
+ if (cost < 0.01) {
117
+ return `$${cost.toFixed(3)}`
118
+ }
119
+ return `$${cost.toFixed(2)}`
120
+ }
121
+
122
+ /**
123
+ * Measure compression between original and filtered content
124
+ *
125
+ * @param original - Original content before filtering
126
+ * @param filtered - Filtered content after compression
127
+ * @returns Token metrics with compression rate and multi-model cost savings
128
+ */
129
+ export function measureCompression(original: string, filtered: string): TokenMetrics {
130
+ const originalTokens = countTokens(original)
131
+ const filteredTokens = countTokens(filtered)
132
+ const tokensSaved = Math.max(0, originalTokens - filteredTokens)
133
+
134
+ const compression = originalTokens > 0 ? (originalTokens - filteredTokens) / originalTokens : 0
135
+
136
+ // Calculate cost for default model
137
+ const defaultCost = calculateModelCost(tokensSaved, DEFAULT_MODEL)
138
+
139
+ // Calculate breakdown for popular models
140
+ const byModel = BREAKDOWN_MODELS.map((model) => ({
141
+ model,
142
+ ...calculateModelCost(tokensSaved, model),
143
+ }))
144
+
145
+ return {
146
+ tokens: {
147
+ original: originalTokens,
148
+ filtered: filteredTokens,
149
+ saved: tokensSaved,
150
+ },
151
+ compression: Math.max(0, Math.min(1, compression)),
152
+ cost: {
153
+ saved: defaultCost.total,
154
+ formatted: formatCostSaved(defaultCost.total),
155
+ byModel,
156
+ },
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Create metrics for a fallback (no compression) case
162
+ *
163
+ * @param content - The full content that couldn't be compressed
164
+ * @returns Token metrics with 0% compression
165
+ */
166
+ export function noCompression(content: string): TokenMetrics {
167
+ const tokens = countTokens(content)
168
+
169
+ return {
170
+ tokens: { original: tokens, filtered: tokens, saved: 0 },
171
+ compression: 0,
172
+ cost: {
173
+ saved: 0,
174
+ formatted: '$0.00',
175
+ byModel: BREAKDOWN_MODELS.map((model) => ({
176
+ model,
177
+ inputSaved: 0,
178
+ outputPotential: 0,
179
+ total: 0,
180
+ })),
181
+ },
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Combine multiple token metrics into one
187
+ *
188
+ * Useful when processing multiple files and aggregating results.
189
+ *
190
+ * @param metrics - Array of token metrics to combine
191
+ * @returns Combined metrics
192
+ */
193
+ export function combineMetrics(metrics: TokenMetrics[]): TokenMetrics {
194
+ if (metrics.length === 0) {
195
+ return noCompression('')
196
+ }
197
+
198
+ // Sum tokens
199
+ const totalOriginal = metrics.reduce((sum, m) => sum + m.tokens.original, 0)
200
+ const totalFiltered = metrics.reduce((sum, m) => sum + m.tokens.filtered, 0)
201
+ const totalSaved = metrics.reduce((sum, m) => sum + m.tokens.saved, 0)
202
+
203
+ // Calculate overall compression
204
+ const compression = totalOriginal > 0 ? (totalOriginal - totalFiltered) / totalOriginal : 0
205
+
206
+ // Sum costs by model
207
+ const byModel = BREAKDOWN_MODELS.map((model) => {
208
+ const modelMetrics = metrics.map(
209
+ (m) =>
210
+ m.cost.byModel.find((b) => b.model === model) || {
211
+ inputSaved: 0,
212
+ outputPotential: 0,
213
+ total: 0,
214
+ }
215
+ )
216
+ return {
217
+ model,
218
+ inputSaved: modelMetrics.reduce((sum, m) => sum + m.inputSaved, 0),
219
+ outputPotential: modelMetrics.reduce((sum, m) => sum + m.outputPotential, 0),
220
+ total: modelMetrics.reduce((sum, m) => sum + m.total, 0),
221
+ }
222
+ })
223
+
224
+ const totalCost = metrics.reduce((sum, m) => sum + m.cost.saved, 0)
225
+
226
+ return {
227
+ tokens: {
228
+ original: totalOriginal,
229
+ filtered: totalFiltered,
230
+ saved: totalSaved,
231
+ },
232
+ compression,
233
+ cost: {
234
+ saved: totalCost,
235
+ formatted: formatCostSaved(totalCost),
236
+ byModel,
237
+ },
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Format token count for display
243
+ *
244
+ * @param tokens - Number of tokens
245
+ * @returns Human-readable string (e.g., "1.5K", "2.3M")
246
+ */
247
+ export function formatTokenCount(tokens: number): string {
248
+ if (tokens >= 1_000_000) {
249
+ return `${(tokens / 1_000_000).toFixed(1)}M`
250
+ }
251
+ if (tokens >= 1_000) {
252
+ return `${(tokens / 1_000).toFixed(1)}K`
253
+ }
254
+ return tokens.toLocaleString()
255
+ }
256
+
257
+ /**
258
+ * Format compression rate for display
259
+ *
260
+ * @param rate - Compression rate (0-1)
261
+ * @returns Human-readable string (e.g., "89%")
262
+ */
263
+ export function formatCompressionRate(rate: number): string {
264
+ return `${Math.round(rate * 100)}%`
265
+ }
266
+
267
+ // =============================================================================
268
+ // Exports
269
+ // =============================================================================
270
+
271
+ export { formatCostSaved }
272
+
273
+ export default {
274
+ countTokens,
275
+ measureCompression,
276
+ noCompression,
277
+ combineMetrics,
278
+ formatTokenCount,
279
+ formatCompressionRate,
280
+ formatCostSaved,
281
+ CHARS_PER_TOKEN,
282
+ MODEL_PRICING,
283
+ DEFAULT_MODEL,
284
+ }