auq-mcp-server 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 +25 -0
- package/README.md +176 -0
- package/dist/__tests__/schema-validation.test.js +137 -0
- package/dist/__tests__/server.integration.test.js +263 -0
- package/dist/add.js +1 -0
- package/dist/add.test.js +5 -0
- package/dist/bin/auq.js +245 -0
- package/dist/bin/test-session-menu.js +28 -0
- package/dist/bin/test-tabbar.js +42 -0
- package/dist/file-utils.js +59 -0
- package/dist/format/ResponseFormatter.js +206 -0
- package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
- package/dist/package.json +74 -0
- package/dist/server.js +107 -0
- package/dist/session/ResponseFormatter.js +130 -0
- package/dist/session/SessionManager.js +474 -0
- package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
- package/dist/session/__tests__/SessionManager.test.js +553 -0
- package/dist/session/__tests__/atomic-operations.test.js +345 -0
- package/dist/session/__tests__/file-watcher.test.js +311 -0
- package/dist/session/__tests__/workflow.integration.test.js +334 -0
- package/dist/session/atomic-operations.js +307 -0
- package/dist/session/file-watcher.js +218 -0
- package/dist/session/index.js +7 -0
- package/dist/session/types.js +20 -0
- package/dist/session/utils.js +125 -0
- package/dist/session-manager.js +171 -0
- package/dist/session-watcher.js +110 -0
- package/dist/src/__tests__/schema-validation.test.js +170 -0
- package/dist/src/__tests__/server.integration.test.js +274 -0
- package/dist/src/add.js +1 -0
- package/dist/src/add.test.js +5 -0
- package/dist/src/server.js +163 -0
- package/dist/src/session/ResponseFormatter.js +163 -0
- package/dist/src/session/SessionManager.js +572 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
- package/dist/src/session/__tests__/SessionManager.test.js +593 -0
- package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
- package/dist/src/session/__tests__/file-watcher.test.js +311 -0
- package/dist/src/session/atomic-operations.js +307 -0
- package/dist/src/session/file-watcher.js +227 -0
- package/dist/src/session/index.js +7 -0
- package/dist/src/session/types.js +20 -0
- package/dist/src/session/utils.js +180 -0
- package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/src/tui/components/AnimatedGradient.js +45 -0
- package/dist/src/tui/components/ConfirmationDialog.js +89 -0
- package/dist/src/tui/components/CustomInput.js +14 -0
- package/dist/src/tui/components/Footer.js +55 -0
- package/dist/src/tui/components/Header.js +35 -0
- package/dist/src/tui/components/MultiLineTextInput.js +65 -0
- package/dist/src/tui/components/OptionsList.js +115 -0
- package/dist/src/tui/components/QuestionDisplay.js +36 -0
- package/dist/src/tui/components/ReviewScreen.js +57 -0
- package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
- package/dist/src/tui/components/StepperView.js +166 -0
- package/dist/src/tui/components/TabBar.js +42 -0
- package/dist/src/tui/components/Toast.js +19 -0
- package/dist/src/tui/components/WaitingScreen.js +20 -0
- package/dist/src/tui/session-watcher.js +195 -0
- package/dist/src/tui/theme.js +114 -0
- package/dist/src/tui/utils/gradientText.js +24 -0
- package/dist/tui/__tests__/session-watcher.test.js +368 -0
- package/dist/tui/session-watcher.js +183 -0
- package/package.json +78 -0
- package/scripts/postinstall.cjs +51 -0
package/dist/bin/auq.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { Box, render, Text, useApp, useInput } from "ink";
|
|
7
|
+
import React, { useEffect, useState } from "react";
|
|
8
|
+
import { SessionManager } from "../src/session/SessionManager.js";
|
|
9
|
+
import { ensureDirectoryExists, getSessionDirectory, } from "../src/session/utils.js";
|
|
10
|
+
import { Header } from "../src/tui/components/Header.js";
|
|
11
|
+
import { StepperView } from "../src/tui/components/StepperView.js";
|
|
12
|
+
import { Toast } from "../src/tui/components/Toast.js";
|
|
13
|
+
import { WaitingScreen } from "../src/tui/components/WaitingScreen.js";
|
|
14
|
+
import { createTUIWatcher } from "../src/tui/session-watcher.js";
|
|
15
|
+
// import { goodbyeText } from "../src/tui/utils/gradientText.js";
|
|
16
|
+
// Handle command-line arguments
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
const command = args[0];
|
|
19
|
+
// Display help
|
|
20
|
+
if (command === "--help" || command === "-h") {
|
|
21
|
+
console.log(`
|
|
22
|
+
AUQ MCP Server - Ask User Questions
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
auq [command] [options]
|
|
26
|
+
|
|
27
|
+
Commands:
|
|
28
|
+
(default) Start the TUI (Terminal User Interface)
|
|
29
|
+
server Start the MCP server (for use with MCP clients)
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--help, -h Show this help message
|
|
33
|
+
--version, -v Show version information
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
auq # Start TUI (wait for questions from AI)
|
|
37
|
+
auq server # Start MCP server (for Claude Desktop, etc.)
|
|
38
|
+
auq --help # Show this help message
|
|
39
|
+
|
|
40
|
+
For more information, visit:
|
|
41
|
+
https://github.com/paulp-o/ask-user-question-mcp
|
|
42
|
+
`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
// Display version
|
|
46
|
+
if (command === "--version" || command === "-v") {
|
|
47
|
+
// Read version from package.json
|
|
48
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
49
|
+
const __dirname = dirname(__filename);
|
|
50
|
+
const packageJsonPath = join(__dirname, "..", "package.json");
|
|
51
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
52
|
+
console.log(`auq-mcp-server v${packageJson.version}`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
// Handle 'server' command
|
|
56
|
+
if (command === "server") {
|
|
57
|
+
console.log("Starting MCP server...");
|
|
58
|
+
const serverProcess = exec("node dist/src/server.js", (error, stdout, stderr) => {
|
|
59
|
+
if (error) {
|
|
60
|
+
console.error(`Error starting server: ${error.message}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (stderr) {
|
|
64
|
+
console.error(stderr);
|
|
65
|
+
}
|
|
66
|
+
console.log(stdout);
|
|
67
|
+
});
|
|
68
|
+
// Forward signals
|
|
69
|
+
process.on("SIGINT", () => {
|
|
70
|
+
serverProcess.kill("SIGINT");
|
|
71
|
+
process.exit(0);
|
|
72
|
+
});
|
|
73
|
+
process.on("SIGTERM", () => {
|
|
74
|
+
serverProcess.kill("SIGTERM");
|
|
75
|
+
process.exit(0);
|
|
76
|
+
});
|
|
77
|
+
// Keep process alive
|
|
78
|
+
await new Promise(() => { });
|
|
79
|
+
}
|
|
80
|
+
const App = () => {
|
|
81
|
+
const { exit } = useApp();
|
|
82
|
+
const [state, setState] = useState({ mode: "WAITING" });
|
|
83
|
+
const [sessionQueue, setSessionQueue] = useState([]);
|
|
84
|
+
const [isInitialized, setIsInitialized] = useState(false);
|
|
85
|
+
const [toast, setToast] = useState(null);
|
|
86
|
+
const [showSessionLog, setShowSessionLog] = useState(true);
|
|
87
|
+
// Get session directory for logging
|
|
88
|
+
const sessionDir = getSessionDirectory();
|
|
89
|
+
// Auto-hide session log after 3 seconds
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
setShowSessionLog(false);
|
|
93
|
+
}, 3000);
|
|
94
|
+
return () => clearTimeout(timer);
|
|
95
|
+
}, []);
|
|
96
|
+
// Initialize: Load existing sessions + start persistent watcher
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
let watcherInstance = null;
|
|
99
|
+
const initialize = async () => {
|
|
100
|
+
try {
|
|
101
|
+
// Step 0: Ensure session directory exists
|
|
102
|
+
await ensureDirectoryExists(sessionDir);
|
|
103
|
+
// Step 1: Load existing pending sessions
|
|
104
|
+
const watcher = createTUIWatcher();
|
|
105
|
+
const sessionIds = await watcher.getPendingSessions();
|
|
106
|
+
const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
|
|
107
|
+
const sessionRequest = await watcher.getSessionRequest(sessionId);
|
|
108
|
+
if (!sessionRequest)
|
|
109
|
+
return null;
|
|
110
|
+
return {
|
|
111
|
+
sessionId,
|
|
112
|
+
sessionRequest,
|
|
113
|
+
timestamp: new Date(sessionRequest.timestamp),
|
|
114
|
+
};
|
|
115
|
+
}));
|
|
116
|
+
// Filter out null entries and sort by timestamp (FIFO - oldest first)
|
|
117
|
+
const validSessions = sessionData
|
|
118
|
+
.filter((s) => s !== null)
|
|
119
|
+
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
120
|
+
setSessionQueue(validSessions);
|
|
121
|
+
setIsInitialized(true);
|
|
122
|
+
// Step 2: Start persistent watcher for new sessions
|
|
123
|
+
watcherInstance = createTUIWatcher({ autoLoadData: true });
|
|
124
|
+
watcherInstance.startEnhancedWatching((event) => {
|
|
125
|
+
// Add new session to queue (FIFO - append to end)
|
|
126
|
+
setSessionQueue((prev) => {
|
|
127
|
+
// Check for duplicates
|
|
128
|
+
if (prev.some((s) => s.sessionId === event.sessionId)) {
|
|
129
|
+
return prev;
|
|
130
|
+
}
|
|
131
|
+
// Add to end of queue
|
|
132
|
+
return [
|
|
133
|
+
...prev,
|
|
134
|
+
{
|
|
135
|
+
sessionId: event.sessionId,
|
|
136
|
+
sessionRequest: event.sessionRequest,
|
|
137
|
+
timestamp: new Date(event.timestamp),
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
console.error("Failed to initialize:", error);
|
|
145
|
+
setIsInitialized(true); // Continue even if initialization fails
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
initialize();
|
|
149
|
+
// Cleanup: stop watcher on unmount
|
|
150
|
+
return () => {
|
|
151
|
+
if (watcherInstance) {
|
|
152
|
+
watcherInstance.stop();
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}, []);
|
|
156
|
+
// Auto-transition: WAITING → PROCESSING when queue has items
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!isInitialized)
|
|
159
|
+
return;
|
|
160
|
+
if (state.mode === "WAITING" && sessionQueue.length > 0) {
|
|
161
|
+
const [nextSession, ...rest] = sessionQueue;
|
|
162
|
+
setSessionQueue(rest);
|
|
163
|
+
setState({ mode: "PROCESSING", session: nextSession });
|
|
164
|
+
}
|
|
165
|
+
}, [state, sessionQueue, isInitialized]);
|
|
166
|
+
// Global 'q' to quit anytime
|
|
167
|
+
useInput((input) => {
|
|
168
|
+
if (input === "q") {
|
|
169
|
+
// If processing a session, reject it before exiting
|
|
170
|
+
if (state.mode === "PROCESSING") {
|
|
171
|
+
const sessionManager = new SessionManager();
|
|
172
|
+
sessionManager
|
|
173
|
+
.rejectSession(state.session.sessionId)
|
|
174
|
+
.catch((error) => {
|
|
175
|
+
console.error("Failed to reject session on quit:", error);
|
|
176
|
+
})
|
|
177
|
+
.finally(() => {
|
|
178
|
+
exit();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
exit();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
// Show toast notification
|
|
187
|
+
const showToast = (message, type = "success") => {
|
|
188
|
+
setToast({ message, type });
|
|
189
|
+
};
|
|
190
|
+
// Handle session completion
|
|
191
|
+
const handleSessionComplete = (wasRejected = false) => {
|
|
192
|
+
// Show appropriate toast
|
|
193
|
+
if (wasRejected) {
|
|
194
|
+
showToast("Question set rejected", "info");
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
showToast("✓ Answers submitted successfully!", "success");
|
|
198
|
+
}
|
|
199
|
+
if (sessionQueue.length > 0) {
|
|
200
|
+
// Auto-load next session
|
|
201
|
+
const [nextSession, ...rest] = sessionQueue;
|
|
202
|
+
setSessionQueue(rest);
|
|
203
|
+
setState({ mode: "PROCESSING", session: nextSession });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Return to WAITING
|
|
207
|
+
setState({ mode: "WAITING" });
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
// Render based on state
|
|
211
|
+
if (!isInitialized) {
|
|
212
|
+
return React.createElement(Text, null, "Loading...");
|
|
213
|
+
}
|
|
214
|
+
let mainContent;
|
|
215
|
+
if (state.mode === "WAITING") {
|
|
216
|
+
mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// PROCESSING mode
|
|
220
|
+
const { session } = state;
|
|
221
|
+
mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, sessionId: session.sessionId, sessionRequest: session.sessionRequest }));
|
|
222
|
+
}
|
|
223
|
+
// Render with header, toast overlay, and main content
|
|
224
|
+
return (React.createElement(Box, { flexDirection: "column", paddingX: 1 },
|
|
225
|
+
React.createElement(Header, { pendingCount: sessionQueue.length }),
|
|
226
|
+
toast && (React.createElement(Box, { marginBottom: 1, marginTop: 1 },
|
|
227
|
+
React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type }))),
|
|
228
|
+
mainContent,
|
|
229
|
+
showSessionLog && (React.createElement(Box, { marginTop: 1 },
|
|
230
|
+
React.createElement(Text, { dimColor: true },
|
|
231
|
+
"[AUQ] Session directory: ",
|
|
232
|
+
sessionDir)))));
|
|
233
|
+
};
|
|
234
|
+
// Clear terminal before showing app
|
|
235
|
+
console.clear();
|
|
236
|
+
const { waitUntilExit } = render(React.createElement(App, null));
|
|
237
|
+
// Handle Ctrl+C gracefully
|
|
238
|
+
process.on("SIGINT", () => {
|
|
239
|
+
process.exit(0);
|
|
240
|
+
});
|
|
241
|
+
// Show goodbye after Ink unmounts
|
|
242
|
+
waitUntilExit().then(() => {
|
|
243
|
+
process.stdout.write("\n");
|
|
244
|
+
console.log("👋 Goodbye! See you next time.");
|
|
245
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { render, Box, Text } from "ink";
|
|
2
|
+
!/usr/bin / env;
|
|
3
|
+
node;
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { SessionSelectionMenu } from "../src/tui/components/SessionSelectionMenu.js";
|
|
6
|
+
const TestSessionMenu = () => {
|
|
7
|
+
const [selectedSession, setSelectedSession] = React.useState(null);
|
|
8
|
+
if (selectedSession) {
|
|
9
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
10
|
+
React.createElement(Text, { bold: true, color: "green" }, "\u2713 Session Selected!"),
|
|
11
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
12
|
+
React.createElement(Text, null,
|
|
13
|
+
"Session ID: ",
|
|
14
|
+
selectedSession.sessionId),
|
|
15
|
+
React.createElement(Text, null,
|
|
16
|
+
"Questions: ",
|
|
17
|
+
selectedSession.sessionRequest.questions.length),
|
|
18
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
19
|
+
React.createElement(Text, { dimColor: true }, "Integration with StepperView will happen in the next subtask (7.5)")));
|
|
20
|
+
}
|
|
21
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
22
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
23
|
+
React.createElement(Text, { bold: true, color: "cyan" }, "SessionSelectionMenu Component Test")),
|
|
24
|
+
React.createElement(SessionSelectionMenu, { onSessionSelect: (sessionId, sessionRequest) => {
|
|
25
|
+
setSelectedSession({ sessionId, sessionRequest });
|
|
26
|
+
} })));
|
|
27
|
+
};
|
|
28
|
+
render(React.createElement(TestSessionMenu, null));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, Box, Text } from "ink";
|
|
2
|
+
!/usr/bin / env;
|
|
3
|
+
node;
|
|
4
|
+
import React, { useState, useEffect } from "react";
|
|
5
|
+
import { TabBar } from "../src/tui/components/TabBar.js";
|
|
6
|
+
const mockQuestions = [
|
|
7
|
+
{
|
|
8
|
+
options: [{ label: "JavaScript" }, { label: "TypeScript" }],
|
|
9
|
+
prompt: "Which programming language?",
|
|
10
|
+
title: "Language",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
options: [{ label: "Web" }, { label: "CLI" }],
|
|
14
|
+
prompt: "What type of application?",
|
|
15
|
+
title: "App Type",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
options: [{ label: "React" }, { label: "Vue" }],
|
|
19
|
+
prompt: "Which framework?",
|
|
20
|
+
title: "Framework",
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
const TestTabBar = () => {
|
|
24
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Cycle through questions every 2 seconds to show highlighting
|
|
27
|
+
const timer = setInterval(() => {
|
|
28
|
+
setCurrentIndex((prev) => (prev + 1) % mockQuestions.length);
|
|
29
|
+
}, 2000);
|
|
30
|
+
return () => clearInterval(timer);
|
|
31
|
+
}, []);
|
|
32
|
+
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
33
|
+
React.createElement(Text, { bold: true, color: "green" }, "TabBar Component Test"),
|
|
34
|
+
React.createElement(Text, { dimColor: true }, "(Cycles through questions every 2 seconds to show highlighting)"),
|
|
35
|
+
React.createElement(Box, { marginTop: 1 },
|
|
36
|
+
React.createElement(TabBar, { currentIndex: currentIndex, questions: mockQuestions })),
|
|
37
|
+
React.createElement(Box, { marginTop: 1 },
|
|
38
|
+
React.createElement(Text, { dimColor: true },
|
|
39
|
+
"Current question: ",
|
|
40
|
+
currentIndex + 1))));
|
|
41
|
+
};
|
|
42
|
+
render(React.createElement(TestTabBar, null));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { tmpdir } from 'os';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
/**
|
|
5
|
+
* Ensure a directory exists with secure permissions
|
|
6
|
+
* @param dirPath Directory path to create
|
|
7
|
+
* @param mode File permissions (defaults to 0o700 for secure user-only access)
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureDir(dirPath, mode = 0o700) {
|
|
10
|
+
try {
|
|
11
|
+
await fs.mkdir(dirPath, { recursive: true, mode });
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
throw new Error(`Failed to create directory ${dirPath}: ${error}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read and parse a JSON file with proper error handling
|
|
19
|
+
* @param filePath Path to the JSON file
|
|
20
|
+
* @returns Parsed JSON data
|
|
21
|
+
*/
|
|
22
|
+
export async function readJsonFile(filePath) {
|
|
23
|
+
try {
|
|
24
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
25
|
+
return JSON.parse(content);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
throw new Error(`Failed to read JSON file ${filePath}: ${error}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Write data to a file atomically to prevent corruption
|
|
33
|
+
* Creates a temporary file, writes to it, then renames to final destination
|
|
34
|
+
* @param filePath The final destination path
|
|
35
|
+
* @param data The data to write
|
|
36
|
+
* @param mode File permissions (defaults to 0o600 for secure user-only access)
|
|
37
|
+
*/
|
|
38
|
+
export async function writeJsonAtomically(filePath, data, mode = 0o600) {
|
|
39
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
40
|
+
const tempFile = join(tmpdir(), `temp-${Date.now()}-${Math.random().toString(36).substring(2)}.json`);
|
|
41
|
+
try {
|
|
42
|
+
// Ensure directory exists with proper permissions
|
|
43
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
// Write to temporary file first
|
|
45
|
+
await fs.writeFile(tempFile, JSON.stringify(data, null, 2), { mode });
|
|
46
|
+
// Atomically rename to final destination
|
|
47
|
+
await fs.rename(tempFile, filePath);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
// Clean up temp file if something went wrong
|
|
51
|
+
try {
|
|
52
|
+
await fs.unlink(tempFile);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Ignore cleanup errors
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Formatter Module
|
|
3
|
+
*
|
|
4
|
+
* Formats user answers into PRD-compliant text for AI consumption.
|
|
5
|
+
* Implements numbered questions, arrow symbols, and proper "Other:" formatting.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Response Formatter Class
|
|
9
|
+
*
|
|
10
|
+
* Converts user answers into human-readable, AI-friendly text format
|
|
11
|
+
* according to PRD specifications.
|
|
12
|
+
*/
|
|
13
|
+
export class ResponseFormatter {
|
|
14
|
+
static DEFAULT_PREAMBLE = "Here are the user's answers:\n\n";
|
|
15
|
+
static ARROW_SYMBOL = "→";
|
|
16
|
+
static OTHER_PREFIX = "Other:";
|
|
17
|
+
/**
|
|
18
|
+
* Format a complete session response according to PRD specifications
|
|
19
|
+
*
|
|
20
|
+
* @param sessionRequest - The original session request with questions
|
|
21
|
+
* @param sessionAnswer - The user's answers
|
|
22
|
+
* @param options - Optional formatting configuration
|
|
23
|
+
* @returns Formatted response ready for AI consumption
|
|
24
|
+
*/
|
|
25
|
+
static formatResponse(sessionRequest, sessionAnswer, options = {}) {
|
|
26
|
+
const { includePreamble = true, preambleText = ResponseFormatter.DEFAULT_PREAMBLE, includeTimestamps = false, maxLineLength = 0, } = options;
|
|
27
|
+
const { questions } = sessionRequest;
|
|
28
|
+
const { answers } = sessionAnswer;
|
|
29
|
+
// Build the formatted response
|
|
30
|
+
let formattedText = includePreamble ? preambleText : "";
|
|
31
|
+
// Process each question and its answer
|
|
32
|
+
questions.forEach((question, index) => {
|
|
33
|
+
const questionNumber = index + 1;
|
|
34
|
+
const answer = answers.find((a) => a.questionIndex === index);
|
|
35
|
+
// Add question number and prompt
|
|
36
|
+
formattedText += `${questionNumber}. ${question.prompt}\n`;
|
|
37
|
+
if (answer) {
|
|
38
|
+
// Format the answer
|
|
39
|
+
const answerText = this.formatAnswer(question, answer, maxLineLength);
|
|
40
|
+
formattedText += `${ResponseFormatter.ARROW_SYMBOL} ${answerText}\n`;
|
|
41
|
+
// Add timestamp if requested
|
|
42
|
+
if (includeTimestamps) {
|
|
43
|
+
formattedText += ` (Answered: ${new Date(answer.timestamp).toLocaleString()})\n`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// Handle unanswered question
|
|
48
|
+
formattedText += `${ResponseFormatter.ARROW_SYMBOL} [No answer provided]\n`;
|
|
49
|
+
}
|
|
50
|
+
formattedText += "\n"; // Add spacing between questions
|
|
51
|
+
});
|
|
52
|
+
// Calculate metadata
|
|
53
|
+
const metadata = {
|
|
54
|
+
totalQuestions: questions.length,
|
|
55
|
+
answeredQuestions: answers.length,
|
|
56
|
+
sessionDuration: this.calculateSessionDuration(sessionRequest, sessionAnswer),
|
|
57
|
+
hasCustomAnswers: answers.some((a) => a.customText && a.customText.trim().length > 0),
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
formatted_response: formattedText.trim(), // Remove trailing whitespace
|
|
61
|
+
metadata,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Format a single answer according to PRD specifications
|
|
66
|
+
*
|
|
67
|
+
* @param question - The question being answered
|
|
68
|
+
* @param answer - The user's answer
|
|
69
|
+
* @param maxLineLength - Optional maximum line length for text wrapping
|
|
70
|
+
* @returns Formatted answer text
|
|
71
|
+
*/
|
|
72
|
+
static formatAnswer(question, answer, maxLineLength) {
|
|
73
|
+
// If custom text is provided, use that (takes precedence)
|
|
74
|
+
if (answer.customText && answer.customText.trim().length > 0) {
|
|
75
|
+
const customText = answer.customText.trim();
|
|
76
|
+
const wrappedText = maxLineLength > 0 ? this.wrapText(customText, maxLineLength) : customText;
|
|
77
|
+
return `${ResponseFormatter.OTHER_PREFIX} '${wrappedText}'`;
|
|
78
|
+
}
|
|
79
|
+
// If a selected option is provided, find and format it
|
|
80
|
+
if (answer.selectedOption) {
|
|
81
|
+
const selectedOption = question.options.find((opt) => opt.label === answer.selectedOption);
|
|
82
|
+
if (selectedOption) {
|
|
83
|
+
// Format: "Label — Description" (if description exists)
|
|
84
|
+
let optionText = selectedOption.label;
|
|
85
|
+
if (selectedOption.description && selectedOption.description.trim().length > 0) {
|
|
86
|
+
optionText += ` — ${selectedOption.description}`;
|
|
87
|
+
}
|
|
88
|
+
return optionText;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Fallback for edge cases
|
|
92
|
+
return answer.selectedOption || "[Invalid answer]";
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Calculate session duration in milliseconds
|
|
96
|
+
*/
|
|
97
|
+
static calculateSessionDuration(sessionRequest, sessionAnswer) {
|
|
98
|
+
return new Date(sessionAnswer.timestamp).getTime() - new Date(sessionRequest.timestamp).getTime();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wrap text to fit within specified line length
|
|
102
|
+
*
|
|
103
|
+
* @param text - Text to wrap
|
|
104
|
+
* @param maxLength - Maximum line length
|
|
105
|
+
* @returns Wrapped text with proper line breaks
|
|
106
|
+
*/
|
|
107
|
+
static wrapText(text, maxLength) {
|
|
108
|
+
if (maxLength <= 0 || text.length <= maxLength) {
|
|
109
|
+
return text;
|
|
110
|
+
}
|
|
111
|
+
const words = text.split(" ");
|
|
112
|
+
const lines = [];
|
|
113
|
+
let currentLine = "";
|
|
114
|
+
for (const word of words) {
|
|
115
|
+
if ((currentLine + " " + word).length <= maxLength) {
|
|
116
|
+
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
if (currentLine) {
|
|
120
|
+
lines.push(currentLine);
|
|
121
|
+
currentLine = word;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Word is longer than maxLength, break it
|
|
125
|
+
lines.push(word.substring(0, maxLength));
|
|
126
|
+
currentLine = word.substring(maxLength);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (currentLine) {
|
|
131
|
+
lines.push(currentLine);
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Validate that answers are complete and properly formatted
|
|
137
|
+
*
|
|
138
|
+
* @param sessionRequest - The original session request
|
|
139
|
+
* @param sessionAnswer - The user's answers
|
|
140
|
+
* @returns Validation result with any issues found
|
|
141
|
+
*/
|
|
142
|
+
static validateAnswers(sessionRequest, sessionAnswer) {
|
|
143
|
+
const issues = [];
|
|
144
|
+
const warnings = [];
|
|
145
|
+
const { questions } = sessionRequest;
|
|
146
|
+
const { answers } = sessionAnswer;
|
|
147
|
+
// Check if all questions have answers
|
|
148
|
+
if (answers.length < questions.length) {
|
|
149
|
+
issues.push(`${questions.length - answers.length} question(s) were not answered`);
|
|
150
|
+
}
|
|
151
|
+
// Check for duplicate answers
|
|
152
|
+
const answeredIndices = answers.map((a) => a.questionIndex);
|
|
153
|
+
const uniqueIndices = [...new Set(answeredIndices)];
|
|
154
|
+
if (answeredIndices.length !== uniqueIndices.length) {
|
|
155
|
+
issues.push("Duplicate answers detected for some questions");
|
|
156
|
+
}
|
|
157
|
+
// Validate each answer
|
|
158
|
+
answers.forEach((answer, index) => {
|
|
159
|
+
// Check question index validity
|
|
160
|
+
if (answer.questionIndex < 0 || answer.questionIndex >= questions.length) {
|
|
161
|
+
issues.push(`Answer ${index + 1} references invalid question index: ${answer.questionIndex}`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const question = questions[answer.questionIndex];
|
|
165
|
+
// Check if answer has either selected option or custom text
|
|
166
|
+
if (!answer.selectedOption && (!answer.customText || answer.customText.trim().length === 0)) {
|
|
167
|
+
issues.push(`Answer ${index + 1} has no selected option or custom text`);
|
|
168
|
+
}
|
|
169
|
+
// Validate selected option exists in question
|
|
170
|
+
if (answer.selectedOption) {
|
|
171
|
+
const optionExists = question.options.some((opt) => opt.label === answer.selectedOption);
|
|
172
|
+
if (!optionExists) {
|
|
173
|
+
issues.push(`Answer ${index + 1} references non-existent option: ${answer.selectedOption}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Check for extremely long custom answers
|
|
177
|
+
if (answer.customText && answer.customText.length > 1000) {
|
|
178
|
+
warnings.push(`Answer ${index + 1} has very long custom text (${answer.customText.length} characters)`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
isValid: issues.length === 0,
|
|
183
|
+
issues,
|
|
184
|
+
warnings,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Create a summary of the session for logging/debugging
|
|
189
|
+
*
|
|
190
|
+
* @param sessionRequest - The original session request
|
|
191
|
+
* @param sessionAnswer - The user's answers
|
|
192
|
+
* @returns Human-readable session summary
|
|
193
|
+
*/
|
|
194
|
+
static createSessionSummary(sessionRequest, sessionAnswer) {
|
|
195
|
+
const { questions } = sessionRequest;
|
|
196
|
+
const { answers } = sessionAnswer;
|
|
197
|
+
const duration = this.calculateSessionDuration(sessionRequest, sessionAnswer);
|
|
198
|
+
return `Session Summary:
|
|
199
|
+
- Session ID: ${sessionRequest.sessionId}
|
|
200
|
+
- Total Questions: ${questions?.length || 0}
|
|
201
|
+
- Answered Questions: ${answers?.length || 0}
|
|
202
|
+
- Duration: ${Math.round(duration / 1000)}s
|
|
203
|
+
- Custom Answers: ${(answers || []).filter((a) => a.customText && a.customText.trim().length > 0).length}
|
|
204
|
+
- Status: ${sessionRequest.status}`;
|
|
205
|
+
}
|
|
206
|
+
}
|