@mindfiredigital/ignix-lite-engine 1.1.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.
Files changed (91) hide show
  1. package/.turbo/turbo-build.log +22 -0
  2. package/CHANGELOG.md +7 -0
  3. package/LICENSE +21 -0
  4. package/README.md +283 -0
  5. package/dist/index.d.ts +171 -0
  6. package/dist/index.js +2540 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/manifests/accordion.json +61 -0
  9. package/dist/manifests/alert.json +69 -0
  10. package/dist/manifests/avatar.json +75 -0
  11. package/dist/manifests/badge.json +74 -0
  12. package/dist/manifests/breadcrumb.json +87 -0
  13. package/dist/manifests/button.json +85 -0
  14. package/dist/manifests/card.json +91 -0
  15. package/dist/manifests/checkbox.json +122 -0
  16. package/dist/manifests/codeblock.json +63 -0
  17. package/dist/manifests/combobox.json +33 -0
  18. package/dist/manifests/dialog.json +64 -0
  19. package/dist/manifests/divider.json +47 -0
  20. package/dist/manifests/dropdown.json +105 -0
  21. package/dist/manifests/form.json +81 -0
  22. package/dist/manifests/grid.json +143 -0
  23. package/dist/manifests/input.json +99 -0
  24. package/dist/manifests/meter.json +103 -0
  25. package/dist/manifests/navigation.json +70 -0
  26. package/dist/manifests/progress.json +88 -0
  27. package/dist/manifests/radio.json +121 -0
  28. package/dist/manifests/select.json +109 -0
  29. package/dist/manifests/skeleton.json +101 -0
  30. package/dist/manifests/tab.json +88 -0
  31. package/dist/manifests/table.json +92 -0
  32. package/dist/manifests/textarea.json +117 -0
  33. package/dist/manifests/toast.json +157 -0
  34. package/dist/manifests/tooltip.json +115 -0
  35. package/dist/vector-index.json +14015 -0
  36. package/package.json +33 -0
  37. package/src/global.d.ts +3 -0
  38. package/src/index.ts +14 -0
  39. package/src/manifests/accordion.json +61 -0
  40. package/src/manifests/alert.json +69 -0
  41. package/src/manifests/avatar.json +75 -0
  42. package/src/manifests/badge.json +74 -0
  43. package/src/manifests/breadcrumb.json +87 -0
  44. package/src/manifests/button.json +85 -0
  45. package/src/manifests/card.json +91 -0
  46. package/src/manifests/checkbox.json +122 -0
  47. package/src/manifests/codeblock.json +63 -0
  48. package/src/manifests/combobox.json +33 -0
  49. package/src/manifests/dialog.json +64 -0
  50. package/src/manifests/divider.json +47 -0
  51. package/src/manifests/dropdown.json +105 -0
  52. package/src/manifests/form.json +81 -0
  53. package/src/manifests/grid.json +143 -0
  54. package/src/manifests/index.ts +49 -0
  55. package/src/manifests/input.json +99 -0
  56. package/src/manifests/meter.json +103 -0
  57. package/src/manifests/navigation.json +70 -0
  58. package/src/manifests/progress.json +88 -0
  59. package/src/manifests/radio.json +121 -0
  60. package/src/manifests/select.json +109 -0
  61. package/src/manifests/skeleton.json +101 -0
  62. package/src/manifests/tab.json +88 -0
  63. package/src/manifests/table.json +92 -0
  64. package/src/manifests/textarea.json +117 -0
  65. package/src/manifests/toast.json +157 -0
  66. package/src/manifests/tooltip.json +115 -0
  67. package/src/tools/build-index.ts +43 -0
  68. package/src/tools/check-a11y.ts +96 -0
  69. package/src/tools/embedder.ts +18 -0
  70. package/src/tools/generate-theme.ts +42 -0
  71. package/src/tools/get-emmet.ts +64 -0
  72. package/src/tools/get-manifests.ts +55 -0
  73. package/src/tools/handoff.ts +302 -0
  74. package/src/tools/intent-engine.ts +215 -0
  75. package/src/tools/list-components.ts +20 -0
  76. package/src/tools/preview.ts +186 -0
  77. package/src/tools/search-index.ts +82 -0
  78. package/src/tools/theme-palette.ts +65 -0
  79. package/src/tools/theme-tokens.ts +176 -0
  80. package/src/tools/token-counter.ts +59 -0
  81. package/src/tools/validator.ts +353 -0
  82. package/src/types.ts +63 -0
  83. package/src/utils/a11y-rules.ts +873 -0
  84. package/src/utils/a11y-types.ts +15 -0
  85. package/src/utils/cosine.ts +15 -0
  86. package/src/utils/emmet-helpers.ts +283 -0
  87. package/src/utils/intent-helpers.ts +66 -0
  88. package/src/utils/intent-parser.ts +175 -0
  89. package/src/utils/tokenizer.ts +7 -0
  90. package/tsconfig.json +17 -0
  91. package/tsup.config.ts +10 -0
@@ -0,0 +1,43 @@
1
+ import { readdirSync, readFileSync, writeFileSync } from 'fs'
2
+
3
+ import { embedText } from './embedder.js'
4
+
5
+ type Example = {
6
+ label?: string
7
+ emmet?: string
8
+ html?: string
9
+ }
10
+
11
+ const files = readdirSync('src/manifests')
12
+
13
+ const index = files
14
+ .filter((file) => file.endsWith('.json'))
15
+ .map((file) => {
16
+ const manifest = JSON.parse(readFileSync(`src/manifests/${file}`, 'utf8'))
17
+
18
+ const searchable = [
19
+ manifest.component ?? '',
20
+ manifest.description ?? '',
21
+ manifest.element ?? '',
22
+ manifest.emmet ?? '',
23
+
24
+ ...(manifest.states ?? []),
25
+ ...(manifest.do ?? []),
26
+ ...(manifest.dont ?? []),
27
+ ...(manifest.examples ?? []).flatMap((x: Example) => [
28
+ x.label ?? '',
29
+ x.emmet ?? '',
30
+ x.html ?? ''
31
+ ])
32
+ ].join(' ')
33
+
34
+ return {
35
+ name: manifest.component,
36
+ emmet: manifest.emmet,
37
+ searchable,
38
+ embedding: embedText(searchable)
39
+ }
40
+ })
41
+
42
+ writeFileSync('dist/vector-index.json', JSON.stringify(index, null, 2))
43
+ console.log('Vector index built')
@@ -0,0 +1,96 @@
1
+ import { parse } from 'node-html-parser'
2
+ import type { A11yIssue, RuleResult } from '../utils/a11y-types.js'
3
+ import {
4
+ checkImages,
5
+ checkFormLabels,
6
+ checkEmptyLabels,
7
+ checkButtons,
8
+ checkLinks,
9
+ checkAriaStates,
10
+ checkDuplicateIds,
11
+ checkTabIndex,
12
+ checkHeadings,
13
+ checkTables,
14
+ checkDialogs,
15
+ checkRoles,
16
+ checkAutocomplete,
17
+ checkFocusStyle,
18
+ checkLang
19
+ } from '../utils/a11y-rules.js'
20
+
21
+ const RULE_CONFIDENCES: Record<string, { error: number; warning: number }> = {
22
+ 'WCAG 1.1.1 Non-text Content': { error: 0.99, warning: 0.80 },
23
+ 'WCAG 1.3.1 Form Labels': { error: 0.98, warning: 0.78 },
24
+ 'WCAG 2.4.6 Empty Labels': { error: 0.95, warning: 0.75 },
25
+ 'WCAG 4.1.2 Button Names': { error: 0.99, warning: 0.80 },
26
+ 'WCAG 2.4.4 Link Purpose': { error: 0.97, warning: 0.75 },
27
+ 'WCAG 3.3.1 Error Identification': { error: 0.98, warning: 0.75 },
28
+ 'WCAG 4.1.2 ARIA State Values': { error: 0.96, warning: 0.75 },
29
+ 'WCAG 4.1.1 Parsing': { error: 0.99, warning: 0.80 },
30
+ 'WCAG 2.1.1 Keyboard': { error: 0.95, warning: 0.75 },
31
+ 'WCAG 2.4.6 Heading Hierarchy': { error: 0.95, warning: 0.75 },
32
+ 'WCAG 1.3.1 Table Structure': { error: 0.97, warning: 0.75 },
33
+ 'WCAG 4.1.2 Dialog Accessibility': { error: 0.98, warning: 0.75 },
34
+ 'WCAG 4.1.2 ARIA Role Requirements': { error: 0.98, warning: 0.75 },
35
+ 'WCAG 1.3.5 Input Purpose': { error: 0.95, warning: 0.75 },
36
+ 'WCAG 2.4.7 Focus Visible': { error: 0.95, warning: 0.75 },
37
+ 'WCAG 3.1.1 Language of Page': { error: 0.99, warning: 0.80 }
38
+ }
39
+
40
+ function getConfidenceForRule(
41
+ ruleName: string,
42
+ type: 'error' | 'warning'
43
+ ): number {
44
+ const conf = RULE_CONFIDENCES[ruleName]
45
+ if (conf) {
46
+ return type === 'error' ? conf.error : conf.warning
47
+ }
48
+ return type === 'error' ? 0.98 : 0.75
49
+ }
50
+
51
+ function computeScore(issues: A11yIssue[]): number {
52
+ const errors = issues.filter((i) => i.type === 'error').length
53
+ const warnings = issues.filter((i) => i.type === 'warning').length
54
+ return Math.max(0, 100 - errors * 10 - warnings * 3)
55
+ }
56
+
57
+ function getPassingRules(results: RuleResult[]): string[] {
58
+ return results.filter((r) => r.issues.length === 0).map((r) => r.ruleName)
59
+ }
60
+
61
+ export function auditA11y(html: string): { score: number; passes: string[]; issues: A11yIssue[]; wcag: string } {
62
+ const root = parse(html)
63
+
64
+ const results: RuleResult[] = [
65
+ checkImages(root),
66
+ checkFormLabels(root),
67
+ checkEmptyLabels(root),
68
+ checkButtons(root),
69
+ checkLinks(root),
70
+ checkAriaStates(root),
71
+ checkDuplicateIds(root),
72
+ checkTabIndex(root),
73
+ checkHeadings(root),
74
+ checkTables(root),
75
+ checkDialogs(root),
76
+ checkRoles(root),
77
+ checkAutocomplete(root),
78
+ checkFocusStyle(root),
79
+ checkLang(root)
80
+ ]
81
+
82
+ const rawIssues = results.flatMap((r) => r.issues)
83
+ const issues: A11yIssue[] = rawIssues.map((i) => ({
84
+ ...i,
85
+ confidence: getConfidenceForRule(i.rule, i.type)
86
+ }))
87
+ const passes = getPassingRules(results)
88
+ const score = computeScore(issues)
89
+
90
+ return {
91
+ score,
92
+ passes,
93
+ issues,
94
+ wcag: 'AA'
95
+ }
96
+ }
@@ -0,0 +1,18 @@
1
+ const VOCAB_SIZE = 512
2
+
3
+ export function embedText(text: string): number[] {
4
+ const vector = new Array(VOCAB_SIZE).fill(0)
5
+ const words = text
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9\s]/g, ' ')
8
+ .split(/\s+/)
9
+ .filter(Boolean)
10
+ words.forEach((word) => {
11
+ let hash = 0
12
+ for (let i = 0; i < word.length; i++) {
13
+ hash = (hash * 31 + word.charCodeAt(i)) % VOCAB_SIZE
14
+ }
15
+ vector[hash] += 1
16
+ })
17
+ return vector
18
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod'
2
+ import type { MCPResponse } from '../types.js'
3
+ import { resolveTokens, buildCss } from './theme-tokens.js'
4
+
5
+ const schema = z.object({ prompt: z.string().min(1) })
6
+
7
+ export function generateTheme(args: unknown): MCPResponse {
8
+ const parsed = schema.safeParse(args)
9
+
10
+ if (!parsed.success) {
11
+ return {
12
+ content: [
13
+ {
14
+ type: 'text',
15
+ text: JSON.stringify({
16
+ error: 'Invalid input',
17
+ suggestion: 'Expected { prompt: string }',
18
+ tokens_used: 2
19
+ })
20
+ }
21
+ ]
22
+ }
23
+ }
24
+
25
+ const { prompt } = parsed.data
26
+ const tokens = resolveTokens(prompt.toLowerCase().trim())
27
+
28
+ return {
29
+ content: [
30
+ {
31
+ type: 'text',
32
+ text: JSON.stringify({
33
+ prompt,
34
+ primary: tokens.resolvedPrimary,
35
+ isDark: tokens.isDark,
36
+ css: buildCss(tokens),
37
+ tokens_used: 10
38
+ })
39
+ }
40
+ ]
41
+ }
42
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod'
2
+ import { manifests } from '../manifests/index.js'
3
+ import type { MCPResponse } from '../types.js'
4
+
5
+ const schema = z.object({
6
+ name: z.string()
7
+ })
8
+
9
+ export function getEmmet(args: unknown): MCPResponse {
10
+ const parsed = schema.safeParse(args)
11
+
12
+ if (!parsed.success) {
13
+ return {
14
+ content: [
15
+ {
16
+ type: 'text',
17
+
18
+ text: JSON.stringify({
19
+ error: 'Invalid input',
20
+
21
+ suggestion: 'Expected { name: string }',
22
+
23
+ tokens_used: 2
24
+ })
25
+ }
26
+ ]
27
+ }
28
+ }
29
+
30
+ const { name } = parsed.data
31
+
32
+ const manifest = manifests[name]
33
+
34
+ if (!manifest) {
35
+ return {
36
+ content: [
37
+ {
38
+ type: 'text',
39
+
40
+ text: JSON.stringify({
41
+ error: `Unknown component: ${name}`,
42
+ suggestion: 'Call list_components() first',
43
+ tokens_used: 2
44
+ })
45
+ }
46
+ ]
47
+ }
48
+ }
49
+
50
+ return {
51
+ content: [
52
+ {
53
+ type: 'text',
54
+
55
+ text: JSON.stringify({
56
+ component: manifest.component,
57
+ emmet: manifest.emmet,
58
+ tokens: manifest.tokens,
59
+ tokens_used: 3
60
+ })
61
+ }
62
+ ]
63
+ }
64
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod'
2
+ import { manifests } from '../manifests/index.js'
3
+ import type { MCPResponse } from '../types.js'
4
+
5
+ const schema = z.object({
6
+ name: z.string()
7
+ })
8
+
9
+ export function getManifest(args: unknown): MCPResponse {
10
+ const parsed = schema.safeParse(args)
11
+
12
+ if (!parsed.success) {
13
+ return {
14
+ content: [
15
+ {
16
+ type: 'text',
17
+ text: JSON.stringify({
18
+ error: 'Invalid input',
19
+ suggestion: 'Expected { name: string }',
20
+ tokens_used: 2
21
+ })
22
+ }
23
+ ]
24
+ }
25
+ }
26
+
27
+ const { name } = parsed.data
28
+
29
+ const manifest = manifests[name]
30
+
31
+ if (!manifest) {
32
+ return {
33
+ content: [
34
+ {
35
+ type: 'text',
36
+
37
+ text: JSON.stringify({
38
+ error: `Unknown component: ${name}`,
39
+ suggestion: 'Call list_components() first',
40
+ tokens_used: 2
41
+ })
42
+ }
43
+ ]
44
+ }
45
+ }
46
+
47
+ return {
48
+ content: [
49
+ {
50
+ type: 'text',
51
+ text: JSON.stringify(manifest)
52
+ }
53
+ ]
54
+ }
55
+ }
@@ -0,0 +1,302 @@
1
+ import { parse, type HTMLElement } from 'node-html-parser'
2
+ import { expandEmmet } from '../utils/emmet-helpers.js'
3
+ import { getTokenCount } from '../utils/tokenizer.js'
4
+ import type { MCPResponse } from '../types.js'
5
+
6
+ export interface HandoffComponent {
7
+ selector: string
8
+ emmet: string
9
+ state: Record<string, string>
10
+ tokens: number
11
+ }
12
+
13
+ export interface HandoffEnvelope {
14
+ schema: 'ignix-lite-handoff'
15
+ version: '1.0'
16
+ id: string
17
+ timestamp: number
18
+ html: string
19
+ components: HandoffComponent[]
20
+ total_tokens: number
21
+ metadata?: Record<string, unknown>
22
+ }
23
+
24
+ const MAX_HANDOFFS = 100
25
+ const handoffs = new Map<string, HandoffEnvelope>()
26
+
27
+ function saveHandoff(id: string, envelope: HandoffEnvelope): void {
28
+ if (handoffs.size >= MAX_HANDOFFS) {
29
+ const oldestKey = handoffs.keys().next().value
30
+ if (oldestKey !== undefined) {
31
+ handoffs.delete(oldestKey)
32
+ }
33
+ }
34
+ handoffs.set(id, envelope)
35
+ }
36
+
37
+ function generateHandoffId(): string {
38
+ return 'hndff_' + Math.random().toString(36).substring(2, 15)
39
+ }
40
+
41
+ function extractState(element: HTMLElement): Record<string, string> {
42
+ const state: Record<string, string> = {}
43
+ const attrs = [
44
+ 'data-intent',
45
+ 'disabled',
46
+ 'checked',
47
+ 'open',
48
+ 'aria-busy',
49
+ 'aria-invalid',
50
+ 'aria-selected',
51
+ 'aria-expanded'
52
+ ]
53
+ for (const attr of attrs) {
54
+ const val = element.getAttribute(attr)
55
+ if (val !== undefined && val !== null) {
56
+ state[attr] = val === '' ? 'true' : val
57
+ }
58
+ }
59
+ return state
60
+ }
61
+
62
+ function getElementSelector(el: HTMLElement, root: HTMLElement): string {
63
+ const elementId = el.getAttribute('id')
64
+ if (elementId) {
65
+ return `#${elementId}`
66
+ }
67
+
68
+ const tag = el.tagName.toLowerCase()
69
+ const parent = el.parentNode as HTMLElement | null
70
+
71
+ if (!parent || parent === root || !parent.tagName) {
72
+ const siblings = (root.childNodes || []).filter(
73
+ (node): node is HTMLElement => node.nodeType === 1
74
+ )
75
+ const sameTagSiblings = siblings.filter(
76
+ (node) => node.tagName && node.tagName.toLowerCase() === tag
77
+ )
78
+ const index = Math.max(1, sameTagSiblings.indexOf(el) + 1)
79
+ return `${tag}:nth-of-type(${index})`
80
+ }
81
+
82
+ const siblings = (parent.childNodes || []).filter(
83
+ (node): node is HTMLElement => node.nodeType === 1
84
+ )
85
+ const sameTagSiblings = siblings.filter(
86
+ (node) => node.tagName && node.tagName.toLowerCase() === tag
87
+ )
88
+ const index = Math.max(1, sameTagSiblings.indexOf(el) + 1)
89
+
90
+ const parentSelector = getElementSelector(parent, root)
91
+ return `${parentSelector} > ${tag}:nth-of-type(${index})`
92
+ }
93
+
94
+ export function createHandoff(args: {
95
+ rendered_html: string
96
+ metadata?: Record<string, unknown>
97
+ }): MCPResponse {
98
+ const { rendered_html, metadata } = args
99
+ const root = parse(rendered_html)
100
+
101
+ const id = generateHandoffId()
102
+ const timestamp = Date.now()
103
+ const components: HandoffComponent[] = []
104
+
105
+ const componentTags = [
106
+ 'button',
107
+ 'input',
108
+ 'textarea',
109
+ 'select',
110
+ 'aside',
111
+ 'mark',
112
+ 'article',
113
+ 'dialog',
114
+ 'details',
115
+ 'progress',
116
+ 'meter',
117
+ 'nav',
118
+ 'hr',
119
+ 'pre',
120
+ 'table',
121
+ 'ix-tabs',
122
+ 'ix-dropdown',
123
+ 'ix-combobox',
124
+ 'ix-tooltip',
125
+ 'ix-toast'
126
+ ]
127
+
128
+ const elements = root.querySelectorAll('*')
129
+ for (const el of elements) {
130
+ const tag = el.tagName.toLowerCase()
131
+ if (componentTags.includes(tag) || el.getAttribute('data-intent')) {
132
+ const selector = getElementSelector(el, root)
133
+
134
+ const outerHtml = el.outerHTML
135
+ const tokens = getTokenCount(outerHtml)
136
+ const state = extractState(el)
137
+
138
+ components.push({
139
+ selector,
140
+ emmet: outerHtml,
141
+ state,
142
+ tokens
143
+ })
144
+ }
145
+ }
146
+
147
+ const envelope: HandoffEnvelope = {
148
+ schema: 'ignix-lite-handoff',
149
+ version: '1.0',
150
+ id,
151
+ timestamp,
152
+ html: rendered_html,
153
+ components,
154
+ total_tokens: getTokenCount(rendered_html),
155
+ metadata
156
+ }
157
+
158
+ saveHandoff(id, envelope)
159
+
160
+ return {
161
+ content: [
162
+ {
163
+ type: 'text',
164
+ text: JSON.stringify({
165
+ handoff_id: id,
166
+ snapshot: {
167
+ schema: envelope.schema,
168
+ version: envelope.version,
169
+ id: envelope.id,
170
+ timestamp: envelope.timestamp,
171
+ components: envelope.components.map((c) => ({
172
+ selector: c.selector,
173
+ emmet: c.emmet,
174
+ state: c.state,
175
+ tokens: c.tokens
176
+ })),
177
+ total_tokens: envelope.total_tokens,
178
+ metadata: envelope.metadata
179
+ },
180
+ tokens_used: 10
181
+ })
182
+ }
183
+ ]
184
+ }
185
+ }
186
+
187
+ export interface HandoffChange {
188
+ selector: string
189
+ action: 'update' | 'add' | 'remove'
190
+ emmet?: string
191
+ html?: string
192
+ }
193
+
194
+ export function applyHandoff(args: {
195
+ handoff_id: string
196
+ changes: HandoffChange[]
197
+ }): MCPResponse {
198
+ const { handoff_id, changes } = args
199
+ const envelope = handoffs.get(handoff_id)
200
+
201
+ if (!envelope) {
202
+ return {
203
+ content: [
204
+ {
205
+ type: 'text',
206
+ text: JSON.stringify({
207
+ error: `Handoff snapshot not found: ${handoff_id}`,
208
+ tokens_used: 5
209
+ })
210
+ }
211
+ ]
212
+ }
213
+ }
214
+
215
+ // Assert that change.html or change.emmet is present for add and update actions
216
+ for (const change of changes) {
217
+ if (change.action === 'update' || change.action === 'add') {
218
+ const hasHtml = change.html !== undefined && change.html !== null && change.html.trim() !== ''
219
+ const hasEmmet = change.emmet !== undefined && change.emmet !== null && change.emmet.trim() !== ''
220
+ if (!hasHtml && !hasEmmet) {
221
+ return {
222
+ content: [
223
+ {
224
+ type: 'text',
225
+ text: JSON.stringify({
226
+ error: `Validation error: change for selector "${change.selector}" with action "${change.action}" must provide a non-empty "html" or "emmet" payload.`,
227
+ tokens_used: 5
228
+ })
229
+ }
230
+ ]
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ const root = parse(envelope.html)
237
+ let diffTokens = 0
238
+ const failedSelectors: string[] = []
239
+ const errors: string[] = []
240
+
241
+ const sortedChanges = [...changes].sort((a, b) => {
242
+ if (a.action === 'remove' && b.action !== 'remove') return 1
243
+ if (a.action !== 'remove' && b.action === 'remove') return -1
244
+ return 0
245
+ })
246
+
247
+ for (const change of sortedChanges) {
248
+ try {
249
+ const target = root.querySelector(change.selector)
250
+ if (!target) {
251
+ failedSelectors.push(change.selector)
252
+ continue
253
+ }
254
+
255
+ const changeContent =
256
+ change.html || (change.emmet ? expandEmmet(change.emmet) : '')
257
+ diffTokens += getTokenCount(changeContent || change.selector)
258
+
259
+ if (change.action === 'update') {
260
+ const newNode = parse(changeContent)
261
+ target.replaceWith(newNode)
262
+ } else if (change.action === 'add') {
263
+ const newNode = parse(changeContent)
264
+ target.appendChild(newNode)
265
+ } else if (change.action === 'remove') {
266
+ target.remove()
267
+ }
268
+ } catch (err: unknown) {
269
+ const message = err instanceof Error ? err.message : String(err)
270
+ errors.push(
271
+ `Failed to apply change on selector "${change.selector}": ${message}`
272
+ )
273
+ }
274
+ }
275
+
276
+ const updatedHtml = root.toString()
277
+ const fullTokens = getTokenCount(updatedHtml)
278
+ const savingsPct =
279
+ fullTokens > 0 ? Math.round((1 - diffTokens / fullTokens) * 100) : 0
280
+
281
+ envelope.html = updatedHtml
282
+ envelope.total_tokens = fullTokens
283
+ saveHandoff(handoff_id, envelope)
284
+
285
+ return {
286
+ content: [
287
+ {
288
+ type: 'text',
289
+ text: JSON.stringify({
290
+ updated_html: updatedHtml,
291
+ diff_tokens: diffTokens,
292
+ full_tokens: fullTokens,
293
+ savings_pct: Math.max(0, savingsPct),
294
+ failed_selectors:
295
+ failedSelectors.length > 0 ? failedSelectors : undefined,
296
+ errors: errors.length > 0 ? errors : undefined,
297
+ tokens_used: 15
298
+ })
299
+ }
300
+ ]
301
+ }
302
+ }