@stackwright-pro/otters 1.0.0-alpha.4 → 1.0.0-alpha.43

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.
@@ -1,391 +0,0 @@
1
- /**
2
- * Python Bridge - Spawns Python server and communicates via JSON over stdio
3
- *
4
- * This module provides the interface between Node.js CLI and the Python
5
- * Clarification Protocol server.
6
- *
7
- * Architecture:
8
- * - Node.js CLI spawns Python server (unix socket + HTTP)
9
- * - Communication via JSON over stdio or HTTP
10
- * - Supports both blocking (TUI) and non-blocking (API) modes
11
- */
12
-
13
- import { spawn, ChildProcess, execSync } from 'child_process';
14
- import { existsSync, unlinkSync } from 'fs';
15
- import { tmpdir } from 'os';
16
- import { join } from 'path';
17
- import { randomUUID } from 'crypto';
18
-
19
- // Types
20
- export interface ClarificationRequest {
21
- context: string;
22
- question_type: 'closed_choice' | 'open_text' | 'conditional' | 'multi_step' | 'reconciliation';
23
- question: string;
24
- choices?: string[];
25
- priority?: 'blocking' | 'preferred' | 'optional';
26
- target_field?: string;
27
- partial_result?: Record<string, unknown>;
28
- }
29
-
30
- export interface ClarificationResponse {
31
- request_id: string;
32
- decision: {
33
- value: unknown;
34
- explicit: boolean;
35
- source: string;
36
- };
37
- fallback_used: boolean;
38
- fallback_reason?: string;
39
- }
40
-
41
- export interface ConflictCheck {
42
- stated_preference: string;
43
- selected_values: Record<string, string>;
44
- }
45
-
46
- export interface ConflictResult {
47
- conflict: boolean;
48
- data?: {
49
- description: string;
50
- stated: string;
51
- options: string[];
52
- };
53
- }
54
-
55
- export interface SessionInfo {
56
- id: string;
57
- phase: string;
58
- context: Record<string, unknown>;
59
- }
60
-
61
- // Constants
62
- const DEFAULT_SOCKET_PATH = join(tmpdir(), `otter-raft-${randomUUID()}.sock`);
63
- const DEFAULT_HTTP_PORT = 8765;
64
-
65
- /**
66
- * PythonBridge - Communicates with Python Clarification Protocol server
67
- */
68
- export class PythonBridge {
69
- private socketPath: string;
70
- private httpPort: number;
71
- private pythonProcess: ChildProcess | null = null;
72
- private useHttp: boolean;
73
- private baseUrl: string;
74
-
75
- constructor(options?: { socketPath?: string; httpPort?: number }) {
76
- this.socketPath = options?.socketPath || DEFAULT_SOCKET_PATH;
77
- this.httpPort = options?.httpPort || DEFAULT_HTTP_PORT;
78
- this.useHttp = true; // Prefer HTTP for reliability
79
- this.baseUrl = `http://127.0.0.1:${this.httpPort}`;
80
- }
81
-
82
- /**
83
- * Start the Python server
84
- */
85
- async start(): Promise<void> {
86
- // Find Python executable
87
- const pythonPath = this.findPython();
88
-
89
- // Find the Python package
90
- const packageRoot = this.findPackageRoot();
91
- const serverModule = 'stackwright_pro.raft.server';
92
-
93
- return new Promise((resolve, reject) => {
94
- // Clean up old socket
95
- if (existsSync(this.socketPath)) {
96
- try {
97
- unlinkSync(this.socketPath);
98
- } catch {
99
- // Ignore
100
- }
101
- }
102
-
103
- this.pythonProcess = spawn(
104
- pythonPath,
105
- ['-m', serverModule, '--socket', this.socketPath, '--port', String(this.httpPort)],
106
- {
107
- stdio: ['pipe', 'pipe', 'pipe'],
108
- env: {
109
- ...process.env,
110
- PYTHONPATH: packageRoot,
111
- },
112
- }
113
- );
114
-
115
- let startupOutput = '';
116
-
117
- this.pythonProcess.stdout?.on('data', (data: Buffer) => {
118
- startupOutput += data.toString();
119
- // Look for "Server ready" message
120
- if (startupOutput.includes('Server ready') || startupOutput.includes('Starting')) {
121
- // Give it a moment to fully start
122
- setTimeout(() => resolve(), 500);
123
- }
124
- });
125
-
126
- this.pythonProcess.stderr?.on('data', (data: Buffer) => {
127
- console.error('[Python]', data.toString().trim());
128
- });
129
-
130
- this.pythonProcess.on('error', (err) => {
131
- reject(new Error(`Failed to start Python server: ${err.message}`));
132
- });
133
-
134
- this.pythonProcess.on('exit', (code) => {
135
- if (code !== 0 && code !== null) {
136
- reject(new Error(`Python server exited with code ${code}`));
137
- }
138
- });
139
-
140
- // Timeout after 10 seconds
141
- setTimeout(() => {
142
- reject(new Error('Python server startup timeout'));
143
- }, 10000);
144
- });
145
- }
146
-
147
- /**
148
- * Stop the Python server
149
- */
150
- async stop(): Promise<void> {
151
- return new Promise((resolve) => {
152
- if (this.pythonProcess) {
153
- this.pythonProcess.on('exit', () => resolve());
154
- this.pythonProcess.kill('SIGTERM');
155
-
156
- // Force kill after 5 seconds
157
- setTimeout(() => {
158
- if (this.pythonProcess) {
159
- this.pythonProcess.kill('SIGKILL');
160
- }
161
- resolve();
162
- }, 5000);
163
- } else {
164
- resolve();
165
- }
166
- });
167
- }
168
-
169
- /**
170
- * Health check
171
- */
172
- async health(): Promise<{ status: string; version: string }> {
173
- return this.httpRequest('/health');
174
- }
175
-
176
- /**
177
- * Create a new clarification session
178
- */
179
- async createSession(): Promise<{ session_id: string }> {
180
- return this.httpRequest('/sessions', {
181
- method: 'POST',
182
- });
183
- }
184
-
185
- /**
186
- * Get session info
187
- */
188
- async getSession(sessionId: string): Promise<SessionInfo> {
189
- return this.httpRequest(`/sessions/${sessionId}`);
190
- }
191
-
192
- /**
193
- * Set session phase
194
- */
195
- async setPhase(sessionId: string, phase: string): Promise<{ phase: string }> {
196
- return this.httpRequest(`/sessions/${sessionId}/phase`, {
197
- method: 'POST',
198
- body: { phase },
199
- });
200
- }
201
-
202
- /**
203
- * Update session context
204
- */
205
- async updateContext(
206
- sessionId: string,
207
- context: Record<string, unknown>
208
- ): Promise<{ context: Record<string, unknown> }> {
209
- return this.httpRequest(`/sessions/${sessionId}/context`, {
210
- method: 'POST',
211
- body: { context },
212
- });
213
- }
214
-
215
- /**
216
- * Ask for clarification within a session
217
- */
218
- async sessionClarify(
219
- sessionId: string,
220
- request: ClarificationRequest
221
- ): Promise<ClarificationResponse> {
222
- return this.httpRequest(`/sessions/${sessionId}/clarify`, {
223
- method: 'POST',
224
- body: { request },
225
- });
226
- }
227
-
228
- /**
229
- * Quick clarify (no session)
230
- */
231
- async clarify(request: ClarificationRequest): Promise<ClarificationResponse> {
232
- return this.httpRequest('/clarify', {
233
- method: 'POST',
234
- body: { request },
235
- });
236
- }
237
-
238
- /**
239
- * Detect preference conflict
240
- */
241
- async detectConflict(check: ConflictCheck): Promise<ConflictResult> {
242
- return this.httpRequest('/conflict', {
243
- method: 'POST',
244
- body: check,
245
- });
246
- }
247
-
248
- // --------------------------------------------------------------------------
249
- // Private methods
250
- // --------------------------------------------------------------------------
251
-
252
- private findPython(): string {
253
- // Try python3 first, fall back to python
254
- try {
255
- execSync('python3 --version', { stdio: 'pipe' });
256
- return 'python3';
257
- } catch {
258
- return 'python';
259
- }
260
- }
261
-
262
- private findPackageRoot(): string {
263
- // Look for the Python package in common locations
264
- const candidates = [
265
- join(__dirname, '../../python/src'),
266
- join(__dirname, '../../../python/src'),
267
- join(process.cwd(), 'python/src'),
268
- ];
269
-
270
- for (const candidate of candidates) {
271
- if (existsSync(candidate)) {
272
- return candidate;
273
- }
274
- }
275
-
276
- // Default to current directory
277
- return process.cwd();
278
- }
279
-
280
- private async httpRequest<T>(
281
- path: string,
282
- options?: {
283
- method?: string;
284
- body?: unknown;
285
- }
286
- ): Promise<T> {
287
- const http = await import('http');
288
-
289
- return new Promise((resolve, reject) => {
290
- const url = new URL(path, this.baseUrl);
291
-
292
- const reqOptions = {
293
- hostname: url.hostname,
294
- port: url.port,
295
- path: url.pathname,
296
- method: options?.method || 'GET',
297
- headers: {
298
- 'Content-Type': 'application/json',
299
- },
300
- };
301
-
302
- const req = http.request(reqOptions, (res) => {
303
- let data = '';
304
-
305
- res.on('data', (chunk: Buffer) => {
306
- data += chunk.toString();
307
- });
308
-
309
- res.on('end', () => {
310
- try {
311
- const parsed = JSON.parse(data);
312
-
313
- if (res.statusCode && res.statusCode >= 400) {
314
- reject(new Error(parsed.detail || parsed.error || `HTTP ${res.statusCode}`));
315
- } else {
316
- resolve(parsed);
317
- }
318
- } catch (e) {
319
- reject(new Error(`Failed to parse response: ${data}`));
320
- }
321
- });
322
- });
323
-
324
- req.on('error', (e) => {
325
- reject(new Error(`HTTP request failed: ${e.message}`));
326
- });
327
-
328
- if (options?.body) {
329
- req.write(JSON.stringify(options.body));
330
- }
331
-
332
- req.end();
333
- });
334
- }
335
- }
336
-
337
- /**
338
- * Create a bridge instance and auto-start it
339
- */
340
- export async function createBridge(options?: {
341
- socketPath?: string;
342
- httpPort?: number;
343
- }): Promise<PythonBridge> {
344
- const bridge = new PythonBridge(options);
345
- await bridge.start();
346
- return bridge;
347
- }
348
-
349
- /**
350
- * Run a single clarification in stdio mode (no server)
351
- *
352
- * Usage:
353
- * echo '{"type":"clarify","request":{...}}' | python -m stackwright_pro.raft.server --stdio
354
- */
355
- export async function runStdioClarify(
356
- request: ClarificationRequest
357
- ): Promise<ClarificationResponse> {
358
- const http = await import('http');
359
-
360
- // For stdio mode, we communicate directly via stdin/stdout
361
- // This is handled by the Python server in --stdio mode
362
- return new Promise((resolve, reject) => {
363
- const input = JSON.stringify({
364
- type: 'clarify',
365
- request,
366
- });
367
-
368
- const proc = spawn('python', ['-m', 'stackwright_pro.raft.server', '--stdio'], {
369
- stdio: ['pipe', 'pipe', 'pipe'],
370
- });
371
-
372
- let output = '';
373
-
374
- proc.stdout?.on('data', (data: Buffer) => {
375
- output += data.toString();
376
- });
377
-
378
- proc.on('close', () => {
379
- try {
380
- resolve(JSON.parse(output));
381
- } catch {
382
- reject(new Error(`Failed to parse response: ${output}`));
383
- }
384
- });
385
-
386
- proc.on('error', reject);
387
-
388
- proc.stdin?.write(input);
389
- proc.stdin?.end();
390
- });
391
- }
@@ -1,296 +0,0 @@
1
- /**
2
- * Question Adapter - Converts between Question Manifest format and ask_user_question format
3
- *
4
- * The ask_user_question MCP tool requires:
5
- * - question: string (full text)
6
- * - header: string (max 12 chars, short label)
7
- * - multi_select: boolean
8
- * - options: {label, description}[] (REQUIRED, min 2)
9
- *
10
- * Our Question Manifest format has:
11
- * - id: string (e.g., "api-1")
12
- * - question: string
13
- * - type: 'text' | 'select' | 'multi-select' | 'confirm'
14
- * - options?: {label, value}[] (optional for text/confirm)
15
- * - required?: boolean
16
- * - default?: string | boolean | string[]
17
- * - dependsOn?: {questionId, value}
18
- */
19
-
20
- export interface QuestionManifestQuestion {
21
- id: string;
22
- question: string;
23
- type: 'text' | 'select' | 'multi-select' | 'confirm';
24
- required?: boolean;
25
- options?: { label: string; value: string }[];
26
- default?: string | boolean | string[];
27
- help?: string;
28
- dependsOn?: {
29
- questionId: string;
30
- value: string | string[];
31
- };
32
- }
33
-
34
- export interface AskUserQuestionOption {
35
- label: string;
36
- description?: string;
37
- }
38
-
39
- export interface AskUserQuestion {
40
- question: string;
41
- header: string;
42
- multi_select: boolean;
43
- options: AskUserQuestionOption[];
44
- }
45
-
46
- /**
47
- * Truncate string to max length, adding ellipsis if needed
48
- */
49
- function truncate(str: string, maxLength: number): string {
50
- if (str.length <= maxLength) return str;
51
- return str.substring(0, maxLength - 1) + '…';
52
- }
53
-
54
- /**
55
- * Generate a valid header from a question ID
56
- * Headers must be max 12 chars, alphanumeric with hyphens
57
- */
58
- function generateHeader(id: string): string {
59
- // Remove numbers and prefix, keep meaningful part
60
- // e.g., "api-1" -> "API-1", "auth-3" -> "AUTH-3"
61
- const parts = id.split('-');
62
- if (parts.length >= 2) {
63
- const prefix = parts[0].toUpperCase().substring(0, 4);
64
- const num = parts[1];
65
- return truncate(`${prefix}-${num}`, 12);
66
- }
67
- return truncate(
68
- id
69
- .toUpperCase()
70
- .replace(/[^A-Z0-9]/g, '')
71
- .substring(0, 12),
72
- 12
73
- );
74
- }
75
-
76
- /**
77
- * Generate default options for question types that don't have options
78
- */
79
- function generateDefaultOptions(type: string): AskUserQuestionOption[] {
80
- switch (type) {
81
- case 'confirm':
82
- return [
83
- { label: 'Yes', description: 'Enable or confirm this option' },
84
- { label: 'No', description: 'Disable or decline this option' },
85
- ];
86
- case 'text':
87
- return [
88
- { label: 'Specify', description: 'I will provide a specific value' },
89
- { label: 'Skip', description: 'Use default or skip this question' },
90
- ];
91
- default:
92
- return [
93
- { label: 'Option 1', description: 'First option' },
94
- { label: 'Option 2', description: 'Second option' },
95
- ];
96
- }
97
- }
98
-
99
- /**
100
- * Convert a single QuestionManifest question to ask_user_question format
101
- */
102
- export function adaptQuestion(q: QuestionManifestQuestion): AskUserQuestion {
103
- // Generate header from ID
104
- const header = generateHeader(q.id);
105
-
106
- // Determine multi_select
107
- const multiSelect = q.type === 'multi-select';
108
-
109
- // Handle options - use provided or generate defaults
110
- let options: AskUserQuestionOption[];
111
-
112
- if (q.options && q.options.length >= 2) {
113
- // Use provided options, adapt format
114
- options = q.options.map((opt) => ({
115
- label: truncate(opt.label, 50),
116
- description: opt.value !== opt.label ? opt.value : undefined,
117
- }));
118
- } else if (q.options && q.options.length === 1) {
119
- // Single option - add a default
120
- options = [
121
- ...q.options.map((opt) => ({ label: truncate(opt.label, 50), description: opt.value })),
122
- { label: 'Other', description: 'Specify a different value' },
123
- ];
124
- } else {
125
- // No options - generate defaults based on type
126
- options = generateDefaultOptions(q.type);
127
- }
128
-
129
- // Ensure minimum 2 options (MCP tool requirement)
130
- if (options.length < 2) {
131
- options.push({ label: 'Other', description: 'Alternative option' });
132
- }
133
-
134
- // Limit to 6 options (MCP tool max)
135
- options = options.slice(0, 6);
136
-
137
- return {
138
- question: q.question + (q.help ? `\n\n${q.help}` : ''),
139
- header,
140
- multi_select: multiSelect,
141
- options,
142
- };
143
- }
144
-
145
- /**
146
- * Adapt multiple questions, filtering by dependsOn
147
- *
148
- * @param questions - All questions from manifest
149
- * @param answers - Previously answered questions (for filtering conditionals)
150
- * @returns Questions adapted for ask_user_question, with conditionals resolved
151
- */
152
- export function adaptQuestions(
153
- questions: QuestionManifestQuestion[],
154
- answers: Record<string, string | string[] | boolean> = {}
155
- ): AskUserQuestion[] {
156
- const adapted: AskUserQuestion[] = [];
157
-
158
- for (const q of questions) {
159
- // Check dependsOn condition
160
- if (q.dependsOn) {
161
- const dependsAnswer = answers[q.dependsOn.questionId];
162
- if (dependsAnswer === undefined) {
163
- // Parent question not answered yet - skip this conditional question
164
- continue;
165
- }
166
-
167
- // Check if the answer matches the condition
168
- const expectedValues = Array.isArray(q.dependsOn.value)
169
- ? q.dependsOn.value
170
- : [q.dependsOn.value];
171
-
172
- const answerValue = Array.isArray(dependsAnswer) ? dependsAnswer[0] : dependsAnswer;
173
-
174
- if (!expectedValues.includes(answerValue as string)) {
175
- // Condition not met - skip this question
176
- continue;
177
- }
178
- }
179
-
180
- adapted.push(adaptQuestion(q));
181
- }
182
-
183
- return adapted;
184
- }
185
-
186
- /**
187
- * Parse JSON response from LLM (handles various formats)
188
- */
189
- export function parseLLMQuestionsResponse(text: string): QuestionManifestQuestion[] {
190
- // Try to extract JSON from the response
191
- let jsonStr = text;
192
-
193
- // Remove markdown code blocks
194
- jsonStr = jsonStr.replace(/```(?:json|javascript)?\s*/gi, '');
195
- jsonStr = jsonStr.replace(/```\s*$/gm, '');
196
-
197
- // Find JSON object or array
198
- const firstBrace = jsonStr.indexOf('{');
199
- const firstBracket = jsonStr.indexOf('[');
200
-
201
- let start = -1;
202
- if (firstBrace !== -1 && firstBracket !== -1) {
203
- start = Math.min(firstBrace, firstBracket);
204
- } else if (firstBrace !== -1) {
205
- start = firstBrace;
206
- } else if (firstBracket !== -1) {
207
- start = firstBracket;
208
- }
209
-
210
- if (start === -1) {
211
- throw new Error('No JSON found in response');
212
- }
213
-
214
- jsonStr = jsonStr.substring(start);
215
-
216
- // Handle trailing text after JSON
217
- const lastBrace = jsonStr.lastIndexOf('}');
218
- const lastBracket = jsonStr.lastIndexOf(']');
219
- const end = Math.max(lastBrace, lastBracket);
220
-
221
- if (end === -1) {
222
- throw new Error('Invalid JSON structure');
223
- }
224
-
225
- jsonStr = jsonStr.substring(0, end + 1);
226
-
227
- // Fix common issues
228
- jsonStr = jsonStr.replace(/,(\s*[}\]])/g, '$1'); // Remove trailing commas
229
- jsonStr = jsonStr.replace(/'/g, '"'); // Single quotes to double
230
-
231
- const parsed = JSON.parse(jsonStr);
232
-
233
- // Handle various JSON structures
234
- let questions: unknown[];
235
-
236
- if (Array.isArray(parsed)) {
237
- questions = parsed;
238
- } else if (parsed.questions && Array.isArray(parsed.questions)) {
239
- questions = parsed.questions;
240
- } else if (parsed.data && Array.isArray((parsed.data as Record<string, unknown>).questions)) {
241
- questions = (parsed.data as Record<string, unknown>).questions as unknown[];
242
- } else {
243
- throw new Error('No questions array found in response');
244
- }
245
-
246
- return questions as QuestionManifestQuestion[];
247
- }
248
-
249
- /**
250
- * Convert ask_user_question answers back to manifest format
251
- */
252
- export function answersToManifestFormat(
253
- answers: { question_header: string; selected_options: string[]; other_text?: string | null }[],
254
- questions: QuestionManifestQuestion[]
255
- ): Record<string, string | string[] | boolean> {
256
- const result: Record<string, string | string[] | boolean> = {};
257
-
258
- for (const answer of answers) {
259
- // Find matching question by header
260
- const headerLower = answer.question_header.toLowerCase();
261
- const question = questions.find((q) => {
262
- const qHeader = generateHeader(q.id).toLowerCase();
263
- return qHeader === headerLower || q.id.toLowerCase().includes(headerLower);
264
- });
265
-
266
- if (!question) {
267
- // Try to match by header prefix
268
- const matched = questions.find((q) => {
269
- const qHeader = generateHeader(q.id).toLowerCase();
270
- return qHeader.startsWith(headerLower.split('-')[0]);
271
- });
272
- if (matched) {
273
- result[matched.id] = answer.selected_options[0] || '';
274
- }
275
- continue;
276
- }
277
-
278
- // Handle multi-select vs single select
279
- if (question.type === 'multi-select' || answer.selected_options.length > 1) {
280
- result[question.id] = answer.selected_options;
281
- } else if (question.type === 'confirm') {
282
- result[question.id] = answer.selected_options[0] === 'Yes';
283
- } else {
284
- // For text or single select, use first option or other_text
285
- if (answer.other_text) {
286
- result[question.id] = answer.other_text;
287
- } else if (answer.selected_options.length > 0) {
288
- // Map label back to value if possible
289
- const option = question.options?.find((o) => o.label === answer.selected_options[0]);
290
- result[question.id] = option?.value ?? answer.selected_options[0];
291
- }
292
- }
293
- }
294
-
295
- return result;
296
- }