prjct-cli 1.7.2 → 1.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.3] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - add Zod validation and token budgets for prompt injection (PRJ-282) (#142)
8
+
9
+
10
+ ## [1.7.3] - 2026-02-07
11
+
12
+ ### Bug Fixes
13
+ - **Validate auto-injected state in prompt builder (PRJ-282)**: Added `safeInject()` validation utility, token-aware truncation via `InjectionBudgetTracker`, and domain-based skill filtering to prevent oversized or irrelevant content in LLM prompts. Replaced hardcoded character limits with configurable token budgets.
14
+
15
+ ### Implementation Details
16
+ - Created `core/agentic/injection-validator.ts` with `safeInject()`, `safeInjectString()`, `truncateToTokenBudget()`, `estimateTokens()`, `filterSkillsByDomains()`, and `InjectionBudgetTracker` class
17
+ - Wired validation into `prompt-builder.ts`: auto-context truncation, agent/skill token budgets, cumulative state budget tracking
18
+ - Skills filtered by detected task domains before injection to reduce token waste
19
+ - 33 new unit tests covering all validation, filtering, and truncation paths
20
+
21
+ ### Test Plan
22
+
23
+ #### For QA
24
+ 1. Run `bun test` — all 526 tests pass (33 new)
25
+ 2. Verify `safeInject()` returns fallback on corrupt data
26
+ 3. Verify `filterSkillsByDomains()` excludes irrelevant skills
27
+ 4. Verify `InjectionBudgetTracker` enforces cumulative limits
28
+
29
+ #### For Users
30
+ - No user-facing changes — validation is automatic
31
+ - Breaking changes: None
32
+
3
33
  ## [1.7.2] - 2026-02-07
4
34
 
5
35
  ### Bug Fixes
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Injection Validator Tests
3
+ * Tests for safeInject, truncation, skill filtering, and budget tracking.
4
+ */
5
+
6
+ import { describe, expect, it } from 'bun:test'
7
+ import { z } from 'zod'
8
+ import {
9
+ DEFAULT_BUDGETS,
10
+ estimateTokens,
11
+ filterSkillsByDomains,
12
+ InjectionBudgetTracker,
13
+ safeInject,
14
+ safeInjectString,
15
+ truncateToTokenBudget,
16
+ } from '../../agentic/injection-validator'
17
+
18
+ // =============================================================================
19
+ // safeInject
20
+ // =============================================================================
21
+
22
+ describe('safeInject', () => {
23
+ const schema = z.object({ name: z.string(), value: z.number() })
24
+ const fallback = { name: 'unknown', value: 0 }
25
+
26
+ it('returns validated data on valid input', () => {
27
+ const data = { name: 'test', value: 42 }
28
+ expect(safeInject(data, schema, fallback)).toEqual(data)
29
+ })
30
+
31
+ it('returns fallback on invalid input', () => {
32
+ const data = { name: 123, value: 'bad' }
33
+ expect(safeInject(data, schema, fallback)).toEqual(fallback)
34
+ })
35
+
36
+ it('returns fallback on null input', () => {
37
+ expect(safeInject(null, schema, fallback)).toEqual(fallback)
38
+ })
39
+
40
+ it('returns fallback on undefined input', () => {
41
+ expect(safeInject(undefined, schema, fallback)).toEqual(fallback)
42
+ })
43
+
44
+ it('strips extra fields via Zod', () => {
45
+ const data = { name: 'test', value: 42, extra: 'ignored' }
46
+ const result = safeInject(data, schema, fallback)
47
+ expect(result.name).toBe('test')
48
+ expect(result.value).toBe(42)
49
+ })
50
+ })
51
+
52
+ // =============================================================================
53
+ // safeInjectString
54
+ // =============================================================================
55
+
56
+ describe('safeInjectString', () => {
57
+ const schema = z.object({ count: z.number() })
58
+ const formatter = (d: { count: number }) => `Items: ${d.count}`
59
+
60
+ it('returns formatted string on valid input', () => {
61
+ expect(safeInjectString({ count: 5 }, schema, formatter, 'N/A')).toBe('Items: 5')
62
+ })
63
+
64
+ it('returns fallback string on invalid input', () => {
65
+ expect(safeInjectString({ count: 'bad' }, schema, formatter, 'N/A')).toBe('N/A')
66
+ })
67
+
68
+ it('returns fallback string on null', () => {
69
+ expect(safeInjectString(null, schema, formatter, 'no data')).toBe('no data')
70
+ })
71
+ })
72
+
73
+ // =============================================================================
74
+ // truncateToTokenBudget
75
+ // =============================================================================
76
+
77
+ describe('truncateToTokenBudget', () => {
78
+ it('returns text unchanged if within budget', () => {
79
+ const text = 'short text'
80
+ expect(truncateToTokenBudget(text, 100)).toBe(text)
81
+ })
82
+
83
+ it('truncates text that exceeds budget', () => {
84
+ const text = 'a'.repeat(500) // 500 chars = ~125 tokens
85
+ const result = truncateToTokenBudget(text, 50) // 50 tokens = 200 chars
86
+ expect(result.length).toBeLessThan(500)
87
+ expect(result).toContain('truncated')
88
+ expect(result).toContain('~50 tokens')
89
+ })
90
+
91
+ it('truncates to exact char limit', () => {
92
+ const text = 'a'.repeat(100)
93
+ const result = truncateToTokenBudget(text, 10) // 10 tokens = 40 chars
94
+ expect(result.startsWith('a'.repeat(40))).toBe(true)
95
+ })
96
+
97
+ it('handles empty string', () => {
98
+ expect(truncateToTokenBudget('', 100)).toBe('')
99
+ })
100
+
101
+ it('handles zero budget', () => {
102
+ const result = truncateToTokenBudget('some text', 0)
103
+ expect(result).toContain('truncated')
104
+ })
105
+ })
106
+
107
+ // =============================================================================
108
+ // estimateTokens
109
+ // =============================================================================
110
+
111
+ describe('estimateTokens', () => {
112
+ it('estimates tokens at ~4 chars per token', () => {
113
+ expect(estimateTokens('a'.repeat(100))).toBe(25)
114
+ })
115
+
116
+ it('rounds up partial tokens', () => {
117
+ expect(estimateTokens('abc')).toBe(1) // 3/4 = 0.75, ceil = 1
118
+ })
119
+
120
+ it('handles empty string', () => {
121
+ expect(estimateTokens('')).toBe(0)
122
+ })
123
+ })
124
+
125
+ // =============================================================================
126
+ // filterSkillsByDomains
127
+ // =============================================================================
128
+
129
+ describe('filterSkillsByDomains', () => {
130
+ const skills = [
131
+ { name: 'react-patterns', content: 'React component patterns and hooks' },
132
+ { name: 'api-design', content: 'RESTful API design and endpoint patterns' },
133
+ { name: 'jest-testing', content: 'Jest test patterns and assertions' },
134
+ { name: 'docker-deploy', content: 'Docker and Kubernetes deployment' },
135
+ { name: 'general-coding', content: 'General coding best practices' },
136
+ ]
137
+
138
+ it('returns all skills when no domains detected', () => {
139
+ expect(filterSkillsByDomains(skills, [])).toEqual(skills)
140
+ })
141
+
142
+ it('returns all skills when skills array is empty', () => {
143
+ expect(filterSkillsByDomains([], ['frontend'])).toEqual([])
144
+ })
145
+
146
+ it('filters to frontend-relevant skills', () => {
147
+ const result = filterSkillsByDomains(skills, ['frontend'])
148
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
149
+ expect(result.some((s) => s.name === 'docker-deploy')).toBe(false)
150
+ })
151
+
152
+ it('filters to backend-relevant skills', () => {
153
+ const result = filterSkillsByDomains(skills, ['backend'])
154
+ expect(result.some((s) => s.name === 'api-design')).toBe(true)
155
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(false)
156
+ })
157
+
158
+ it('filters to testing-relevant skills', () => {
159
+ const result = filterSkillsByDomains(skills, ['testing'])
160
+ expect(result.some((s) => s.name === 'jest-testing')).toBe(true)
161
+ })
162
+
163
+ it('supports multiple domains', () => {
164
+ const result = filterSkillsByDomains(skills, ['frontend', 'testing'])
165
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
166
+ expect(result.some((s) => s.name === 'jest-testing')).toBe(true)
167
+ expect(result.some((s) => s.name === 'docker-deploy')).toBe(false)
168
+ })
169
+
170
+ it('matches domain name itself as keyword', () => {
171
+ const customSkills = [{ name: 'devops-helper', content: 'general devops tools' }]
172
+ const result = filterSkillsByDomains(customSkills, ['devops'])
173
+ expect(result).toHaveLength(1)
174
+ })
175
+
176
+ it('is case insensitive', () => {
177
+ const result = filterSkillsByDomains(skills, ['Frontend'])
178
+ expect(result.some((s) => s.name === 'react-patterns')).toBe(true)
179
+ })
180
+ })
181
+
182
+ // =============================================================================
183
+ // InjectionBudgetTracker
184
+ // =============================================================================
185
+
186
+ describe('InjectionBudgetTracker', () => {
187
+ it('tracks cumulative token usage', () => {
188
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 100 })
189
+ tracker.addSection('a'.repeat(200), 100) // 200 chars = 50 tokens
190
+ expect(tracker.totalUsed).toBe(50)
191
+ expect(tracker.remaining).toBe(50)
192
+ })
193
+
194
+ it('truncates sections to per-section budget', () => {
195
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 1000 })
196
+ const result = tracker.addSection('a'.repeat(500), 50) // budget: 50 tokens = 200 chars
197
+ expect(result.length).toBeLessThan(500)
198
+ })
199
+
200
+ it('returns empty string when total budget exhausted', () => {
201
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 10 })
202
+ tracker.addSection('a'.repeat(100), 50) // uses all 10 tokens of total budget
203
+ const result = tracker.addSection('more content', 50)
204
+ expect(result).toBe('')
205
+ })
206
+
207
+ it('fits content to remaining total budget', () => {
208
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 30 })
209
+ tracker.addSection('a'.repeat(80), 30) // 80 chars = 20 tokens
210
+ // Remaining: 10 tokens
211
+ const result = tracker.addSection('b'.repeat(200), 100) // wants 100 tokens, only 10 left
212
+ expect(result.length).toBeLessThan(200)
213
+ expect(tracker.remaining).toBe(0)
214
+ })
215
+
216
+ it('uses default budgets when none provided', () => {
217
+ const tracker = new InjectionBudgetTracker()
218
+ expect(tracker.config.totalPrompt).toBe(DEFAULT_BUDGETS.totalPrompt)
219
+ expect(tracker.config.autoContext).toBe(DEFAULT_BUDGETS.autoContext)
220
+ })
221
+
222
+ it('allows partial budget overrides', () => {
223
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 5000 })
224
+ expect(tracker.config.totalPrompt).toBe(5000)
225
+ expect(tracker.config.autoContext).toBe(DEFAULT_BUDGETS.autoContext) // unchanged
226
+ })
227
+
228
+ it('remaining never goes negative', () => {
229
+ const tracker = new InjectionBudgetTracker({ totalPrompt: 5 })
230
+ tracker.addSection('a'.repeat(1000), 500)
231
+ expect(tracker.remaining).toBe(0)
232
+ })
233
+ })
234
+
235
+ // =============================================================================
236
+ // DEFAULT_BUDGETS
237
+ // =============================================================================
238
+
239
+ describe('DEFAULT_BUDGETS', () => {
240
+ it('has all required fields', () => {
241
+ expect(DEFAULT_BUDGETS.autoContext).toBeGreaterThan(0)
242
+ expect(DEFAULT_BUDGETS.agentContent).toBeGreaterThan(0)
243
+ expect(DEFAULT_BUDGETS.skillContent).toBeGreaterThan(0)
244
+ expect(DEFAULT_BUDGETS.stateData).toBeGreaterThan(0)
245
+ expect(DEFAULT_BUDGETS.memories).toBeGreaterThan(0)
246
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(0)
247
+ })
248
+
249
+ it('totalPrompt is larger than individual budgets', () => {
250
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.autoContext)
251
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.agentContent)
252
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.skillContent)
253
+ expect(DEFAULT_BUDGETS.totalPrompt).toBeGreaterThan(DEFAULT_BUDGETS.stateData)
254
+ })
255
+ })
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Injection Validator
3
+ *
4
+ * Validates data before auto-injection into LLM prompts.
5
+ * Corrupted or oversized data gets safe fallbacks instead of broken context.
6
+ *
7
+ * @module agentic/injection-validator
8
+ */
9
+
10
+ import type { z } from 'zod'
11
+
12
+ // =============================================================================
13
+ // Token Budget Configuration
14
+ // =============================================================================
15
+
16
+ /** Configurable token budgets per injection section */
17
+ export interface InjectionBudgets {
18
+ /** Auto-injected context (task + queue + patterns) */
19
+ autoContext: number
20
+ /** Per-agent content in orchestrator */
21
+ agentContent: number
22
+ /** Per-skill content in orchestrator */
23
+ skillContent: number
24
+ /** State data section */
25
+ stateData: number
26
+ /** Memories section */
27
+ memories: number
28
+ /** Total prompt ceiling (all sections combined) */
29
+ totalPrompt: number
30
+ }
31
+
32
+ /** Default budgets (in estimated tokens, ~4 chars per token) */
33
+ export const DEFAULT_BUDGETS: InjectionBudgets = {
34
+ autoContext: 500,
35
+ agentContent: 400,
36
+ skillContent: 500,
37
+ stateData: 1000,
38
+ memories: 600,
39
+ totalPrompt: 8000,
40
+ }
41
+
42
+ // Approximate chars-per-token ratio
43
+ const CHARS_PER_TOKEN = 4
44
+
45
+ // =============================================================================
46
+ // Safe Injection
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Validate data against a Zod schema before injection.
51
+ * Returns validated data on success, or the fallback on failure.
52
+ */
53
+ export function safeInject<T>(data: unknown, schema: z.ZodType<T>, fallback: T): T {
54
+ const result = schema.safeParse(data)
55
+ if (result.success) {
56
+ return result.data
57
+ }
58
+ return fallback
59
+ }
60
+
61
+ /**
62
+ * Validate and stringify data for prompt injection.
63
+ * Returns formatted string on success, or fallback string on failure.
64
+ */
65
+ export function safeInjectString<T>(
66
+ data: unknown,
67
+ schema: z.ZodType<T>,
68
+ formatter: (valid: T) => string,
69
+ fallbackString: string
70
+ ): string {
71
+ const result = schema.safeParse(data)
72
+ if (result.success) {
73
+ return formatter(result.data)
74
+ }
75
+ return fallbackString
76
+ }
77
+
78
+ // =============================================================================
79
+ // Token-Aware Truncation
80
+ // =============================================================================
81
+
82
+ /**
83
+ * Truncate text to fit within a token budget.
84
+ * Uses char-based estimation (~4 chars/token).
85
+ */
86
+ export function truncateToTokenBudget(text: string, maxTokens: number): string {
87
+ const maxChars = maxTokens * CHARS_PER_TOKEN
88
+ if (text.length <= maxChars) return text
89
+ return `${text.substring(0, maxChars)}\n... (truncated to ~${maxTokens} tokens)`
90
+ }
91
+
92
+ /**
93
+ * Estimate token count for a string.
94
+ */
95
+ export function estimateTokens(text: string): number {
96
+ return Math.ceil(text.length / CHARS_PER_TOKEN)
97
+ }
98
+
99
+ // =============================================================================
100
+ // Skill Filtering
101
+ // =============================================================================
102
+
103
+ /** Domain keywords for matching skills to task domains */
104
+ const DOMAIN_KEYWORDS: Record<string, string[]> = {
105
+ frontend: ['react', 'vue', 'svelte', 'css', 'html', 'ui', 'component', 'frontend', 'web', 'dom'],
106
+ backend: ['api', 'server', 'backend', 'endpoint', 'route', 'middleware', 'database', 'sql'],
107
+ testing: ['test', 'spec', 'jest', 'vitest', 'cypress', 'playwright', 'coverage', 'assert'],
108
+ devops: ['docker', 'ci', 'cd', 'deploy', 'kubernetes', 'terraform', 'pipeline', 'github-actions'],
109
+ docs: ['documentation', 'readme', 'guide', 'tutorial', 'markdown'],
110
+ design: ['design', 'ux', 'ui', 'figma', 'wireframe', 'layout', 'accessibility'],
111
+ }
112
+
113
+ /**
114
+ * Filter skills by relevance to detected task domains.
115
+ * Returns only skills whose content matches the task's domains.
116
+ */
117
+ export function filterSkillsByDomains(
118
+ skills: { name: string; content: string }[],
119
+ detectedDomains: string[]
120
+ ): { name: string; content: string }[] {
121
+ if (detectedDomains.length === 0 || skills.length === 0) return skills
122
+
123
+ // Build keyword set from detected domains
124
+ const relevantKeywords = new Set<string>()
125
+ for (const domain of detectedDomains) {
126
+ const keywords = DOMAIN_KEYWORDS[domain.toLowerCase()]
127
+ if (keywords) {
128
+ for (const kw of keywords) relevantKeywords.add(kw)
129
+ }
130
+ // Also add the domain name itself
131
+ relevantKeywords.add(domain.toLowerCase())
132
+ }
133
+
134
+ return skills.filter((skill) => {
135
+ const text = `${skill.name} ${skill.content}`.toLowerCase()
136
+ // Keep skill if any relevant keyword appears in its name or content
137
+ for (const kw of relevantKeywords) {
138
+ if (text.includes(kw)) return true
139
+ }
140
+ return false
141
+ })
142
+ }
143
+
144
+ // =============================================================================
145
+ // Section Budget Tracker
146
+ // =============================================================================
147
+
148
+ /**
149
+ * Tracks cumulative token usage across injection sections.
150
+ * Allows checking remaining budget before adding more content.
151
+ */
152
+ export class InjectionBudgetTracker {
153
+ private used = 0
154
+ private budgets: InjectionBudgets
155
+
156
+ constructor(budgets: Partial<InjectionBudgets> = {}) {
157
+ this.budgets = { ...DEFAULT_BUDGETS, ...budgets }
158
+ }
159
+
160
+ /** Add content and return it (possibly truncated to fit budget) */
161
+ addSection(content: string, sectionBudget: number): string {
162
+ const truncated = truncateToTokenBudget(content, sectionBudget)
163
+ const tokens = estimateTokens(truncated)
164
+
165
+ // Check total budget
166
+ if (this.used + tokens > this.budgets.totalPrompt) {
167
+ const remaining = this.budgets.totalPrompt - this.used
168
+ if (remaining <= 0) return ''
169
+ const fitted = truncateToTokenBudget(truncated, remaining)
170
+ this.used += estimateTokens(fitted)
171
+ return fitted
172
+ }
173
+
174
+ this.used += tokens
175
+ return truncated
176
+ }
177
+
178
+ /** Get remaining token budget */
179
+ get remaining(): number {
180
+ return Math.max(0, this.budgets.totalPrompt - this.used)
181
+ }
182
+
183
+ /** Get total tokens used */
184
+ get totalUsed(): number {
185
+ return this.used
186
+ }
187
+
188
+ /** Get the budgets config */
189
+ get config(): InjectionBudgets {
190
+ return this.budgets
191
+ }
192
+ }
@@ -28,6 +28,12 @@ import type {
28
28
  import { getErrorMessage, isNotFoundError } from '../types/fs'
29
29
  import { fileExists } from '../utils/fs-helpers'
30
30
  import { PACKAGE_ROOT } from '../utils/version'
31
+ import {
32
+ DEFAULT_BUDGETS,
33
+ filterSkillsByDomains,
34
+ InjectionBudgetTracker,
35
+ truncateToTokenBudget,
36
+ } from './injection-validator'
31
37
 
32
38
  // Re-export types for convenience
33
39
  export type {
@@ -288,7 +294,8 @@ class PromptBuilder {
288
294
  parts.push('---')
289
295
  parts.push('')
290
296
 
291
- return parts.join('\n')
297
+ const result = parts.join('\n')
298
+ return truncateToTokenBudget(result, DEFAULT_BUDGETS.autoContext)
292
299
  }
293
300
 
294
301
  /**
@@ -464,25 +471,29 @@ class PromptBuilder {
464
471
  if (agent.skills.length > 0) {
465
472
  parts.push(`Skills: ${agent.skills.join(', ')}\n`)
466
473
  }
467
- // Include first 1500 chars of agent content
468
- const truncatedContent =
469
- agent.content.length > 1500
470
- ? `${agent.content.substring(0, 1500)}\n... (truncated, read full file for more)`
471
- : agent.content
474
+ // Truncate agent content to token budget
475
+ const truncatedContent = truncateToTokenBudget(
476
+ agent.content,
477
+ DEFAULT_BUDGETS.agentContent
478
+ )
472
479
  parts.push(`\`\`\`markdown\n${truncatedContent}\n\`\`\`\n\n`)
473
480
  }
474
481
  }
475
482
 
476
- // Inject loaded skill content (truncated)
477
- if (orchestratorContext.skills.length > 0) {
483
+ // Filter skills by detected domains, then inject (truncated)
484
+ const relevantSkills = filterSkillsByDomains(
485
+ orchestratorContext.skills,
486
+ orchestratorContext.detectedDomains
487
+ )
488
+ if (relevantSkills.length > 0) {
478
489
  parts.push('### LOADED SKILLS (From Agent Frontmatter)\n\n')
479
- for (const skill of orchestratorContext.skills) {
490
+ for (const skill of relevantSkills) {
480
491
  parts.push(`#### Skill: ${skill.name}\n`)
481
- // Include first 2000 chars of skill content
482
- const truncatedContent =
483
- skill.content.length > 2000
484
- ? `${skill.content.substring(0, 2000)}\n... (truncated)`
485
- : skill.content
492
+ // Truncate skill content to token budget
493
+ const truncatedContent = truncateToTokenBudget(
494
+ skill.content,
495
+ DEFAULT_BUDGETS.skillContent
496
+ )
486
497
  parts.push(`\`\`\`markdown\n${truncatedContent}\n\`\`\`\n\n`)
487
498
  }
488
499
  }
@@ -721,28 +732,21 @@ class PromptBuilder {
721
732
  }
722
733
 
723
734
  /**
724
- * Filter state data to include only relevant portions for the prompt
735
+ * Filter state data to include only relevant portions for the prompt.
736
+ * Uses InjectionBudgetTracker to enforce cumulative token limits.
725
737
  */
726
738
  filterRelevantState(state: State): string | null {
727
739
  if (!state || Object.keys(state).length === 0) return null
728
740
 
741
+ const tracker = new InjectionBudgetTracker({ totalPrompt: DEFAULT_BUDGETS.stateData })
742
+ const criticalFiles = ['now', 'next', 'context', 'analysis', 'codePatterns']
729
743
  const relevant: string[] = []
744
+
730
745
  for (const [key, content] of Object.entries(state)) {
731
746
  if (content && (content as string).trim()) {
732
- const criticalFiles = ['now', 'next', 'context', 'analysis', 'codePatterns']
733
- if (criticalFiles.includes(key)) {
734
- const display =
735
- (content as string).length > 2000
736
- ? `${(content as string).substring(0, 2000)}\n... (truncated)`
737
- : content
738
- relevant.push(`### ${key}\n${display}`)
739
- } else if ((content as string).length < 1000) {
740
- relevant.push(`### ${key}\n${content}`)
741
- } else {
742
- relevant.push(
743
- `### ${key}\n${(content as string).substring(0, 500)}... (truncated, use Read tool for full content)`
744
- )
745
- }
747
+ const sectionBudget = criticalFiles.includes(key) ? 500 : 250
748
+ const section = tracker.addSection(`### ${key}\n${content}`, sectionBudget)
749
+ if (section) relevant.push(section)
746
750
  }
747
751
  }
748
752
 
@@ -793,7 +797,8 @@ class PromptBuilder {
793
797
  parts.push(`\nAvoid:\n${antiPatterns}`)
794
798
  }
795
799
 
796
- const result = parts.join('\n').substring(0, 800)
800
+ const joined = parts.join('\n')
801
+ const result = truncateToTokenBudget(joined, 200) // ~800 chars
797
802
  return result || null
798
803
  }
799
804
 
@@ -14219,6 +14219,97 @@ var init_outcomes2 = __esm({
14219
14219
  }
14220
14220
  });
14221
14221
 
14222
+ // core/agentic/injection-validator.ts
14223
+ function truncateToTokenBudget(text, maxTokens) {
14224
+ const maxChars = maxTokens * CHARS_PER_TOKEN2;
14225
+ if (text.length <= maxChars) return text;
14226
+ return `${text.substring(0, maxChars)}
14227
+ ... (truncated to ~${maxTokens} tokens)`;
14228
+ }
14229
+ function estimateTokens(text) {
14230
+ return Math.ceil(text.length / CHARS_PER_TOKEN2);
14231
+ }
14232
+ function filterSkillsByDomains(skills, detectedDomains) {
14233
+ if (detectedDomains.length === 0 || skills.length === 0) return skills;
14234
+ const relevantKeywords = /* @__PURE__ */ new Set();
14235
+ for (const domain of detectedDomains) {
14236
+ const keywords = DOMAIN_KEYWORDS3[domain.toLowerCase()];
14237
+ if (keywords) {
14238
+ for (const kw of keywords) relevantKeywords.add(kw);
14239
+ }
14240
+ relevantKeywords.add(domain.toLowerCase());
14241
+ }
14242
+ return skills.filter((skill) => {
14243
+ const text = `${skill.name} ${skill.content}`.toLowerCase();
14244
+ for (const kw of relevantKeywords) {
14245
+ if (text.includes(kw)) return true;
14246
+ }
14247
+ return false;
14248
+ });
14249
+ }
14250
+ var DEFAULT_BUDGETS, CHARS_PER_TOKEN2, DOMAIN_KEYWORDS3, InjectionBudgetTracker;
14251
+ var init_injection_validator = __esm({
14252
+ "core/agentic/injection-validator.ts"() {
14253
+ "use strict";
14254
+ DEFAULT_BUDGETS = {
14255
+ autoContext: 500,
14256
+ agentContent: 400,
14257
+ skillContent: 500,
14258
+ stateData: 1e3,
14259
+ memories: 600,
14260
+ totalPrompt: 8e3
14261
+ };
14262
+ CHARS_PER_TOKEN2 = 4;
14263
+ __name(truncateToTokenBudget, "truncateToTokenBudget");
14264
+ __name(estimateTokens, "estimateTokens");
14265
+ DOMAIN_KEYWORDS3 = {
14266
+ frontend: ["react", "vue", "svelte", "css", "html", "ui", "component", "frontend", "web", "dom"],
14267
+ backend: ["api", "server", "backend", "endpoint", "route", "middleware", "database", "sql"],
14268
+ testing: ["test", "spec", "jest", "vitest", "cypress", "playwright", "coverage", "assert"],
14269
+ devops: ["docker", "ci", "cd", "deploy", "kubernetes", "terraform", "pipeline", "github-actions"],
14270
+ docs: ["documentation", "readme", "guide", "tutorial", "markdown"],
14271
+ design: ["design", "ux", "ui", "figma", "wireframe", "layout", "accessibility"]
14272
+ };
14273
+ __name(filterSkillsByDomains, "filterSkillsByDomains");
14274
+ InjectionBudgetTracker = class {
14275
+ static {
14276
+ __name(this, "InjectionBudgetTracker");
14277
+ }
14278
+ used = 0;
14279
+ budgets;
14280
+ constructor(budgets = {}) {
14281
+ this.budgets = { ...DEFAULT_BUDGETS, ...budgets };
14282
+ }
14283
+ /** Add content and return it (possibly truncated to fit budget) */
14284
+ addSection(content, sectionBudget) {
14285
+ const truncated = truncateToTokenBudget(content, sectionBudget);
14286
+ const tokens = estimateTokens(truncated);
14287
+ if (this.used + tokens > this.budgets.totalPrompt) {
14288
+ const remaining = this.budgets.totalPrompt - this.used;
14289
+ if (remaining <= 0) return "";
14290
+ const fitted = truncateToTokenBudget(truncated, remaining);
14291
+ this.used += estimateTokens(fitted);
14292
+ return fitted;
14293
+ }
14294
+ this.used += tokens;
14295
+ return truncated;
14296
+ }
14297
+ /** Get remaining token budget */
14298
+ get remaining() {
14299
+ return Math.max(0, this.budgets.totalPrompt - this.used);
14300
+ }
14301
+ /** Get total tokens used */
14302
+ get totalUsed() {
14303
+ return this.used;
14304
+ }
14305
+ /** Get the budgets config */
14306
+ get config() {
14307
+ return this.budgets;
14308
+ }
14309
+ };
14310
+ }
14311
+ });
14312
+
14222
14313
  // core/agentic/prompt-builder.ts
14223
14314
  import fs29 from "node:fs/promises";
14224
14315
  import path28 from "node:path";
@@ -14231,6 +14322,7 @@ var init_prompt_builder = __esm({
14231
14322
  init_fs();
14232
14323
  init_fs_helpers();
14233
14324
  init_version();
14325
+ init_injection_validator();
14234
14326
  PromptBuilder = class {
14235
14327
  static {
14236
14328
  __name(this, "PromptBuilder");
@@ -14426,7 +14518,8 @@ var init_prompt_builder = __esm({
14426
14518
  }
14427
14519
  parts.push("---");
14428
14520
  parts.push("");
14429
- return parts.join("\n");
14521
+ const result = parts.join("\n");
14522
+ return truncateToTokenBudget(result, DEFAULT_BUDGETS.autoContext);
14430
14523
  }
14431
14524
  /**
14432
14525
  * Calculate elapsed time from ISO timestamp.
@@ -14560,8 +14653,10 @@ Apply specialized expertise. Read agent file for details if needed.
14560
14653
  parts.push(`Skills: ${agent2.skills.join(", ")}
14561
14654
  `);
14562
14655
  }
14563
- const truncatedContent = agent2.content.length > 1500 ? `${agent2.content.substring(0, 1500)}
14564
- ... (truncated, read full file for more)` : agent2.content;
14656
+ const truncatedContent = truncateToTokenBudget(
14657
+ agent2.content,
14658
+ DEFAULT_BUDGETS.agentContent
14659
+ );
14565
14660
  parts.push(`\`\`\`markdown
14566
14661
  ${truncatedContent}
14567
14662
  \`\`\`
@@ -14569,13 +14664,19 @@ ${truncatedContent}
14569
14664
  `);
14570
14665
  }
14571
14666
  }
14572
- if (orchestratorContext.skills.length > 0) {
14667
+ const relevantSkills = filterSkillsByDomains(
14668
+ orchestratorContext.skills,
14669
+ orchestratorContext.detectedDomains
14670
+ );
14671
+ if (relevantSkills.length > 0) {
14573
14672
  parts.push("### LOADED SKILLS (From Agent Frontmatter)\n\n");
14574
- for (const skill of orchestratorContext.skills) {
14673
+ for (const skill of relevantSkills) {
14575
14674
  parts.push(`#### Skill: ${skill.name}
14576
14675
  `);
14577
- const truncatedContent = skill.content.length > 2e3 ? `${skill.content.substring(0, 2e3)}
14578
- ... (truncated)` : skill.content;
14676
+ const truncatedContent = truncateToTokenBudget(
14677
+ skill.content,
14678
+ DEFAULT_BUDGETS.skillContent
14679
+ );
14579
14680
  parts.push(`\`\`\`markdown
14580
14681
  ${truncatedContent}
14581
14682
  \`\`\`
@@ -14803,28 +14904,20 @@ Show changes, list affected files, ask for confirmation.
14803
14904
  return parts.join("");
14804
14905
  }
14805
14906
  /**
14806
- * Filter state data to include only relevant portions for the prompt
14907
+ * Filter state data to include only relevant portions for the prompt.
14908
+ * Uses InjectionBudgetTracker to enforce cumulative token limits.
14807
14909
  */
14808
14910
  filterRelevantState(state) {
14809
14911
  if (!state || Object.keys(state).length === 0) return null;
14912
+ const tracker = new InjectionBudgetTracker({ totalPrompt: DEFAULT_BUDGETS.stateData });
14913
+ const criticalFiles = ["now", "next", "context", "analysis", "codePatterns"];
14810
14914
  const relevant = [];
14811
14915
  for (const [key, content] of Object.entries(state)) {
14812
14916
  if (content && content.trim()) {
14813
- const criticalFiles = ["now", "next", "context", "analysis", "codePatterns"];
14814
- if (criticalFiles.includes(key)) {
14815
- const display = content.length > 2e3 ? `${content.substring(0, 2e3)}
14816
- ... (truncated)` : content;
14817
- relevant.push(`### ${key}
14818
- ${display}`);
14819
- } else if (content.length < 1e3) {
14820
- relevant.push(`### ${key}
14821
- ${content}`);
14822
- } else {
14823
- relevant.push(
14824
- `### ${key}
14825
- ${content.substring(0, 500)}... (truncated, use Read tool for full content)`
14826
- );
14827
- }
14917
+ const sectionBudget = criticalFiles.includes(key) ? 500 : 250;
14918
+ const section = tracker.addSection(`### ${key}
14919
+ ${content}`, sectionBudget);
14920
+ if (section) relevant.push(section);
14828
14921
  }
14829
14922
  }
14830
14923
  return relevant.length > 0 ? relevant.join("\n\n") : null;
@@ -14865,7 +14958,8 @@ ${content.substring(0, 500)}... (truncated, use Read tool for full content)`
14865
14958
  Avoid:
14866
14959
  ${antiPatterns}`);
14867
14960
  }
14868
- const result = parts.join("\n").substring(0, 800);
14961
+ const joined = parts.join("\n");
14962
+ const result = truncateToTokenBudget(joined, 200);
14869
14963
  return result || null;
14870
14964
  }
14871
14965
  /**
@@ -16233,13 +16327,13 @@ var init_breakdown_service = __esm({
16233
16327
  });
16234
16328
 
16235
16329
  // core/services/context-selector.ts
16236
- var DEFAULT_TOKEN_BUDGET, DOMAIN_KEYWORDS3, ContextSelector, contextSelector;
16330
+ var DEFAULT_TOKEN_BUDGET, DOMAIN_KEYWORDS4, ContextSelector, contextSelector;
16237
16331
  var init_context_selector = __esm({
16238
16332
  "core/services/context-selector.ts"() {
16239
16333
  "use strict";
16240
16334
  init_index_storage();
16241
16335
  DEFAULT_TOKEN_BUDGET = 8e4;
16242
- DOMAIN_KEYWORDS3 = {
16336
+ DOMAIN_KEYWORDS4 = {
16243
16337
  payments: [
16244
16338
  "payment",
16245
16339
  "pay",
@@ -16422,7 +16516,7 @@ var init_context_selector = __esm({
16422
16516
  detectTaskDomains(description, projectDomains) {
16423
16517
  const normalizedDesc = description.toLowerCase();
16424
16518
  const detectedDomains = /* @__PURE__ */ new Set();
16425
- for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS3)) {
16519
+ for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS4)) {
16426
16520
  for (const keyword of keywords) {
16427
16521
  if (normalizedDesc.includes(keyword)) {
16428
16522
  detectedDomains.add(domain);
@@ -19043,8 +19137,8 @@ var init_analyzer2 = __esm({
19043
19137
 
19044
19138
  // core/services/diff-generator.ts
19045
19139
  import chalk11 from "chalk";
19046
- function estimateTokens(content) {
19047
- return Math.ceil(content.length / CHARS_PER_TOKEN2);
19140
+ function estimateTokens2(content) {
19141
+ return Math.ceil(content.length / CHARS_PER_TOKEN3);
19048
19142
  }
19049
19143
  function parseMarkdownSections(content) {
19050
19144
  const lines = content.split("\n");
@@ -19087,8 +19181,8 @@ function generateSyncDiff(oldContent, newContent) {
19087
19181
  modified: [],
19088
19182
  removed: [],
19089
19183
  preserved: [],
19090
- tokensBefore: estimateTokens(oldContent),
19091
- tokensAfter: estimateTokens(newContent),
19184
+ tokensBefore: estimateTokens2(oldContent),
19185
+ tokensAfter: estimateTokens2(newContent),
19092
19186
  tokenDelta: 0
19093
19187
  };
19094
19188
  diff.tokenDelta = diff.tokensAfter - diff.tokensBefore;
@@ -19242,12 +19336,12 @@ function formatFullDiff(diff, options = {}) {
19242
19336
  }
19243
19337
  return lines.join("\n");
19244
19338
  }
19245
- var CHARS_PER_TOKEN2;
19339
+ var CHARS_PER_TOKEN3;
19246
19340
  var init_diff_generator = __esm({
19247
19341
  "core/services/diff-generator.ts"() {
19248
19342
  "use strict";
19249
- CHARS_PER_TOKEN2 = 4;
19250
- __name(estimateTokens, "estimateTokens");
19343
+ CHARS_PER_TOKEN3 = 4;
19344
+ __name(estimateTokens2, "estimateTokens");
19251
19345
  __name(parseMarkdownSections, "parseMarkdownSections");
19252
19346
  __name(isPreservedSection, "isPreservedSection");
19253
19347
  __name(generateSyncDiff, "generateSyncDiff");
@@ -23429,7 +23523,7 @@ You are the ${name} expert for this project. Apply best practices for the detect
23429
23523
  * Token estimation: ~4 chars per token (industry standard)
23430
23524
  */
23431
23525
  async recordSyncMetrics(stats, contextFiles, agents, duration) {
23432
- const CHARS_PER_TOKEN3 = 4;
23526
+ const CHARS_PER_TOKEN4 = 4;
23433
23527
  let filteredChars = 0;
23434
23528
  for (const file of contextFiles) {
23435
23529
  try {
@@ -23452,7 +23546,7 @@ You are the ${name} expert for this project. Apply best practices for the detect
23452
23546
  });
23453
23547
  }
23454
23548
  }
23455
- const filteredSize = Math.floor(filteredChars / CHARS_PER_TOKEN3);
23549
+ const filteredSize = Math.floor(filteredChars / CHARS_PER_TOKEN4);
23456
23550
  const avgTokensPerFile = 500;
23457
23551
  const originalSize = stats.fileCount * avgTokensPerFile;
23458
23552
  const compressionRate = originalSize > 0 ? Math.max(0, (originalSize - filteredSize) / originalSize) : 0;
@@ -29016,7 +29110,7 @@ var require_package = __commonJS({
29016
29110
  "package.json"(exports, module) {
29017
29111
  module.exports = {
29018
29112
  name: "prjct-cli",
29019
- version: "1.7.2",
29113
+ version: "1.7.3",
29020
29114
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
29021
29115
  main: "core/index.ts",
29022
29116
  bin: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "1.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {