@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.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +94 -0
- package/src/agents/base.js +361 -0
- package/src/constants.js +47 -0
- package/src/enrichment/base.js +49 -0
- package/src/enrichment/enrichers/accessibility-enricher.js +197 -0
- package/src/enrichment/enrichers/dom-enricher.js +171 -0
- package/src/enrichment/enrichers/page-state-enricher.js +129 -0
- package/src/enrichment/enrichers/position-enricher.js +67 -0
- package/src/enrichment/index.js +96 -0
- package/src/enrichment/mcp-integration.js +149 -0
- package/src/enrichment/mcp-ref-enricher.js +78 -0
- package/src/enrichment/pipeline.js +192 -0
- package/src/enrichment/trace-text-enricher.js +115 -0
- package/src/framework/AGENTS.md +98 -0
- package/src/framework/agents/base.js +72 -0
- package/src/framework/agents/claude-strategy.js +278 -0
- package/src/framework/agents/cursor-strategy.js +459 -0
- package/src/framework/agents/index.js +105 -0
- package/src/framework/agents/utils/cursor-output-formatter.js +67 -0
- package/src/framework/agents/utils/openai-proxy-formatter.js +249 -0
- package/src/framework/code-generator.js +301 -0
- package/src/framework/constants.js +33 -0
- package/src/framework/context-loader.js +101 -0
- package/src/framework/function-bridge.js +78 -0
- package/src/framework/function-skill-registry.js +20 -0
- package/src/framework/graph-compiler.js +342 -0
- package/src/framework/graph.js +610 -0
- package/src/framework/index.js +28 -0
- package/src/framework/node-registry.js +163 -0
- package/src/framework/node.js +259 -0
- package/src/framework/output-parser.js +71 -0
- package/src/framework/skill-registry.js +55 -0
- package/src/framework/state-utils.js +52 -0
- package/src/framework/state.js +67 -0
- package/src/framework/tool-resolver.js +65 -0
- package/src/index.js +342 -0
- package/src/runtime/generation/base.js +46 -0
- package/src/runtime/generation/index.js +70 -0
- package/src/runtime/generation/mcp-ref-strategy.js +197 -0
- package/src/runtime/generation/stable-id-strategy.js +170 -0
- package/src/runtime/stable-id-runtime.js +248 -0
- package/src/runtime/verification/base.js +44 -0
- package/src/runtime/verification/index.js +67 -0
- package/src/runtime/verification/playwright-json-strategy.js +119 -0
- package/src/runtime/zibby-runtime.js +299 -0
- package/src/sync/index.js +2 -0
- package/src/sync/uploader.js +29 -0
- package/src/tools/run-playwright-test.js +158 -0
- package/src/utils/adf-converter.js +68 -0
- package/src/utils/ast-utils.js +37 -0
- package/src/utils/ci-setup.js +124 -0
- package/src/utils/cursor-utils.js +71 -0
- package/src/utils/logger.js +144 -0
- package/src/utils/mcp-config-writer.js +115 -0
- package/src/utils/node-schema-parser.js +522 -0
- package/src/utils/post-process-events.js +55 -0
- package/src/utils/result-handler.js +102 -0
- package/src/utils/ripple-effect.js +84 -0
- package/src/utils/selector-generator.js +239 -0
- package/src/utils/streaming-parser.js +387 -0
- package/src/utils/test-post-processor.js +211 -0
- package/src/utils/timeline.js +217 -0
- package/src/utils/trace-parser.js +325 -0
- package/src/utils/video-organizer.js +91 -0
- package/templates/browser-test-automation/README.md +114 -0
- package/templates/browser-test-automation/graph.js +54 -0
- package/templates/browser-test-automation/nodes/execute-live.js +250 -0
- package/templates/browser-test-automation/nodes/generate-script.js +77 -0
- package/templates/browser-test-automation/nodes/index.js +3 -0
- package/templates/browser-test-automation/nodes/preflight.js +59 -0
- package/templates/browser-test-automation/nodes/utils.js +154 -0
- package/templates/browser-test-automation/result-handler.js +286 -0
- package/templates/code-analysis/graph.js +72 -0
- package/templates/code-analysis/index.js +18 -0
- package/templates/code-analysis/nodes/analyze-ticket-node.js +204 -0
- package/templates/code-analysis/nodes/create-pr-node.js +175 -0
- package/templates/code-analysis/nodes/finalize-node.js +118 -0
- package/templates/code-analysis/nodes/generate-code-node.js +425 -0
- package/templates/code-analysis/nodes/generate-test-cases-node.js +376 -0
- package/templates/code-analysis/nodes/services/prMetaService.js +86 -0
- package/templates/code-analysis/nodes/setup-node.js +142 -0
- package/templates/code-analysis/prompts/analyze-ticket.md +181 -0
- package/templates/code-analysis/prompts/generate-code.md +33 -0
- package/templates/code-analysis/prompts/generate-test-cases.md +110 -0
- package/templates/code-analysis/state.js +40 -0
- package/templates/code-implementation/graph.js +35 -0
- package/templates/code-implementation/index.js +7 -0
- package/templates/code-implementation/state.js +14 -0
- package/templates/global-setup.js +56 -0
- package/templates/index.js +94 -0
- 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
|
+
}
|