@zibby/core 0.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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +94 -0
  4. package/src/agents/base.js +361 -0
  5. package/src/constants.js +47 -0
  6. package/src/enrichment/base.js +49 -0
  7. package/src/enrichment/enrichers/accessibility-enricher.js +197 -0
  8. package/src/enrichment/enrichers/dom-enricher.js +171 -0
  9. package/src/enrichment/enrichers/page-state-enricher.js +129 -0
  10. package/src/enrichment/enrichers/position-enricher.js +67 -0
  11. package/src/enrichment/index.js +96 -0
  12. package/src/enrichment/mcp-integration.js +149 -0
  13. package/src/enrichment/mcp-ref-enricher.js +78 -0
  14. package/src/enrichment/pipeline.js +192 -0
  15. package/src/enrichment/trace-text-enricher.js +115 -0
  16. package/src/framework/AGENTS.md +98 -0
  17. package/src/framework/agents/base.js +72 -0
  18. package/src/framework/agents/claude-strategy.js +278 -0
  19. package/src/framework/agents/cursor-strategy.js +459 -0
  20. package/src/framework/agents/index.js +105 -0
  21. package/src/framework/agents/utils/cursor-output-formatter.js +67 -0
  22. package/src/framework/agents/utils/openai-proxy-formatter.js +249 -0
  23. package/src/framework/code-generator.js +301 -0
  24. package/src/framework/constants.js +33 -0
  25. package/src/framework/context-loader.js +101 -0
  26. package/src/framework/function-bridge.js +78 -0
  27. package/src/framework/function-skill-registry.js +20 -0
  28. package/src/framework/graph-compiler.js +342 -0
  29. package/src/framework/graph.js +610 -0
  30. package/src/framework/index.js +28 -0
  31. package/src/framework/node-registry.js +163 -0
  32. package/src/framework/node.js +259 -0
  33. package/src/framework/output-parser.js +71 -0
  34. package/src/framework/skill-registry.js +55 -0
  35. package/src/framework/state-utils.js +52 -0
  36. package/src/framework/state.js +67 -0
  37. package/src/framework/tool-resolver.js +65 -0
  38. package/src/index.js +342 -0
  39. package/src/runtime/generation/base.js +46 -0
  40. package/src/runtime/generation/index.js +70 -0
  41. package/src/runtime/generation/mcp-ref-strategy.js +197 -0
  42. package/src/runtime/generation/stable-id-strategy.js +170 -0
  43. package/src/runtime/stable-id-runtime.js +248 -0
  44. package/src/runtime/verification/base.js +44 -0
  45. package/src/runtime/verification/index.js +67 -0
  46. package/src/runtime/verification/playwright-json-strategy.js +119 -0
  47. package/src/runtime/zibby-runtime.js +299 -0
  48. package/src/sync/index.js +2 -0
  49. package/src/sync/uploader.js +29 -0
  50. package/src/tools/run-playwright-test.js +158 -0
  51. package/src/utils/adf-converter.js +68 -0
  52. package/src/utils/ast-utils.js +37 -0
  53. package/src/utils/ci-setup.js +124 -0
  54. package/src/utils/cursor-utils.js +71 -0
  55. package/src/utils/logger.js +144 -0
  56. package/src/utils/mcp-config-writer.js +115 -0
  57. package/src/utils/node-schema-parser.js +522 -0
  58. package/src/utils/post-process-events.js +55 -0
  59. package/src/utils/result-handler.js +102 -0
  60. package/src/utils/ripple-effect.js +84 -0
  61. package/src/utils/selector-generator.js +239 -0
  62. package/src/utils/streaming-parser.js +387 -0
  63. package/src/utils/test-post-processor.js +211 -0
  64. package/src/utils/timeline.js +217 -0
  65. package/src/utils/trace-parser.js +325 -0
  66. package/src/utils/video-organizer.js +91 -0
  67. package/templates/browser-test-automation/README.md +114 -0
  68. package/templates/browser-test-automation/graph.js +54 -0
  69. package/templates/browser-test-automation/nodes/execute-live.js +250 -0
  70. package/templates/browser-test-automation/nodes/generate-script.js +77 -0
  71. package/templates/browser-test-automation/nodes/index.js +3 -0
  72. package/templates/browser-test-automation/nodes/preflight.js +59 -0
  73. package/templates/browser-test-automation/nodes/utils.js +154 -0
  74. package/templates/browser-test-automation/result-handler.js +286 -0
  75. package/templates/code-analysis/graph.js +72 -0
  76. package/templates/code-analysis/index.js +18 -0
  77. package/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
  78. package/templates/code-analysis/nodes/create-pr-node.js +175 -0
  79. package/templates/code-analysis/nodes/finalize-node.js +118 -0
  80. package/templates/code-analysis/nodes/generate-code-node.js +425 -0
  81. package/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
  82. package/templates/code-analysis/nodes/services/prMetaService.js +86 -0
  83. package/templates/code-analysis/nodes/setup-node.js +142 -0
  84. package/templates/code-analysis/prompts/analyze-ticket.md +181 -0
  85. package/templates/code-analysis/prompts/generate-code.md +33 -0
  86. package/templates/code-analysis/prompts/generate-test-cases.md +110 -0
  87. package/templates/code-analysis/state.js +40 -0
  88. package/templates/code-implementation/graph.js +35 -0
  89. package/templates/code-implementation/index.js +7 -0
  90. package/templates/code-implementation/state.js +14 -0
  91. package/templates/global-setup.js +56 -0
  92. package/templates/index.js +94 -0
  93. package/templates/register-nodes.js +24 -0
@@ -0,0 +1,84 @@
1
+ export const RIPPLE_EFFECT_SCRIPT = `
2
+ const style = document.createElement('style');
3
+ style.textContent = \`
4
+ @keyframes zibby-ripple {
5
+ 0% {
6
+ transform: scale(0);
7
+ opacity: 0.7;
8
+ }
9
+ 100% {
10
+ transform: scale(4);
11
+ opacity: 0;
12
+ }
13
+ }
14
+ .zibby-ripple {
15
+ position: absolute;
16
+ border-radius: 50%;
17
+ background: rgba(59, 130, 246, 0.7);
18
+ pointer-events: none;
19
+ animation: zibby-ripple 0.6s ease-out;
20
+ z-index: 999999;
21
+ }
22
+ .zibby-typing-indicator {
23
+ position: absolute;
24
+ border-radius: 50%;
25
+ border: 2px solid rgba(59, 130, 246, 0.8);
26
+ background: rgba(59, 130, 246, 0.1);
27
+ pointer-events: none;
28
+ z-index: 999999;
29
+ animation: zibby-pulse 1s ease-in-out infinite;
30
+ }
31
+ @keyframes zibby-pulse {
32
+ 0%, 100% { opacity: 1; }
33
+ 50% { opacity: 0.5; }
34
+ }
35
+ \`;
36
+
37
+ document.addEventListener('DOMContentLoaded', () => {
38
+ if (document.head && !document.getElementById('zibby-ripple-style')) {
39
+ style.id = 'zibby-ripple-style';
40
+ document.head.appendChild(style);
41
+ }
42
+ });
43
+
44
+ if (document.head && !document.getElementById('zibby-ripple-style')) {
45
+ style.id = 'zibby-ripple-style';
46
+ document.head.appendChild(style);
47
+ }
48
+
49
+ window.__zibbyShowRipple = function(x, y, isTyping = false) {
50
+ const ripple = document.createElement('div');
51
+ ripple.className = 'zibby-ripple';
52
+ ripple.style.left = (x - 10) + 'px';
53
+ ripple.style.top = (y - 10) + 'px';
54
+ ripple.style.width = '20px';
55
+ ripple.style.height = '20px';
56
+
57
+ if (document.body) {
58
+ document.body.appendChild(ripple);
59
+ setTimeout(() => ripple.remove(), 600);
60
+ }
61
+ };
62
+ `;
63
+
64
+ export function injectRippleEffect(page) {
65
+ return page.addInitScript(RIPPLE_EFFECT_SCRIPT);
66
+ }
67
+
68
+ export function generateRippleHelperCode() {
69
+ return `
70
+ async function showRipple(page, locator) {
71
+ const box = await locator.boundingBox().catch(() => null);
72
+ if (box) {
73
+ const x = box.x + box.width / 2;
74
+ const y = box.y + box.height / 2;
75
+ await page.evaluate((coords) => {
76
+ if (window.__zibbyShowRipple) {
77
+ window.__zibbyShowRipple(coords.x, coords.y, false);
78
+ }
79
+ }, { x, y }).catch(() => {});
80
+ }
81
+ }
82
+ `.trim();
83
+ }
84
+
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Generates robust Playwright selector code with .or() fallback chains
3
+ * @module SelectorGenerator
4
+ */
5
+
6
+ export class SelectorGenerator {
7
+ /**
8
+ * Generate multi-strategy selector code from captured selectors
9
+ * @param {Object} action - Action object from execute_live with selectors
10
+ * @param {string} variableName - Variable name for the locator
11
+ * @returns {string} Generated code with .or() fallback chain
12
+ */
13
+ static generate(action, variableName = 'element') {
14
+ const { selectors } = action;
15
+
16
+ if (!selectors || typeof selectors !== 'object') {
17
+ return this.generateFallbackSelector(action, variableName);
18
+ }
19
+
20
+ const strategies = [];
21
+
22
+ // Priority 1: Role-based (most robust)
23
+ if (selectors.role) {
24
+ strategies.push(this.generateRoleSelector(selectors.role));
25
+ }
26
+
27
+ // Priority 2: Stable attributes
28
+ if (selectors.attributes) {
29
+ strategies.push(this.generateAttributeSelector(selectors.attributes));
30
+ }
31
+
32
+ // Priority 3: Partial match (for dynamic elements)
33
+ if (selectors.partialMatch) {
34
+ strategies.push(this.generatePartialMatchSelector(selectors.partialMatch));
35
+ }
36
+
37
+ // Priority 4: Structural fallback
38
+ if (selectors.structure) {
39
+ strategies.push(this.generateStructuralSelector(selectors.structure));
40
+ }
41
+
42
+ if (strategies.length === 0) {
43
+ return this.generateFallbackSelector(action, variableName);
44
+ }
45
+
46
+ // Single strategy - no .or() needed
47
+ if (strategies.length === 1) {
48
+ return `const ${variableName} = ${strategies[0]};`;
49
+ }
50
+
51
+ // Multi-strategy with .or() chain
52
+ const code = `const ${variableName} = ${strategies[0]}\n${
53
+ strategies.slice(1).map(s => ` .or(${s})`).join('\n') };`;
54
+
55
+ return code;
56
+ }
57
+
58
+ /**
59
+ * Generate role-based selector (most robust)
60
+ */
61
+ static generateRoleSelector(roleInfo) {
62
+ const { role, name } = roleInfo;
63
+
64
+ if (!role) return null;
65
+
66
+ if (name) {
67
+ // Use regex for i18n and case-insensitive matching
68
+ const namePattern = this.escapeRegex(name);
69
+ return `page.getByRole('${role}', { name: /${namePattern}/i })`;
70
+ }
71
+
72
+ return `page.getByRole('${role}')`;
73
+ }
74
+
75
+ /**
76
+ * Generate attribute-based selector
77
+ */
78
+ static generateAttributeSelector(attributes) {
79
+ if (!attributes || typeof attributes !== 'object') return null;
80
+
81
+ const attrPairs = Object.entries(attributes)
82
+ .filter(([_key, value]) => value !== undefined && value !== null)
83
+ .map(([key, value]) => {
84
+ // Handle special cases
85
+ if (key === 'placeholder' || key === 'aria-label') {
86
+ // Use getByPlaceholder for better semantics
87
+ if (key === 'placeholder') {
88
+ return `page.getByPlaceholder('${this.escapeString(value)}')`;
89
+ }
90
+ return `page.locator('[${key}="${this.escapeString(value)}"]')`;
91
+ }
92
+ return `[${key}="${this.escapeString(value)}"]`;
93
+ });
94
+
95
+ // If we have a placeholder, return it directly
96
+ if (attributes.placeholder) {
97
+ return `page.getByPlaceholder('${this.escapeString(attributes.placeholder)}')`;
98
+ }
99
+
100
+ // Otherwise combine attributes
101
+ const selector = attrPairs
102
+ .filter(p => !p.startsWith('page.'))
103
+ .join('');
104
+
105
+ return selector ? `page.locator('${selector}')` : null;
106
+ }
107
+
108
+ /**
109
+ * Generate partial match selector for dynamic elements
110
+ */
111
+ static generatePartialMatchSelector(partialMatch) {
112
+ if (!partialMatch || typeof partialMatch !== 'object') return null;
113
+
114
+ const matches = Object.entries(partialMatch)
115
+ .filter(([_key, value]) => value !== undefined)
116
+ .map(([key, pattern]) => {
117
+ // Remove leading ^ if present (we'll add it)
118
+ const cleanPattern = pattern.replace(/^\^/, '');
119
+ return `[${key}^="${this.escapeString(cleanPattern)}"]`;
120
+ });
121
+
122
+ return matches.length > 0 ? `page.locator('${matches.join('')}')` : null;
123
+ }
124
+
125
+ /**
126
+ * Generate structural selector (last resort)
127
+ */
128
+ static generateStructuralSelector(structure) {
129
+ if (!structure || typeof structure !== 'string') return null;
130
+
131
+ return `page.locator('${this.escapeString(structure)}')`;
132
+ }
133
+
134
+ /**
135
+ * Generate fallback selector when no selectors provided
136
+ * Try to infer from description
137
+ */
138
+ static generateFallbackSelector(action, variableName) {
139
+ const { description, type } = action;
140
+
141
+ // Try to extract hints from description
142
+ if (type === 'fill' || type === 'type') {
143
+ return `const ${variableName} = page.locator('input').first();`;
144
+ }
145
+
146
+ if (type === 'click') {
147
+ if (description.toLowerCase().includes('button')) {
148
+ return `const ${variableName} = page.locator('button').first();`;
149
+ }
150
+ if (description.toLowerCase().includes('link')) {
151
+ return `const ${variableName} = page.locator('a').first();`;
152
+ }
153
+ }
154
+
155
+ // Generic fallback
156
+ return `const ${variableName} = page.locator('body');`;
157
+ }
158
+
159
+ /**
160
+ * Escape string for use in selector
161
+ */
162
+ static escapeString(str) {
163
+ if (typeof str !== 'string') return String(str);
164
+ return str
165
+ .replace(/\\/g, '\\\\')
166
+ .replace(/'/g, "\\'")
167
+ .replace(/"/g, '\\"');
168
+ }
169
+
170
+ /**
171
+ * Escape string for use in regex
172
+ */
173
+ static escapeRegex(str) {
174
+ if (typeof str !== 'string') return String(str);
175
+ // Escape special regex characters but allow | for alternation
176
+ return str.replace(/[.*+?^${}()[\]\\]/g, '\\$&');
177
+ }
178
+
179
+ /**
180
+ * Generate complete action code (selector + action)
181
+ * @param {Object} action - Action from execute_live
182
+ * @param {number} index - Action index for unique variable names
183
+ * @returns {string} Complete code for this action
184
+ */
185
+ static generateActionCode(action, index) {
186
+ const { type, value, description: _desc, selectors: _selectors } = action;
187
+
188
+ const varName = `element${index}`;
189
+ const selectorCode = this.generate(action, varName);
190
+ const actionCode = this.generateActionMethod(type, varName, value);
191
+
192
+ return `${selectorCode}\n${actionCode}`;
193
+ }
194
+
195
+ /**
196
+ * Generate the action method call (fill, click, etc.)
197
+ */
198
+ static generateActionMethod(type, varName, value) {
199
+ switch (type) {
200
+ case 'fill':
201
+ case 'type':
202
+ return `await ${varName}.fill('${this.escapeString(value || '')}');`;
203
+
204
+ case 'click':
205
+ return `await ${varName}.click();`;
206
+
207
+ case 'navigate':
208
+ return `await page.goto('${this.escapeString(value || '')}');`;
209
+
210
+ case 'wait': {
211
+ const duration = parseInt(value) || 2000;
212
+ return `await page.waitForTimeout(${duration});`;
213
+ }
214
+
215
+ default:
216
+ return `// Unknown action type: ${type}`;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Generate assertion code from live execution assertions
222
+ */
223
+ static generateAssertionCode(assertion, _index) {
224
+ const { description, expected: _expected, actual, passed: _passed } = assertion;
225
+
226
+ if (description.toLowerCase().includes('url')) {
227
+ return `await expect(page).toHaveURL(/${this.escapeRegex(actual)}/);`;
228
+ }
229
+
230
+ if (description.toLowerCase().includes('visible')) {
231
+ return `await expect(page.locator('body')).toBeVisible();`;
232
+ }
233
+
234
+ // Generic assertion based on description
235
+ return `// ${description}`;
236
+ }
237
+ }
238
+
239
+ export default SelectorGenerator;
@@ -0,0 +1,387 @@
1
+ /**
2
+ * StreamingParser - Handles NDJSON streaming output from cursor-agent
3
+ *
4
+ * Cursor agent outputs newline-delimited JSON where each line is a message.
5
+ * We extract text content and look for result JSON.
6
+ */
7
+
8
+ export class StreamingParser {
9
+ constructor() {
10
+ this.buffer = '';
11
+ this.extractedResult = null;
12
+ this.rawText = '';
13
+ this.zodSchema = null;
14
+ this.lastOutputLength = 0;
15
+ this.onToolCall = null;
16
+ this._lastToolEmit = null;
17
+ }
18
+
19
+ /**
20
+ * Process a chunk of data from the stream
21
+ * @param {string} chunk - New data from stdout
22
+ * @returns {string|null} - Text to display to user, or null if nothing to display
23
+ */
24
+ processChunk(chunk) {
25
+ if (!chunk) return null;
26
+
27
+ // cursor-agent streams NDJSON - process complete lines, but also try partial parsing
28
+ this.buffer += chunk;
29
+ const lines = this.buffer.split('\n');
30
+
31
+ // Keep the last incomplete line in the buffer
32
+ this.buffer = lines.pop() || '';
33
+
34
+ let displayText = '';
35
+
36
+ for (const line of lines) {
37
+ if (!line.trim()) continue; // Skip empty lines
38
+
39
+ try {
40
+ const jsonLine = JSON.parse(line);
41
+ this._emitToolCalls(jsonLine);
42
+ const text = this.extractText(jsonLine);
43
+
44
+ if (text) {
45
+ // cursor-agent may send full text each time OR incremental deltas
46
+ // Check if this text is new (not already in rawText)
47
+ // If text starts with what we already have, it's full text - only output new part
48
+ if (this.rawText && text.startsWith(this.rawText)) {
49
+ // Full text - only output the new part
50
+ const newPart = text.substring(this.rawText.length);
51
+ this.rawText = text; // Update to full text
52
+ displayText += newPart;
53
+ } else if (!this.rawText.includes(text) || text.length < 20) {
54
+ // Incremental delta or short new text - output it
55
+ this.rawText += text;
56
+ displayText += text;
57
+ }
58
+ // else: duplicate, skip
59
+
60
+ this.tryExtractResult(this.rawText);
61
+ } else if (this.isValidResult(jsonLine)) {
62
+ // Valid result JSON
63
+ this.rawText += `${line }\n`;
64
+ displayText += `${line }\n`;
65
+ this.extractedResult = jsonLine;
66
+ }
67
+ // else: Protocol message without text - ignore silently
68
+ } catch (_e) {
69
+ // Partial JSON - try to extract text from partial if it looks like thinking/reasoning
70
+ // This allows streaming even before complete JSON arrives
71
+ if (line.includes('"text"') || line.includes('"content"')) {
72
+ // Try to extract partial text for immediate display
73
+ const textMatch = line.match(/"text"\s*:\s*"([^"]*)/);
74
+ const contentMatch = line.match(/"content"\s*:\s*"([^"]*)/);
75
+ const partialText = textMatch ? textMatch[1] : (contentMatch ? contentMatch[1] : null);
76
+
77
+ if (partialText && !this.rawText.includes(partialText)) {
78
+ // Output partial text immediately for typewriter effect
79
+ displayText += partialText;
80
+ this.rawText += partialText;
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // Return text immediately for typewriter effect
87
+ return displayText || null;
88
+ }
89
+
90
+ /**
91
+ * Flush any remaining buffer
92
+ * @returns {string|null} - Remaining text to display
93
+ */
94
+ flush() {
95
+ if (!this.buffer.trim()) {
96
+ return null;
97
+ }
98
+
99
+ let displayText = '';
100
+
101
+ try {
102
+ const jsonLine = JSON.parse(this.buffer);
103
+ this._emitToolCalls(jsonLine);
104
+ const text = this.extractText(jsonLine);
105
+
106
+ if (text) {
107
+ this.rawText += text;
108
+ displayText += text;
109
+ this.tryExtractResult(text);
110
+ }
111
+ } catch (_e) {
112
+ // Not JSON - treat as plain text
113
+ this.rawText += this.buffer;
114
+ displayText += this.buffer;
115
+
116
+ // Also try to extract result from buffer
117
+ this.tryExtractResult(this.buffer);
118
+ }
119
+
120
+ this.buffer = '';
121
+ return displayText || null;
122
+ }
123
+
124
+ /**
125
+ * Detect tool calls from any known NDJSON shape and invoke onToolCall (Cursor/Claude variants).
126
+ */
127
+ _emitToolCalls(jsonLine) {
128
+ if (!this.onToolCall) return;
129
+ const emit = (name, input) => {
130
+ if (!name) return;
131
+ const key = `${name}:${JSON.stringify(input ?? {})}`;
132
+ if (this._lastToolEmit === key) return;
133
+ this._lastToolEmit = key;
134
+ this.onToolCall(name, input != null ? input : undefined);
135
+ };
136
+ const normInput = (obj) => {
137
+ if (obj == null) return undefined;
138
+ if (typeof obj === 'object' && !Array.isArray(obj)) return obj;
139
+ if (typeof obj === 'string') {
140
+ try { return JSON.parse(obj); } catch (_) { return undefined; }
141
+ }
142
+ return undefined;
143
+ };
144
+
145
+ if (jsonLine.type === 'tool_use' || jsonLine.type === 'tool_call') {
146
+ if (jsonLine.name) {
147
+ emit(jsonLine.name, normInput(jsonLine.input ?? jsonLine.arguments));
148
+ return;
149
+ }
150
+ // Cursor CLI: type "tool_call", tool_call: { toolName: { args } }
151
+ const tc = jsonLine.tool_call;
152
+ if (tc && typeof tc === 'object' && !Array.isArray(tc)) {
153
+ const keys = Object.keys(tc);
154
+ if (keys.length === 1) {
155
+ const name = keys[0];
156
+ const payload = tc[name];
157
+ const input = payload && typeof payload === 'object' ? (payload.args ?? payload.input ?? payload) : undefined;
158
+ emit(name, normInput(input));
159
+ }
160
+ return;
161
+ }
162
+ return;
163
+ }
164
+ if (Array.isArray(jsonLine.tool_calls)) {
165
+ for (const t of jsonLine.tool_calls) {
166
+ emit(t.name, normInput(t.input ?? t.arguments));
167
+ }
168
+ return;
169
+ }
170
+ const msg = jsonLine.message ?? jsonLine;
171
+ if (Array.isArray(msg?.tool_calls)) {
172
+ for (const t of msg.tool_calls) {
173
+ emit(t.name, normInput(t.input ?? t.arguments));
174
+ }
175
+ return;
176
+ }
177
+ const content = msg?.content ?? jsonLine.content;
178
+ if (Array.isArray(content)) {
179
+ for (const item of content) {
180
+ if ((item.type === 'tool_use' || item.type === 'tool_call') && item.name) {
181
+ emit(item.name, normInput(item.input ?? item.arguments));
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Extract text content from a streaming JSON message
189
+ * @param {Object} jsonLine - Parsed JSON line
190
+ * @returns {string|null} - Extracted text or null
191
+ */
192
+ extractText(jsonLine) {
193
+ if (jsonLine.type === 'assistant' && jsonLine.message?.content) {
194
+ const content = jsonLine.message.content;
195
+ if (Array.isArray(content)) {
196
+ return content
197
+ .filter(item => item.type === 'text' && item.text)
198
+ .map(item => item.text)
199
+ .join('');
200
+ }
201
+ }
202
+
203
+ if (jsonLine.type === 'thinking' && jsonLine.text) {
204
+ return jsonLine.text;
205
+ }
206
+
207
+ if (jsonLine.text) return jsonLine.text;
208
+ if (jsonLine.content && typeof jsonLine.content === 'string') return jsonLine.content;
209
+ if (jsonLine.delta) return jsonLine.delta;
210
+
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Extract JSON - SIMPLE AND BULLETPROOF
216
+ * Find the LARGEST valid JSON object in the text
217
+ */
218
+ tryExtractResult(text) {
219
+ if (!text || typeof text !== 'string') return;
220
+
221
+ const candidates = [];
222
+
223
+ // 1. Extract ALL JSON from markdown code fences
224
+ // CRITICAL: Match the COMPLETE block from opening to closing fence
225
+ // Made more flexible - closing fence can be on same line or next line
226
+ const codeBlockRegex = /```json\s*\n?([\s\S]*?)\n?```/g;
227
+ let match;
228
+ while ((match = codeBlockRegex.exec(text)) !== null) {
229
+ const jsonText = match[1].trim();
230
+ // Validate it's actually parseable before adding
231
+ try {
232
+ JSON.parse(jsonText);
233
+ candidates.push({ text: jsonText, source: 'markdown' });
234
+ } catch (_e) {
235
+ // Skip invalid JSON
236
+ }
237
+ }
238
+
239
+ // 2. Extract ALL complete JSON objects using brace counting
240
+ let start = 0;
241
+ let _braceCount = 0;
242
+ while (start < text.length) {
243
+ start = text.indexOf('{', start);
244
+ if (start === -1) break;
245
+
246
+ let depth = 0;
247
+ let end = start;
248
+ for (let i = start; i < text.length; i++) {
249
+ if (text[i] === '{') depth++;
250
+ else if (text[i] === '}') {
251
+ depth--;
252
+ if (depth === 0) {
253
+ end = i;
254
+ candidates.push({ text: text.substring(start, end + 1), source: 'brace' });
255
+ _braceCount++;
256
+ break;
257
+ }
258
+ }
259
+ }
260
+ start = end + 1;
261
+ }
262
+
263
+ // 3. Parse all candidates and pick the LAST valid one (handles self-correction)
264
+ let bestResult = this.extractedResult; // KEEP PREVIOUS BEST
265
+ let _bestSize = bestResult ? JSON.stringify(bestResult).length : 0;
266
+ let _validCount = 0;
267
+ let _bestPosition = -1;
268
+
269
+ // Suppress verbose logging during streaming (only log when we find valid result)
270
+ // if (candidates.length > 0) {
271
+ // console.log(`🔍 [StreamingParser] Found ${candidates.length} JSON candidates (${candidates.filter(c => c.source === 'markdown').length} from markdown, ${candidates.filter(c => c.source === 'brace').length} from brace counting)`);
272
+ // }
273
+
274
+ for (let i = 0; i < candidates.length; i++) {
275
+ const candidate = candidates[i];
276
+ try {
277
+ // Fix common JSON issues: trailing commas
278
+ const jsonText = candidate.text.replace(/,(\s*[}\]])/g, '$1');
279
+
280
+ const parsed = JSON.parse(jsonText);
281
+ const isValid = this.isValidResult(parsed);
282
+
283
+ if (isValid) {
284
+ _validCount++;
285
+ const size = JSON.stringify(parsed).length;
286
+
287
+ // ALWAYS take the latest valid JSON (Cursor self-corrects over time)
288
+ // Don't compare size - just use the most recent one
289
+ _bestSize = size;
290
+ bestResult = parsed;
291
+ _bestPosition = i;
292
+ }
293
+ // Suppress protocol message and parse error logs during streaming
294
+ // else {
295
+ // const keys = Object.keys(parsed);
296
+ // console.log(` ✗ Invalid candidate (${candidate.source}): Protocol message with keys [${keys.join(', ')}]`);
297
+ // }
298
+ } catch (_e) {
299
+ // Invalid JSON, skip silently during streaming
300
+ // console.log(` ✗ Invalid JSON (${candidate.source}): Parse error`);
301
+ }
302
+ }
303
+
304
+ // ONLY UPDATE if we found something valid
305
+ if (bestResult) {
306
+ this.extractedResult = bestResult;
307
+ // Self-correction happens naturally - no need to log (Cursor iterates/refines its JSON)
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Check if a parsed object is a valid result (not a protocol message)
313
+ * @param {Object} obj - Parsed JSON object
314
+ * @returns {boolean} - True if valid result
315
+ */
316
+ isValidResult(obj) {
317
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
318
+ return false;
319
+ }
320
+
321
+ // Filter out protocol messages
322
+ if (obj.session_id || obj.timestamp_ms || obj.type || obj.call_id || obj.tool_call) {
323
+ return false;
324
+ }
325
+
326
+ // Filter out tool call result wrappers
327
+ // Tool calls return: { result: { success: {...} } } or { result: { error: {...} } }
328
+ // User JSON returns: { result: "string" } or { success: boolean, ... }
329
+ if (obj.result && typeof obj.result === 'object') {
330
+ if ((obj.result.success && typeof obj.result.success === 'object') ||
331
+ (obj.result.error && typeof obj.result.error === 'object')) {
332
+ return false;
333
+ }
334
+ }
335
+
336
+ // Filter out objects that have 'args' property (tool call objects)
337
+ if (obj.args && typeof obj.args === 'object') return false;
338
+
339
+ // If we have a schema, validate against it
340
+ if (this.zodSchema) {
341
+ try {
342
+ this.zodSchema.parse(obj);
343
+ return true;
344
+ } catch (_e) {
345
+ return false;
346
+ }
347
+ }
348
+
349
+ return true;
350
+ }
351
+
352
+ /**
353
+ * Get the extracted result object
354
+ * @returns {Object|null} - Extracted result or null
355
+ */
356
+ getResult() {
357
+ return this.extractedResult;
358
+ }
359
+
360
+ /**
361
+ * Get all accumulated raw text
362
+ * @returns {string} - All raw text
363
+ */
364
+ getRawText() {
365
+ return this.rawText;
366
+ }
367
+
368
+ /**
369
+ * Static helper to extract result from raw output
370
+ * @param {string} raw - Raw output text
371
+ * @param {Object} schema - Optional Zod schema to validate candidates
372
+ * @returns {Object|null} - Extracted result or null
373
+ */
374
+ static extractResult(raw, schema = null) {
375
+ const parser = new StreamingParser();
376
+ parser.zodSchema = schema; // Pass schema for validation
377
+ parser.processChunk(raw);
378
+ parser.flush();
379
+ const result = parser.getResult();
380
+
381
+ if (!result && process.env.LOG_LEVEL === 'debug') {
382
+ console.error('[StreamingParser] No result extracted from', raw?.length || 0, 'chars');
383
+ }
384
+
385
+ return result;
386
+ }
387
+ }