@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,459 @@
|
|
|
1
|
+
import { AgentStrategy } from './base.js';
|
|
2
|
+
import { spawn, execSync } from 'child_process';
|
|
3
|
+
import { writeFileSync, unlinkSync, mkdtempSync, readFileSync, mkdirSync, existsSync, accessSync, constants } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir, homedir } from 'os';
|
|
6
|
+
import { logger } from '../../utils/logger.js';
|
|
7
|
+
import { DEFAULT_MODELS, TIMEOUTS } from '../../constants.js';
|
|
8
|
+
import { DEFAULT_OUTPUT_BASE, SESSION_INFO_FILE } from '../constants.js';
|
|
9
|
+
import { getAllSkills } from '../skill-registry.js';
|
|
10
|
+
import { StreamingParser } from '../../utils/streaming-parser.js';
|
|
11
|
+
import { CursorOutputFormatter } from './utils/cursor-output-formatter.js';
|
|
12
|
+
import { formatWithOpenAIProxy } from './utils/openai-proxy-formatter.js';
|
|
13
|
+
import { timeline } from '../../utils/timeline.js';
|
|
14
|
+
|
|
15
|
+
export class CursorAgentStrategy extends AgentStrategy {
|
|
16
|
+
constructor() {
|
|
17
|
+
super(
|
|
18
|
+
'cursor',
|
|
19
|
+
'Cursor Agent (Local IDE Agent)',
|
|
20
|
+
100
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
canHandle(_context) {
|
|
25
|
+
const paths = [
|
|
26
|
+
'agent',
|
|
27
|
+
'/usr/local/bin/agent',
|
|
28
|
+
'/usr/local/bin/cursor-agent',
|
|
29
|
+
'/Applications/Cursor.app/Contents/Resources/app/bin/cursor'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
for (const path of paths) {
|
|
33
|
+
try {
|
|
34
|
+
if (path.startsWith('/')) {
|
|
35
|
+
accessSync(path, constants.X_OK);
|
|
36
|
+
logger.debug(`[Cursor] Found agent at: ${path}`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
execSync(`${path} --version 2>/dev/null`, { stdio: 'ignore', timeout: 2000 });
|
|
40
|
+
logger.debug(`[Cursor] Found '${path}' in PATH`);
|
|
41
|
+
return true;
|
|
42
|
+
} catch (_e) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
logger.warn('[Cursor] ❌ Cursor Agent CLI not found');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async invoke(prompt, options = {}) {
|
|
52
|
+
const {
|
|
53
|
+
workspace = process.cwd(),
|
|
54
|
+
print: _print = false,
|
|
55
|
+
schema = null,
|
|
56
|
+
skills = null,
|
|
57
|
+
sessionPath = null,
|
|
58
|
+
timeout = TIMEOUTS.CURSOR_AGENT_DEFAULT,
|
|
59
|
+
config = {}
|
|
60
|
+
} = options;
|
|
61
|
+
|
|
62
|
+
const strictMode = config?.agent?.strictMode || false;
|
|
63
|
+
|
|
64
|
+
// Model: prefer .zibby.config.js agent.cursor.model so CLI uses it instead of IDE default
|
|
65
|
+
const model = options.model ?? config?.agent?.cursor?.model ?? DEFAULT_MODELS.CURSOR;
|
|
66
|
+
|
|
67
|
+
logger.debug(`[Cursor] Invoking (model: ${model}, timeout: ${timeout / 1000}s, skills: ${JSON.stringify(skills)})`);
|
|
68
|
+
|
|
69
|
+
this._setupMcpConfig(sessionPath, workspace, config);
|
|
70
|
+
|
|
71
|
+
const possibleBins = [
|
|
72
|
+
'agent',
|
|
73
|
+
'/usr/local/bin/agent',
|
|
74
|
+
'/usr/local/bin/cursor-agent',
|
|
75
|
+
'/Applications/Cursor.app/Contents/Resources/app/bin/cursor'
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
let cursorBin = 'agent';
|
|
79
|
+
for (const bin of possibleBins) {
|
|
80
|
+
if (bin === 'agent') {
|
|
81
|
+
cursorBin = bin;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (existsSync(bin)) {
|
|
85
|
+
cursorBin = bin;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// File-based structured output: agent writes JSON to this file
|
|
91
|
+
let resultFilePath = null;
|
|
92
|
+
if (schema) {
|
|
93
|
+
const resultFileName = `zibby-result-${Date.now()}.json`;
|
|
94
|
+
resultFilePath = join(workspace, '.zibby', 'tmp', resultFileName);
|
|
95
|
+
const tmpDir = join(workspace, '.zibby', 'tmp');
|
|
96
|
+
if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
|
|
97
|
+
|
|
98
|
+
const formatInstructions = CursorOutputFormatter.generateFileOutputInstructions(schema, resultFilePath);
|
|
99
|
+
prompt = `${prompt}\n\n${formatInstructions}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const promptTmpDir = mkdtempSync(join(tmpdir(), 'zibby-prompt-'));
|
|
103
|
+
const promptFile = join(promptTmpDir, 'prompt.md');
|
|
104
|
+
writeFileSync(promptFile, prompt, 'utf-8');
|
|
105
|
+
logger.debug(`📝 [Agent] Prompt written to ${promptFile} (${prompt.length} chars)`);
|
|
106
|
+
|
|
107
|
+
let args;
|
|
108
|
+
if (cursorBin === 'agent') {
|
|
109
|
+
args = [
|
|
110
|
+
'chat',
|
|
111
|
+
'--print',
|
|
112
|
+
'--force',
|
|
113
|
+
'--approve-mcps',
|
|
114
|
+
'--output-format', 'stream-json',
|
|
115
|
+
'--stream-partial-output',
|
|
116
|
+
'--model', model || 'auto',
|
|
117
|
+
];
|
|
118
|
+
if (process.env.CURSOR_API_KEY) {
|
|
119
|
+
args.push('--api-key', process.env.CURSOR_API_KEY);
|
|
120
|
+
}
|
|
121
|
+
args.push(`@${promptFile}`);
|
|
122
|
+
} else {
|
|
123
|
+
args = ['agent', '--print', '--force', '--trust', '--approve-mcps'];
|
|
124
|
+
if (process.env.CURSOR_API_KEY) {
|
|
125
|
+
args.push('--api-key', process.env.CURSOR_API_KEY);
|
|
126
|
+
}
|
|
127
|
+
args.push('--model', model || 'auto', `@${promptFile}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fullCmd = [cursorBin, ...args].join(' ');
|
|
131
|
+
logger.debug(`[Agent] Executing: ${fullCmd.slice(0, 200)}`);
|
|
132
|
+
logger.debug(`[Agent] Workspace: ${workspace}`);
|
|
133
|
+
if (process.env.LOG_LEVEL === 'debug' || process.env.ZIBBY_LOG_CURSOR_CLI === '1') {
|
|
134
|
+
console.log(`\n🔧 Cursor CLI --model ${model || 'auto'} (from .zibby.config.js agent.cursor.model)\n`);
|
|
135
|
+
if (process.env.ZIBBY_LOG_CURSOR_CLI === '1') {
|
|
136
|
+
console.log(` Full command: ${fullCmd}\n`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let spawnResult;
|
|
141
|
+
let spawnError = null;
|
|
142
|
+
try {
|
|
143
|
+
spawnResult = await this._spawnWithStreaming(cursorBin, args, workspace, timeout, prompt);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
spawnError = err;
|
|
146
|
+
} finally {
|
|
147
|
+
try { unlinkSync(promptFile); } catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const rawOutput = spawnResult?.stdout || '';
|
|
151
|
+
|
|
152
|
+
if (schema) {
|
|
153
|
+
const isZodSchema = typeof schema.parse === 'function';
|
|
154
|
+
|
|
155
|
+
// Step 1: Read the file the agent was told to write (even if agent crashed)
|
|
156
|
+
let fileContent = null;
|
|
157
|
+
if (resultFilePath && existsSync(resultFilePath)) {
|
|
158
|
+
try {
|
|
159
|
+
const raw = readFileSync(resultFilePath, 'utf-8').trim();
|
|
160
|
+
fileContent = JSON.parse(raw);
|
|
161
|
+
logger.debug(`[Agent] Read structured output file (${raw.length} chars)`);
|
|
162
|
+
if (spawnError) {
|
|
163
|
+
logger.debug(`[Agent] Agent exited non-zero but result file was written — recovering`);
|
|
164
|
+
}
|
|
165
|
+
} catch (parseErr) {
|
|
166
|
+
logger.warn(`⚠️ [Agent] Result file exists but failed to parse: ${parseErr.message}`);
|
|
167
|
+
}
|
|
168
|
+
} else if (!spawnError) {
|
|
169
|
+
logger.warn(`⚠️ [Agent] Result file not found at ${resultFilePath}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Step 2: Validate with Zod if we got parsed JSON
|
|
173
|
+
if (fileContent && isZodSchema) {
|
|
174
|
+
try {
|
|
175
|
+
const validated = schema.parse(fileContent);
|
|
176
|
+
logger.debug(`✅ [Agent] File-based output validated with Zod — done`);
|
|
177
|
+
return { raw: rawOutput, structured: validated };
|
|
178
|
+
} catch (zodErr) {
|
|
179
|
+
logger.warn(`⚠️ [Agent] File JSON failed Zod validation: ${zodErr.message?.slice(0, 300)}`);
|
|
180
|
+
}
|
|
181
|
+
} else if (fileContent) {
|
|
182
|
+
logger.debug(`✅ [Agent] File-based output extracted (no Zod schema) — done`);
|
|
183
|
+
return { raw: rawOutput, structured: fileContent };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Step 3: If strict mode, fall back to OpenAI proxy to enforce schema
|
|
187
|
+
if (strictMode && !spawnError) {
|
|
188
|
+
const parsedText = spawnResult.parsedText;
|
|
189
|
+
const proxyInput = fileContent ? JSON.stringify(fileContent) : parsedText;
|
|
190
|
+
logger.debug(`[Agent] strictMode — falling back to OpenAI proxy (${proxyInput.length} chars)`);
|
|
191
|
+
const result = await formatWithOpenAIProxy(proxyInput, schema);
|
|
192
|
+
if (isZodSchema) {
|
|
193
|
+
const validated = schema.parse(result.structured);
|
|
194
|
+
return { raw: rawOutput, structured: validated };
|
|
195
|
+
}
|
|
196
|
+
return { raw: rawOutput, ...result };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// If agent crashed and we couldn't salvage from file, re-throw original error
|
|
200
|
+
if (spawnError) throw spawnError;
|
|
201
|
+
|
|
202
|
+
logger.error(`❌ [Agent] No structured output extracted from file`);
|
|
203
|
+
logger.error(`💡 Tip: Set strictMode=true in .zibby.config.js for OpenAI proxy fallback`);
|
|
204
|
+
throw new Error(`Agent did not produce a valid result file at ${resultFilePath}. Enable strictMode for proxy fallback.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// No schema — non-zero exit is a real failure
|
|
208
|
+
if (spawnError) throw spawnError;
|
|
209
|
+
|
|
210
|
+
return rawOutput;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_setupMcpConfig(sessionPath, workspace, config) {
|
|
214
|
+
const cursorDir = join(homedir(), '.cursor');
|
|
215
|
+
const mcpConfigPath = join(cursorDir, 'mcp.json');
|
|
216
|
+
|
|
217
|
+
let existing = {};
|
|
218
|
+
if (existsSync(mcpConfigPath)) {
|
|
219
|
+
try {
|
|
220
|
+
existing = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
|
|
221
|
+
} catch { /* start fresh */ }
|
|
222
|
+
}
|
|
223
|
+
const mcpServers = existing.mcpServers || {};
|
|
224
|
+
|
|
225
|
+
const outputBase = config?.paths?.output || DEFAULT_OUTPUT_BASE;
|
|
226
|
+
const sessionInfoPath = join(workspace || process.cwd(), outputBase, SESSION_INFO_FILE);
|
|
227
|
+
|
|
228
|
+
const configured = new Set();
|
|
229
|
+
for (const [, skill] of getAllSkills()) {
|
|
230
|
+
if (typeof skill.resolve !== 'function') continue;
|
|
231
|
+
if (configured.has(skill.serverName)) continue;
|
|
232
|
+
configured.add(skill.serverName);
|
|
233
|
+
this._ensureSkillConfigured(mcpServers, skill, sessionPath, sessionInfoPath);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (Object.keys(mcpServers).length === 0) {
|
|
237
|
+
logger.debug(`[MCP] No MCP servers configured - agent will run without tool access`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!existsSync(cursorDir)) {
|
|
242
|
+
mkdirSync(cursorDir, { recursive: true });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
writeFileSync(mcpConfigPath, JSON.stringify({ mcpServers }, null, 2));
|
|
246
|
+
logger.debug(`[MCP] Config: ${Object.keys(mcpServers).join(', ')}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_ensureSkillConfigured(mcpServers, skill, sessionPath, sessionInfoPath) {
|
|
250
|
+
const preferredKey = skill.cursorKey || skill.serverName;
|
|
251
|
+
const existingKey = mcpServers[preferredKey] ? preferredKey
|
|
252
|
+
: mcpServers[skill.serverName] ? skill.serverName
|
|
253
|
+
: null;
|
|
254
|
+
|
|
255
|
+
if (existingKey && sessionPath) {
|
|
256
|
+
mcpServers[existingKey].args = (mcpServers[existingKey].args || []).map(arg =>
|
|
257
|
+
arg.startsWith('--output-dir=') ? `--output-dir=${sessionPath}` : arg
|
|
258
|
+
);
|
|
259
|
+
if (skill.sessionEnvKey) {
|
|
260
|
+
mcpServers[existingKey].env = {
|
|
261
|
+
...(mcpServers[existingKey].env || {}),
|
|
262
|
+
[skill.sessionEnvKey]: sessionInfoPath,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
logger.debug(`[MCP] Updated ${existingKey} session → ${sessionPath}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (existingKey) return;
|
|
270
|
+
|
|
271
|
+
const resolved = skill.resolve({ sessionPath });
|
|
272
|
+
if (!resolved) return;
|
|
273
|
+
|
|
274
|
+
mcpServers[preferredKey] = {
|
|
275
|
+
...resolved,
|
|
276
|
+
...(skill.sessionEnvKey && {
|
|
277
|
+
env: { ...(resolved.env || {}), [skill.sessionEnvKey]: sessionInfoPath },
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
logger.debug(`[MCP] Configured ${preferredKey}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_spawnWithStreaming(bin, args, cwd, timeout, stdinPrompt = null) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
const startTime = Date.now();
|
|
286
|
+
let stdout = '';
|
|
287
|
+
let stderr = '';
|
|
288
|
+
let lastOutputTime = Date.now();
|
|
289
|
+
let lineCount = 0;
|
|
290
|
+
let killed = false;
|
|
291
|
+
|
|
292
|
+
const proc = spawn(bin, args, {
|
|
293
|
+
cwd,
|
|
294
|
+
shell: false,
|
|
295
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
logger.debug(`[Agent] PID: ${proc.pid}`);
|
|
299
|
+
|
|
300
|
+
if (stdinPrompt) {
|
|
301
|
+
proc.stdin.write(stdinPrompt);
|
|
302
|
+
proc.stdin.end();
|
|
303
|
+
logger.debug(`[Agent] Prompt also piped to stdin (${stdinPrompt.length} chars)`);
|
|
304
|
+
} else {
|
|
305
|
+
proc.stdin.end();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const modifiedFiles = new Set();
|
|
309
|
+
const _startIso = new Date(startTime).toISOString().replace(/\.\d+Z$/, '');
|
|
310
|
+
|
|
311
|
+
const heartbeat = setInterval(() => {
|
|
312
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
313
|
+
const _silent = Math.round((Date.now() - lastOutputTime) / 1000);
|
|
314
|
+
|
|
315
|
+
const newFiles = [];
|
|
316
|
+
try {
|
|
317
|
+
const elapsedMin = Math.ceil(elapsed / 60) + 1;
|
|
318
|
+
const raw = execSync(
|
|
319
|
+
`find "${cwd}" -type f -mmin -${elapsedMin} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/target/*' 2>/dev/null | head -20`,
|
|
320
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
321
|
+
).trim();
|
|
322
|
+
if (raw) {
|
|
323
|
+
for (const f of raw.split('\n')) {
|
|
324
|
+
const rel = f.replace(`${cwd }/`, '');
|
|
325
|
+
if (!modifiedFiles.has(rel)) {
|
|
326
|
+
modifiedFiles.add(rel);
|
|
327
|
+
newFiles.push(rel);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch {}
|
|
332
|
+
|
|
333
|
+
let activity = '';
|
|
334
|
+
if (newFiles.length > 0) {
|
|
335
|
+
const short = newFiles.map(f => f.split('/').pop());
|
|
336
|
+
activity = ` | 📁 new: ${short.join(', ')}`;
|
|
337
|
+
}
|
|
338
|
+
if (modifiedFiles.size > 0) {
|
|
339
|
+
activity += ` | 📦 total: ${modifiedFiles.size} files`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
logger.debug(`💓 [Agent] Running for ${elapsed}s | ${lineCount} lines output${activity}`);
|
|
343
|
+
|
|
344
|
+
if (lineCount === 0 && elapsed >= 90 && elapsed < 120 && modifiedFiles.size === 0) {
|
|
345
|
+
logger.warn(`⚠️ [Agent] No output and no file changes after ${elapsed}s — agent may be stuck.`);
|
|
346
|
+
try {
|
|
347
|
+
const procInfo = execSync(`ps -p ${proc.pid} -o pid,stat,rss,time 2>/dev/null || echo "Process info unavailable"`, { encoding: 'utf-8', timeout: 5000 });
|
|
348
|
+
logger.warn(`⚠️ [Agent] Process: ${procInfo.trim()}`);
|
|
349
|
+
} catch {}
|
|
350
|
+
}
|
|
351
|
+
}, 30000);
|
|
352
|
+
|
|
353
|
+
const timer = setTimeout(() => {
|
|
354
|
+
killed = true;
|
|
355
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
356
|
+
logger.error(`⏱️ [Agent] Timeout after ${elapsed}s — killing process (PID: ${proc.pid})`);
|
|
357
|
+
if (stdout.trim()) {
|
|
358
|
+
logger.warn(`📤 [Agent] Partial output (${stdout.length} chars) before timeout:\n${stdout.slice(-2000)}`);
|
|
359
|
+
}
|
|
360
|
+
proc.kill('SIGTERM');
|
|
361
|
+
setTimeout(() => {
|
|
362
|
+
if (!proc.killed) proc.kill('SIGKILL');
|
|
363
|
+
}, 5000);
|
|
364
|
+
}, timeout);
|
|
365
|
+
|
|
366
|
+
const streamParser = new StreamingParser();
|
|
367
|
+
streamParser.onToolCall = (name, input) => {
|
|
368
|
+
let displayName = name;
|
|
369
|
+
let displayInput = input;
|
|
370
|
+
if (name === 'mcpToolCall' && input?.name) {
|
|
371
|
+
displayName = input.name.replace(/^mcp_+[^_]+_+/, '');
|
|
372
|
+
if (displayName.includes('-') && displayName.split('-')[0] === displayName.split('-')[1]) {
|
|
373
|
+
displayName = displayName.split('-')[0];
|
|
374
|
+
}
|
|
375
|
+
displayInput = input.args ?? input.input ?? input;
|
|
376
|
+
} else if (name === 'readToolCall' || name === 'editToolCall' || name === 'writeToolCall') {
|
|
377
|
+
return;
|
|
378
|
+
} else if (name.startsWith('mcp__') || name.includes('ToolCall')) {
|
|
379
|
+
displayName = name.replace(/^mcp_+[^_]+_+/, '').replace(/ToolCall$/, '');
|
|
380
|
+
}
|
|
381
|
+
const isMemoryTool = displayName.includes('memory');
|
|
382
|
+
if (isMemoryTool) {
|
|
383
|
+
timeline.stepMemory(`Tool: ${displayName}`);
|
|
384
|
+
} else {
|
|
385
|
+
timeline.stepTool(`Tool: ${displayName}`);
|
|
386
|
+
}
|
|
387
|
+
if (displayInput != null && typeof displayInput === 'object' && Object.keys(displayInput).length > 0) {
|
|
388
|
+
const raw = JSON.stringify(displayInput);
|
|
389
|
+
const preview = raw.length > 100 ? `${raw.substring(0, 100)}...` : raw;
|
|
390
|
+
console.log(` Input: ${preview}`);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
proc.stdout.on('data', (data) => {
|
|
395
|
+
const chunk = data.toString();
|
|
396
|
+
stdout += chunk;
|
|
397
|
+
lastOutputTime = Date.now();
|
|
398
|
+
|
|
399
|
+
const displayText = streamParser.processChunk(chunk);
|
|
400
|
+
if (displayText) {
|
|
401
|
+
process.stdout.write(displayText);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const lines = chunk.split('\n').filter(l => l.trim());
|
|
405
|
+
lineCount += lines.length;
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
proc.stderr.on('data', (data) => {
|
|
409
|
+
const chunk = data.toString();
|
|
410
|
+
stderr += chunk;
|
|
411
|
+
lastOutputTime = Date.now();
|
|
412
|
+
|
|
413
|
+
const lines = chunk.split('\n').filter(l => l.trim());
|
|
414
|
+
for (const line of lines) {
|
|
415
|
+
logger.warn(`⚠️ [Agent stderr] ${line}`);
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
proc.on('close', (code, signal) => {
|
|
420
|
+
clearTimeout(timer);
|
|
421
|
+
clearInterval(heartbeat);
|
|
422
|
+
streamParser.flush();
|
|
423
|
+
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
424
|
+
logger.debug(`[Agent] Exited: code=${code}, signal=${signal}, elapsed=${elapsed}s, output=${stdout.length} chars`);
|
|
425
|
+
timeline.step(`Agent completed (${elapsed}s)`);
|
|
426
|
+
|
|
427
|
+
if (killed) {
|
|
428
|
+
reject(new Error(
|
|
429
|
+
`Cursor Agent timed out after ${elapsed}s (limit: ${timeout / 1000}s). ` +
|
|
430
|
+
`${lineCount} lines produced. Last output ${Math.round((Date.now() - lastOutputTime) / 1000)}s ago. ${
|
|
431
|
+
stdout.trim() ? `\nPartial output (last 500 chars):\n${stdout.slice(-500)}` : 'No output captured.'}`
|
|
432
|
+
));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (code !== 0) {
|
|
437
|
+
reject(new Error(
|
|
438
|
+
`Cursor Agent failed: exit code ${code}, signal ${signal}. ${
|
|
439
|
+
stderr.trim() ? `\nStderr: ${stderr.slice(-1000)}` : ''
|
|
440
|
+
}${stdout.trim() ? `\nStdout (last 500 chars): ${stdout.slice(-500)}` : ''}`
|
|
441
|
+
));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const extractedJson = streamParser.getResult();
|
|
446
|
+
const parsedText = extractedJson
|
|
447
|
+
? JSON.stringify(extractedJson, null, 2)
|
|
448
|
+
: (streamParser.getRawText() || stdout || '');
|
|
449
|
+
resolve({ stdout: stdout || stderr || '', parsedText });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
proc.on('error', (err) => {
|
|
453
|
+
clearTimeout(timer);
|
|
454
|
+
clearInterval(heartbeat);
|
|
455
|
+
reject(new Error(`Cursor Agent spawn error: ${err.message}`));
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Strategy Factory
|
|
3
|
+
*
|
|
4
|
+
* Selects the best available agent strategy based on:
|
|
5
|
+
* 1. User preference (state.agentType or AGENT_TYPE env)
|
|
6
|
+
* 2. Availability (can the strategy be used?)
|
|
7
|
+
* 3. Priority (which strategy is preferred?)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CursorAgentStrategy } from './cursor-strategy.js';
|
|
11
|
+
import { ClaudeAgentStrategy } from './claude-strategy.js';
|
|
12
|
+
import { logger } from '../../utils/logger.js';
|
|
13
|
+
|
|
14
|
+
// Registry of all available agent strategies
|
|
15
|
+
const AGENT_STRATEGIES = [
|
|
16
|
+
new CursorAgentStrategy(),
|
|
17
|
+
new ClaudeAgentStrategy()
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the best available agent strategy
|
|
22
|
+
* @param {Object} context - Context containing state, env, etc.
|
|
23
|
+
* @returns {AgentStrategy}
|
|
24
|
+
*/
|
|
25
|
+
export function getAgentStrategy(context = {}) {
|
|
26
|
+
const { state = {}, preferredAgent = null } = context;
|
|
27
|
+
|
|
28
|
+
// Agent must be explicitly specified - no auto-selection
|
|
29
|
+
const requestedAgent = preferredAgent ||
|
|
30
|
+
state.agentType ||
|
|
31
|
+
process.env.AGENT_TYPE;
|
|
32
|
+
|
|
33
|
+
if (!requestedAgent) {
|
|
34
|
+
throw new Error('No agent specified. Set agent.claude or agent.cursor in .zibby.config.js');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.debug(`Agent selection: requested=${requestedAgent}`);
|
|
38
|
+
|
|
39
|
+
const strategy = AGENT_STRATEGIES.find(s => s.getName() === requestedAgent);
|
|
40
|
+
|
|
41
|
+
if (!strategy) {
|
|
42
|
+
throw new Error(`Unknown agent '${requestedAgent}'. Available: ${AGENT_STRATEGIES.map(s => s.getName()).join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
logger.debug(`Checking if ${requestedAgent} can handle this environment...`);
|
|
46
|
+
if (!strategy.canHandle(context)) {
|
|
47
|
+
const hint = requestedAgent === 'claude'
|
|
48
|
+
? 'Set ANTHROPIC_API_KEY in .env'
|
|
49
|
+
: 'Install cursor-agent CLI or set CURSOR_API_KEY';
|
|
50
|
+
throw new Error(`Agent '${requestedAgent}' is not available. ${hint}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
logger.debug(`Using agent: ${strategy.getName()}`);
|
|
54
|
+
return strategy;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Invoke an agent with automatic strategy selection
|
|
59
|
+
* @param {string} prompt - The prompt to send
|
|
60
|
+
* @param {Object} context - Context for strategy selection
|
|
61
|
+
* @param {Object} options - Options for invoke (model, workspace, etc.)
|
|
62
|
+
* @returns {Promise<string>}
|
|
63
|
+
*/
|
|
64
|
+
export async function invokeAgent(prompt, context = {}, options = {}) {
|
|
65
|
+
const strategy = getAgentStrategy(context);
|
|
66
|
+
|
|
67
|
+
const config = context.state?.config || options.config || {};
|
|
68
|
+
const finalOptions = {
|
|
69
|
+
model: options.model ?? context.state?.model ?? config?.agent?.claude?.model ?? config?.agent?.cursor?.model,
|
|
70
|
+
workspace: context.state?.workspace || options.workspace,
|
|
71
|
+
schema: options.schema || context.schema,
|
|
72
|
+
images: options.images || context.images || [],
|
|
73
|
+
skills: options.skills || context.skills || null,
|
|
74
|
+
config,
|
|
75
|
+
...options
|
|
76
|
+
};
|
|
77
|
+
if (!finalOptions.model) {
|
|
78
|
+
finalOptions.model = finalOptions.config?.agent?.cursor ? (finalOptions.config.agent.cursor.model ?? 'auto') : (finalOptions.config?.agent?.claude?.model ?? 'auto');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const extraInstructions = context.state?._currentNodeConfig?.extraPromptInstructions?.trim();
|
|
82
|
+
if (extraInstructions) {
|
|
83
|
+
prompt += `\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
84
|
+
⚠️ PRIORITY OVERRIDE — THE FOLLOWING INSTRUCTIONS TAKE PRECEDENCE OVER ALL PREVIOUS CONTENT
|
|
85
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
86
|
+
|
|
87
|
+
${extraInstructions}
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logger.debug(`Prompt length: ${prompt.length} chars`);
|
|
92
|
+
if (process.env.STAGE !== 'prod') {
|
|
93
|
+
logger.debug(`Full prompt:\n${prompt}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawOutput = await strategy.invoke(prompt, finalOptions);
|
|
97
|
+
|
|
98
|
+
// Return the output as-is (strategy handles formatting)
|
|
99
|
+
// For schemas: { raw: string, structured: object }
|
|
100
|
+
// For text: string
|
|
101
|
+
return rawOutput;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { CursorAgentStrategy, ClaudeAgentStrategy };
|
|
105
|
+
export { AgentStrategy } from './base.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Output Formatter
|
|
3
|
+
* Generates prompt instructions for file-based structured output from Cursor agent
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
7
|
+
|
|
8
|
+
export class CursorOutputFormatter {
|
|
9
|
+
/**
|
|
10
|
+
* Generate prompt instructions telling the agent to write structured JSON to a file.
|
|
11
|
+
* @param {Object} schema - Zod schema
|
|
12
|
+
* @param {string} outputFilePath - Absolute path the agent must write to
|
|
13
|
+
* @returns {string} Prompt instructions to append
|
|
14
|
+
*/
|
|
15
|
+
static generateFileOutputInstructions(schema, outputFilePath) {
|
|
16
|
+
let jsonSchema;
|
|
17
|
+
if (typeof schema?.parse === 'function') {
|
|
18
|
+
jsonSchema = zodToJsonSchema(schema, { target: 'openApi3' });
|
|
19
|
+
} else {
|
|
20
|
+
jsonSchema = schema;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const example = this._buildExample(jsonSchema);
|
|
24
|
+
|
|
25
|
+
return `
|
|
26
|
+
[OUTPUT INSTRUCTIONS]
|
|
27
|
+
Write your result as pure JSON to this file: ${outputFilePath}
|
|
28
|
+
Exact structure (follow types and nesting precisely):
|
|
29
|
+
${JSON.stringify(example, null, 2)}
|
|
30
|
+
Rules: valid JSON only, no markdown wrapping, no extra text. Stop after writing.`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a representative example object from a JSON Schema,
|
|
35
|
+
* showing correct types and nesting so the agent knows the exact shape.
|
|
36
|
+
*/
|
|
37
|
+
static _buildExample(schema) {
|
|
38
|
+
if (!schema) return {};
|
|
39
|
+
const type = schema.type;
|
|
40
|
+
|
|
41
|
+
if (type === 'object' && schema.properties) {
|
|
42
|
+
const obj = {};
|
|
43
|
+
for (const [key, prop] of Object.entries(schema.properties)) {
|
|
44
|
+
obj[key] = this._buildExample(prop);
|
|
45
|
+
}
|
|
46
|
+
return obj;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (type === 'array' && schema.items) {
|
|
50
|
+
return [this._buildExample(schema.items)];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (schema.description) return `<${schema.description}>`;
|
|
54
|
+
if (type === 'string') return '<string>';
|
|
55
|
+
if (type === 'number' || type === 'integer') return 0;
|
|
56
|
+
if (type === 'boolean') return false;
|
|
57
|
+
|
|
58
|
+
if (schema.nullable || schema.oneOf || schema.anyOf) {
|
|
59
|
+
const inner = schema.oneOf?.find(s => s.type !== 'null') ||
|
|
60
|
+
schema.anyOf?.find(s => s.type !== 'null');
|
|
61
|
+
if (inner) return this._buildExample(inner);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return '<value>';
|
|
66
|
+
}
|
|
67
|
+
}
|