prjct-cli 1.8.0 → 1.10.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/CHANGELOG.md +176 -0
- package/core/__tests__/agentic/domain-classifier.test.ts +330 -0
- package/core/__tests__/agentic/prompt-assembly.test.ts +298 -0
- package/core/__tests__/agentic/prompt-builder.test.ts +2 -2
- package/core/__tests__/agentic/response-validator.test.ts +263 -0
- package/core/__tests__/agentic/smart-context.test.ts +3 -3
- package/core/__tests__/agentic/token-budget.test.ts +294 -0
- package/core/__tests__/schemas/model.test.ts +272 -0
- package/core/agentic/anti-hallucination.ts +124 -0
- package/core/agentic/domain-classifier.ts +525 -0
- package/core/agentic/environment-block.ts +102 -0
- package/core/agentic/index.ts +1 -0
- package/core/agentic/injection-validator.ts +16 -0
- package/core/agentic/orchestrator-executor.ts +43 -199
- package/core/agentic/prompt-builder.ts +352 -158
- package/core/agentic/response-validator.ts +98 -0
- package/core/agentic/smart-context.ts +60 -144
- package/core/agentic/token-budget.ts +226 -0
- package/core/infrastructure/ai-provider.ts +35 -0
- package/core/schemas/analysis.ts +4 -0
- package/core/schemas/classification.ts +91 -0
- package/core/schemas/index.ts +6 -0
- package/core/schemas/llm-output.ts +170 -0
- package/core/schemas/model.ts +153 -0
- package/core/schemas/state.ts +3 -0
- package/core/services/context-selector.ts +8 -2
- package/core/types/config.ts +2 -0
- package/core/types/provider.ts +12 -0
- package/dist/bin/prjct.mjs +2146 -1347
- package/dist/core/infrastructure/command-installer.js +78 -7
- package/dist/core/infrastructure/setup.js +78 -7
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Validator
|
|
3
|
+
*
|
|
4
|
+
* Validates LLM responses against Zod schemas.
|
|
5
|
+
* Provides structured error handling with re-prompt support.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* 1. Parse raw text as JSON
|
|
9
|
+
* 2. Validate against Zod schema
|
|
10
|
+
* 3. On success: return typed data
|
|
11
|
+
* 4. On failure: return validation errors for re-prompt or fallback
|
|
12
|
+
*
|
|
13
|
+
* @see PRJ-264
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { z } from 'zod'
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Types
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
export interface ValidationSuccess<T> {
|
|
23
|
+
success: true
|
|
24
|
+
data: T
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ValidationFailure {
|
|
28
|
+
success: false
|
|
29
|
+
error: string
|
|
30
|
+
/** Raw parsed JSON (may be partial) */
|
|
31
|
+
rawParsed: unknown
|
|
32
|
+
/** Zod validation issues */
|
|
33
|
+
issues: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
|
|
37
|
+
|
|
38
|
+
// =============================================================================
|
|
39
|
+
// Core Validation
|
|
40
|
+
// =============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate a raw LLM response string against a Zod schema.
|
|
44
|
+
*
|
|
45
|
+
* Handles:
|
|
46
|
+
* - JSON parse errors (LLM returned non-JSON)
|
|
47
|
+
* - Markdown-wrapped JSON (```json ... ```)
|
|
48
|
+
* - Schema validation errors (wrong fields, types)
|
|
49
|
+
*/
|
|
50
|
+
export function validateLLMResponse<T>(raw: string, schema: z.ZodType<T>): ValidationResult<T> {
|
|
51
|
+
// Strip markdown code fences if present
|
|
52
|
+
let jsonText = raw.trim()
|
|
53
|
+
const fenceMatch = jsonText.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?\s*```$/)
|
|
54
|
+
if (fenceMatch) {
|
|
55
|
+
jsonText = fenceMatch[1].trim()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Attempt JSON parse
|
|
59
|
+
let parsed: unknown
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(jsonText)
|
|
62
|
+
} catch {
|
|
63
|
+
return {
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'Response is not valid JSON',
|
|
66
|
+
rawParsed: null,
|
|
67
|
+
issues: [`JSON parse error: expected JSON, got: ${jsonText.slice(0, 100)}...`],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate against schema
|
|
72
|
+
const result = schema.safeParse(parsed)
|
|
73
|
+
if (result.success) {
|
|
74
|
+
return { success: true, data: result.data }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Extract readable error messages
|
|
78
|
+
const issues = result.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: `Schema validation failed: ${issues.join('; ')}`,
|
|
83
|
+
rawParsed: parsed,
|
|
84
|
+
issues,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a re-prompt message when validation fails.
|
|
90
|
+
* Includes the original error so the LLM can fix its response.
|
|
91
|
+
*/
|
|
92
|
+
export function buildReprompt(failure: ValidationFailure, schemaExample: string): string {
|
|
93
|
+
return `Your previous response was not valid. Errors:
|
|
94
|
+
${failure.issues.map((i) => `- ${i}`).join('\n')}
|
|
95
|
+
|
|
96
|
+
Return ONLY valid JSON matching this exact format (no markdown, no explanation):
|
|
97
|
+
${schemaExample}`
|
|
98
|
+
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Intelligently filters context based on task type.
|
|
5
5
|
* Reduces prompt size by 40-70% while maintaining relevance.
|
|
6
6
|
*
|
|
7
|
+
* Uses LLM-based domain classification (PRJ-299) instead of keyword matching.
|
|
8
|
+
*
|
|
7
9
|
* @module agentic/smart-context
|
|
8
|
-
* @version
|
|
10
|
+
* @version 2.0
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { agentPerformanceTracker } from '../agents'
|
|
@@ -19,6 +21,7 @@ import type {
|
|
|
19
21
|
StackInfo,
|
|
20
22
|
TaskType,
|
|
21
23
|
} from '../types'
|
|
24
|
+
import domainClassifier, { classifyWithHeuristic, type ProjectContext } from './domain-classifier'
|
|
22
25
|
|
|
23
26
|
// Re-export types for convenience
|
|
24
27
|
export type {
|
|
@@ -35,163 +38,76 @@ export type {
|
|
|
35
38
|
// Type alias exported for backward compatibility (used by external consumers)
|
|
36
39
|
export type ProjectState = SmartContextProjectState
|
|
37
40
|
|
|
41
|
+
// Map ClassificationDomain → ContextDomain
|
|
42
|
+
function toContextDomain(domain: string): ContextDomain {
|
|
43
|
+
const mapping: Record<string, ContextDomain> = {
|
|
44
|
+
frontend: 'frontend',
|
|
45
|
+
backend: 'backend',
|
|
46
|
+
database: 'backend', // database maps to backend context domain
|
|
47
|
+
devops: 'devops',
|
|
48
|
+
testing: 'testing',
|
|
49
|
+
docs: 'docs',
|
|
50
|
+
uxui: 'frontend', // uxui maps to frontend context domain
|
|
51
|
+
general: 'general',
|
|
52
|
+
}
|
|
53
|
+
return mapping[domain] || 'general'
|
|
54
|
+
}
|
|
55
|
+
|
|
38
56
|
/**
|
|
39
57
|
* SmartContext - Intelligent context filtering.
|
|
40
58
|
*/
|
|
41
59
|
class SmartContext {
|
|
42
60
|
/**
|
|
43
61
|
* Detect the domain of a task from its description.
|
|
62
|
+
*
|
|
63
|
+
* Synchronous version using the improved heuristic (word-boundary matching).
|
|
64
|
+
* For full LLM-based classification, use classifyDomain().
|
|
44
65
|
*/
|
|
45
66
|
detectDomain(taskDescription: string): DomainAnalysis {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
'style',
|
|
57
|
-
'button',
|
|
58
|
-
'form',
|
|
59
|
-
'modal',
|
|
60
|
-
'layout',
|
|
61
|
-
'responsive',
|
|
62
|
-
'animation',
|
|
63
|
-
'dom',
|
|
64
|
-
'html',
|
|
65
|
-
'frontend',
|
|
66
|
-
'fe',
|
|
67
|
-
'client',
|
|
68
|
-
'browser',
|
|
69
|
-
'jsx',
|
|
70
|
-
'tsx',
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
// Backend indicators
|
|
74
|
-
const backendKeywords = [
|
|
75
|
-
'api',
|
|
76
|
-
'server',
|
|
77
|
-
'database',
|
|
78
|
-
'db',
|
|
79
|
-
'endpoint',
|
|
80
|
-
'route',
|
|
81
|
-
'handler',
|
|
82
|
-
'controller',
|
|
83
|
-
'service',
|
|
84
|
-
'repository',
|
|
85
|
-
'model',
|
|
86
|
-
'query',
|
|
87
|
-
'backend',
|
|
88
|
-
'be',
|
|
89
|
-
'rest',
|
|
90
|
-
'graphql',
|
|
91
|
-
'prisma',
|
|
92
|
-
'sql',
|
|
93
|
-
'redis',
|
|
94
|
-
'auth',
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
// DevOps indicators
|
|
98
|
-
const devopsKeywords = [
|
|
99
|
-
'deploy',
|
|
100
|
-
'docker',
|
|
101
|
-
'kubernetes',
|
|
102
|
-
'k8s',
|
|
103
|
-
'ci',
|
|
104
|
-
'cd',
|
|
105
|
-
'pipeline',
|
|
106
|
-
'terraform',
|
|
107
|
-
'ansible',
|
|
108
|
-
'aws',
|
|
109
|
-
'gcp',
|
|
110
|
-
'azure',
|
|
111
|
-
'config',
|
|
112
|
-
'nginx',
|
|
113
|
-
'devops',
|
|
114
|
-
'infrastructure',
|
|
115
|
-
'monitoring',
|
|
116
|
-
'logging',
|
|
117
|
-
'build',
|
|
118
|
-
]
|
|
119
|
-
|
|
120
|
-
// Docs indicators
|
|
121
|
-
const docsKeywords = [
|
|
122
|
-
'document',
|
|
123
|
-
'docs',
|
|
124
|
-
'readme',
|
|
125
|
-
'changelog',
|
|
126
|
-
'comment',
|
|
127
|
-
'jsdoc',
|
|
128
|
-
'tutorial',
|
|
129
|
-
'guide',
|
|
130
|
-
'explain',
|
|
131
|
-
'describe',
|
|
132
|
-
'markdown',
|
|
133
|
-
]
|
|
134
|
-
|
|
135
|
-
// Testing indicators
|
|
136
|
-
const testingKeywords = [
|
|
137
|
-
'test',
|
|
138
|
-
'spec',
|
|
139
|
-
// JS/TS
|
|
140
|
-
'bun',
|
|
141
|
-
'bun test',
|
|
142
|
-
'jest',
|
|
143
|
-
'mocha',
|
|
144
|
-
'cypress',
|
|
145
|
-
'playwright',
|
|
146
|
-
// Python
|
|
147
|
-
'pytest',
|
|
148
|
-
'unittest',
|
|
149
|
-
// Go
|
|
150
|
-
'go test',
|
|
151
|
-
// Rust
|
|
152
|
-
'cargo test',
|
|
153
|
-
// .NET
|
|
154
|
-
'dotnet test',
|
|
155
|
-
// Java
|
|
156
|
-
'mvn test',
|
|
157
|
-
'gradle test',
|
|
158
|
-
'gradlew test',
|
|
159
|
-
'e2e',
|
|
160
|
-
'unit',
|
|
161
|
-
'integration',
|
|
162
|
-
'coverage',
|
|
163
|
-
'mock',
|
|
164
|
-
'fixture',
|
|
165
|
-
]
|
|
166
|
-
|
|
167
|
-
// Count matches
|
|
168
|
-
const scores: Record<ContextDomain, number> = {
|
|
169
|
-
frontend: frontendKeywords.filter((k) => lower.includes(k)).length,
|
|
170
|
-
backend: backendKeywords.filter((k) => lower.includes(k)).length,
|
|
171
|
-
devops: devopsKeywords.filter((k) => lower.includes(k)).length,
|
|
172
|
-
docs: docsKeywords.filter((k) => lower.includes(k)).length,
|
|
173
|
-
testing: testingKeywords.filter((k) => lower.includes(k)).length,
|
|
174
|
-
general: 0,
|
|
67
|
+
// Default context when no project info is available
|
|
68
|
+
const defaultContext: ProjectContext = {
|
|
69
|
+
domains: {
|
|
70
|
+
hasFrontend: true,
|
|
71
|
+
hasBackend: true,
|
|
72
|
+
hasDatabase: true,
|
|
73
|
+
hasTesting: true,
|
|
74
|
+
hasDocker: true,
|
|
75
|
+
},
|
|
76
|
+
agents: [],
|
|
175
77
|
}
|
|
176
78
|
|
|
177
|
-
|
|
178
|
-
const sorted = Object.entries(scores)
|
|
179
|
-
.filter(([_, score]) => score > 0)
|
|
180
|
-
.sort((a, b) => b[1] - a[1])
|
|
79
|
+
const result = classifyWithHeuristic(taskDescription, defaultContext)
|
|
181
80
|
|
|
182
|
-
|
|
183
|
-
|
|
81
|
+
return {
|
|
82
|
+
primary: toContextDomain(result.primaryDomain),
|
|
83
|
+
secondary: result.secondaryDomains.map(toContextDomain),
|
|
84
|
+
confidence: result.confidence,
|
|
184
85
|
}
|
|
86
|
+
}
|
|
185
87
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Classify domain using the full fallback chain (cache → history → LLM → heuristic).
|
|
90
|
+
* Async version that leverages project context and LLM classification.
|
|
91
|
+
*/
|
|
92
|
+
async classifyDomain(
|
|
93
|
+
taskDescription: string,
|
|
94
|
+
projectId: string,
|
|
95
|
+
globalPath: string,
|
|
96
|
+
context: ProjectContext
|
|
97
|
+
): Promise<DomainAnalysis & { source: string }> {
|
|
98
|
+
const { classification, source } = await domainClassifier.classify(
|
|
99
|
+
taskDescription,
|
|
100
|
+
projectId,
|
|
101
|
+
globalPath,
|
|
102
|
+
context
|
|
103
|
+
)
|
|
193
104
|
|
|
194
|
-
return {
|
|
105
|
+
return {
|
|
106
|
+
primary: toContextDomain(classification.primaryDomain),
|
|
107
|
+
secondary: classification.secondaryDomains.map(toContextDomain),
|
|
108
|
+
confidence: classification.confidence,
|
|
109
|
+
source,
|
|
110
|
+
}
|
|
195
111
|
}
|
|
196
112
|
|
|
197
113
|
/**
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Budget Coordinator
|
|
3
|
+
*
|
|
4
|
+
* Centrally manages the global token budget across all context-loading components.
|
|
5
|
+
* Ensures the combined prompt stays within the model's context window
|
|
6
|
+
* and reserves space for the output.
|
|
7
|
+
*
|
|
8
|
+
* Budget formula: inputBudget = modelContextWindow * 0.65
|
|
9
|
+
* Priority: state (P1) > injection context (P2) > file content (P3)
|
|
10
|
+
*
|
|
11
|
+
* @see PRJ-266
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Model Context Windows
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
/** Context window sizes by model identifier (in tokens) */
|
|
19
|
+
export const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
|
20
|
+
// Claude models (short names from model.ts)
|
|
21
|
+
opus: 200_000,
|
|
22
|
+
sonnet: 200_000,
|
|
23
|
+
haiku: 200_000,
|
|
24
|
+
// Gemini models
|
|
25
|
+
'2.5-pro': 1_000_000,
|
|
26
|
+
'2.5-flash': 1_000_000,
|
|
27
|
+
'2.0-flash': 1_000_000,
|
|
28
|
+
// Full model IDs (for direct API usage)
|
|
29
|
+
'claude-opus-4.5': 200_000,
|
|
30
|
+
'claude-sonnet-4.5': 200_000,
|
|
31
|
+
'claude-haiku-4.5': 200_000,
|
|
32
|
+
'claude-opus-4-6': 200_000,
|
|
33
|
+
// Default fallback
|
|
34
|
+
default: 200_000,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Ratio of context window reserved for input (rest for output) */
|
|
38
|
+
export const INPUT_RATIO = 0.65
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Budget Categories
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
/** Budget category identifiers ordered by priority */
|
|
45
|
+
export type BudgetCategory = 'state' | 'injection' | 'files'
|
|
46
|
+
|
|
47
|
+
/** Budget allocation result for each category */
|
|
48
|
+
export interface BudgetAllocation {
|
|
49
|
+
state: number
|
|
50
|
+
injection: number
|
|
51
|
+
files: number
|
|
52
|
+
inputBudget: number
|
|
53
|
+
outputReserve: number
|
|
54
|
+
contextWindow: number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Usage tracking per category */
|
|
58
|
+
export interface BudgetUsage {
|
|
59
|
+
category: BudgetCategory
|
|
60
|
+
allocated: number
|
|
61
|
+
used: number
|
|
62
|
+
remaining: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Default Allocation Ratios (within input budget)
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
interface AllocationConfig {
|
|
70
|
+
ratio: number
|
|
71
|
+
minimum: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ALLOCATION_CONFIG: Record<BudgetCategory, AllocationConfig> = {
|
|
75
|
+
/** P1: State — current task, queue, patterns (highest priority) */
|
|
76
|
+
state: { ratio: 0.02, minimum: 1_500 },
|
|
77
|
+
/** P2: Injection — agents, skills, modules, checklists */
|
|
78
|
+
injection: { ratio: 0.08, minimum: 8_000 },
|
|
79
|
+
/** P3: Files — codebase file content (lowest priority, gets remainder) */
|
|
80
|
+
files: { ratio: 0.9, minimum: 20_000 },
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Priority order for budget distribution */
|
|
84
|
+
const PRIORITY_ORDER: BudgetCategory[] = ['state', 'injection', 'files']
|
|
85
|
+
|
|
86
|
+
// =============================================================================
|
|
87
|
+
// TokenBudgetCoordinator
|
|
88
|
+
// =============================================================================
|
|
89
|
+
|
|
90
|
+
export class TokenBudgetCoordinator {
|
|
91
|
+
private readonly _contextWindow: number
|
|
92
|
+
private readonly _inputBudget: number
|
|
93
|
+
private readonly _outputReserve: number
|
|
94
|
+
private readonly _allocations: Map<BudgetCategory, number> = new Map()
|
|
95
|
+
private readonly _used: Map<BudgetCategory, number> = new Map()
|
|
96
|
+
|
|
97
|
+
constructor(model?: string) {
|
|
98
|
+
this._contextWindow = getContextWindow(model)
|
|
99
|
+
this._inputBudget = Math.floor(this._contextWindow * INPUT_RATIO)
|
|
100
|
+
this._outputReserve = this._contextWindow - this._inputBudget
|
|
101
|
+
this.distributeBudget()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Distribute input budget across categories by priority */
|
|
105
|
+
private distributeBudget(): void {
|
|
106
|
+
let remaining = this._inputBudget
|
|
107
|
+
|
|
108
|
+
for (const category of PRIORITY_ORDER) {
|
|
109
|
+
const config = ALLOCATION_CONFIG[category]
|
|
110
|
+
|
|
111
|
+
if (category === 'files') {
|
|
112
|
+
// Lowest priority gets whatever remains
|
|
113
|
+
this._allocations.set(category, Math.max(remaining, 0))
|
|
114
|
+
} else {
|
|
115
|
+
const allocation = Math.max(config.minimum, Math.floor(this._inputBudget * config.ratio))
|
|
116
|
+
const granted = Math.min(allocation, remaining)
|
|
117
|
+
this._allocations.set(category, granted)
|
|
118
|
+
remaining -= granted
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this._used.set(category, 0)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Get the full budget allocation */
|
|
126
|
+
getAllocation(): BudgetAllocation {
|
|
127
|
+
return {
|
|
128
|
+
state: this._allocations.get('state') ?? 0,
|
|
129
|
+
injection: this._allocations.get('injection') ?? 0,
|
|
130
|
+
files: this._allocations.get('files') ?? 0,
|
|
131
|
+
inputBudget: this._inputBudget,
|
|
132
|
+
outputReserve: this._outputReserve,
|
|
133
|
+
contextWindow: this._contextWindow,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Request tokens from a category.
|
|
139
|
+
* Returns actual tokens granted (may be less than requested if budget is exhausted).
|
|
140
|
+
*/
|
|
141
|
+
request(category: BudgetCategory, requestedTokens: number): number {
|
|
142
|
+
const allocated = this._allocations.get(category) ?? 0
|
|
143
|
+
const currentUsed = this._used.get(category) ?? 0
|
|
144
|
+
const available = Math.max(0, allocated - currentUsed)
|
|
145
|
+
const granted = Math.min(requestedTokens, available)
|
|
146
|
+
this._used.set(category, currentUsed + granted)
|
|
147
|
+
return granted
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Record token usage for a category */
|
|
151
|
+
record(category: BudgetCategory, tokensUsed: number): void {
|
|
152
|
+
const current = this._used.get(category) ?? 0
|
|
153
|
+
this._used.set(category, current + tokensUsed)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Get usage details for a category */
|
|
157
|
+
getUsage(category: BudgetCategory): BudgetUsage {
|
|
158
|
+
const allocated = this._allocations.get(category) ?? 0
|
|
159
|
+
const used = this._used.get(category) ?? 0
|
|
160
|
+
return {
|
|
161
|
+
category,
|
|
162
|
+
allocated,
|
|
163
|
+
used,
|
|
164
|
+
remaining: Math.max(0, allocated - used),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Get allocation for a specific category */
|
|
169
|
+
getAllocationFor(category: BudgetCategory): number {
|
|
170
|
+
return this._allocations.get(category) ?? 0
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Get total remaining input budget across all categories */
|
|
174
|
+
get totalRemaining(): number {
|
|
175
|
+
let totalUsed = 0
|
|
176
|
+
for (const v of this._used.values()) {
|
|
177
|
+
totalUsed += v
|
|
178
|
+
}
|
|
179
|
+
return Math.max(0, this._inputBudget - totalUsed)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Check if total usage exceeds input budget */
|
|
183
|
+
get isOverBudget(): boolean {
|
|
184
|
+
let totalUsed = 0
|
|
185
|
+
for (const v of this._used.values()) {
|
|
186
|
+
totalUsed += v
|
|
187
|
+
}
|
|
188
|
+
return totalUsed > this._inputBudget
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Context window size */
|
|
192
|
+
get contextWindow(): number {
|
|
193
|
+
return this._contextWindow
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Total input budget */
|
|
197
|
+
get inputBudget(): number {
|
|
198
|
+
return this._inputBudget
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Output token reserve */
|
|
202
|
+
get outputReserve(): number {
|
|
203
|
+
return this._outputReserve
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// =============================================================================
|
|
208
|
+
// Helpers
|
|
209
|
+
// =============================================================================
|
|
210
|
+
|
|
211
|
+
/** Get context window size for a model identifier */
|
|
212
|
+
export function getContextWindow(model?: string): number {
|
|
213
|
+
if (!model) return MODEL_CONTEXT_WINDOWS.default
|
|
214
|
+
return MODEL_CONTEXT_WINDOWS[model] ?? MODEL_CONTEXT_WINDOWS.default
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Calculate input budget for a model */
|
|
218
|
+
export function calculateInputBudget(model?: string): number {
|
|
219
|
+
return Math.floor(getContextWindow(model) * INPUT_RATIO)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Calculate output reserve for a model */
|
|
223
|
+
export function calculateOutputReserve(model?: string): number {
|
|
224
|
+
const contextWindow = getContextWindow(model)
|
|
225
|
+
return contextWindow - Math.floor(contextWindow * INPUT_RATIO)
|
|
226
|
+
}
|
|
@@ -21,6 +21,7 @@ import { exec } from 'node:child_process'
|
|
|
21
21
|
import os from 'node:os'
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { promisify } from 'node:util'
|
|
24
|
+
import { compareSemver } from '../schemas/model'
|
|
24
25
|
import { fileExists } from '../utils/fs-helpers'
|
|
25
26
|
import { readProviderCache, writeProviderCache } from '../utils/provider-cache'
|
|
26
27
|
|
|
@@ -58,6 +59,9 @@ export const ClaudeProvider: AIProviderConfig = {
|
|
|
58
59
|
ignoreFile: '.claudeignore',
|
|
59
60
|
websiteUrl: 'https://www.anthropic.com/claude',
|
|
60
61
|
docsUrl: 'https://docs.anthropic.com/claude-code',
|
|
62
|
+
defaultModel: 'sonnet',
|
|
63
|
+
supportedModels: ['opus', 'sonnet', 'haiku'],
|
|
64
|
+
minCliVersion: '1.0.0',
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
/**
|
|
@@ -77,6 +81,9 @@ export const GeminiProvider: AIProviderConfig = {
|
|
|
77
81
|
ignoreFile: '.geminiignore',
|
|
78
82
|
websiteUrl: 'https://geminicli.com',
|
|
79
83
|
docsUrl: 'https://geminicli.com/docs',
|
|
84
|
+
defaultModel: '2.5-flash',
|
|
85
|
+
supportedModels: ['2.5-pro', '2.5-flash', '2.0-flash'],
|
|
86
|
+
minCliVersion: '1.0.0',
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|
|
@@ -100,6 +107,9 @@ export const AntigravityProvider: AIProviderConfig = {
|
|
|
100
107
|
ignoreFile: '.agentignore', // Assumed
|
|
101
108
|
websiteUrl: 'https://gemini.google.com/app/antigravity',
|
|
102
109
|
docsUrl: 'https://gemini.google.com/app/antigravity',
|
|
110
|
+
defaultModel: null, // Platform-managed
|
|
111
|
+
supportedModels: [],
|
|
112
|
+
minCliVersion: null,
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
/**
|
|
@@ -129,6 +139,9 @@ export const CursorProvider: AIProviderConfig = {
|
|
|
129
139
|
isProjectLevel: true, // Config is project-level only
|
|
130
140
|
websiteUrl: 'https://cursor.com',
|
|
131
141
|
docsUrl: 'https://cursor.com/docs',
|
|
142
|
+
defaultModel: null, // Multi-model IDE, user selects
|
|
143
|
+
supportedModels: [],
|
|
144
|
+
minCliVersion: null,
|
|
132
145
|
}
|
|
133
146
|
|
|
134
147
|
/**
|
|
@@ -159,6 +172,9 @@ export const WindsurfProvider: AIProviderConfig = {
|
|
|
159
172
|
isProjectLevel: true, // Config is project-level only
|
|
160
173
|
websiteUrl: 'https://windsurf.com',
|
|
161
174
|
docsUrl: 'https://docs.windsurf.com',
|
|
175
|
+
defaultModel: null, // Multi-model IDE, user selects
|
|
176
|
+
supportedModels: [],
|
|
177
|
+
minCliVersion: null,
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
/**
|
|
@@ -221,14 +237,33 @@ export async function detectProvider(provider: AIProviderName): Promise<Provider
|
|
|
221
237
|
}
|
|
222
238
|
|
|
223
239
|
const version = await getCliVersion(config.cliCommand)
|
|
240
|
+
const versionWarning = validateCliVersion(provider, version || undefined)
|
|
224
241
|
|
|
225
242
|
return {
|
|
226
243
|
installed: true,
|
|
227
244
|
version: version || undefined,
|
|
228
245
|
path: cliPath,
|
|
246
|
+
versionWarning: versionWarning || undefined,
|
|
229
247
|
}
|
|
230
248
|
}
|
|
231
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Validate that a detected CLI version meets the provider's minimum requirement.
|
|
252
|
+
* Returns a warning message if the version is below minimum, or null if OK.
|
|
253
|
+
*/
|
|
254
|
+
export function validateCliVersion(
|
|
255
|
+
provider: AIProviderName,
|
|
256
|
+
version: string | undefined
|
|
257
|
+
): string | null {
|
|
258
|
+
const config = Providers[provider]
|
|
259
|
+
if (!config.minCliVersion || !version) return null
|
|
260
|
+
|
|
261
|
+
if (compareSemver(version, config.minCliVersion) < 0) {
|
|
262
|
+
return `⚠️ ${config.displayName} v${version} is below minimum v${config.minCliVersion}. Some features may not work correctly.`
|
|
263
|
+
}
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
|
|
232
267
|
/**
|
|
233
268
|
* Detect all available CLI-based providers
|
|
234
269
|
* Results are cached to disk with a 10-minute TTL to avoid redundant shell spawns.
|
package/core/schemas/analysis.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Defines the structure for analysis.json - repository analysis.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { ModelMetadata } from './model'
|
|
8
|
+
|
|
7
9
|
export interface CodePattern {
|
|
8
10
|
name: string
|
|
9
11
|
description: string
|
|
@@ -28,6 +30,8 @@ export interface AnalysisSchema {
|
|
|
28
30
|
patterns: CodePattern[]
|
|
29
31
|
antiPatterns: AntiPattern[]
|
|
30
32
|
analyzedAt: string // ISO8601
|
|
33
|
+
/** Which AI model was used for this analysis (PRJ-265) */
|
|
34
|
+
modelMetadata?: ModelMetadata
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export const DEFAULT_ANALYSIS: Omit<AnalysisSchema, 'projectId'> = {
|