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.
Files changed (66) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +176 -0
  3. package/dist/__tests__/schema-validation.test.js +137 -0
  4. package/dist/__tests__/server.integration.test.js +263 -0
  5. package/dist/add.js +1 -0
  6. package/dist/add.test.js +5 -0
  7. package/dist/bin/auq.js +245 -0
  8. package/dist/bin/test-session-menu.js +28 -0
  9. package/dist/bin/test-tabbar.js +42 -0
  10. package/dist/file-utils.js +59 -0
  11. package/dist/format/ResponseFormatter.js +206 -0
  12. package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
  13. package/dist/package.json +74 -0
  14. package/dist/server.js +107 -0
  15. package/dist/session/ResponseFormatter.js +130 -0
  16. package/dist/session/SessionManager.js +474 -0
  17. package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
  18. package/dist/session/__tests__/SessionManager.test.js +553 -0
  19. package/dist/session/__tests__/atomic-operations.test.js +345 -0
  20. package/dist/session/__tests__/file-watcher.test.js +311 -0
  21. package/dist/session/__tests__/workflow.integration.test.js +334 -0
  22. package/dist/session/atomic-operations.js +307 -0
  23. package/dist/session/file-watcher.js +218 -0
  24. package/dist/session/index.js +7 -0
  25. package/dist/session/types.js +20 -0
  26. package/dist/session/utils.js +125 -0
  27. package/dist/session-manager.js +171 -0
  28. package/dist/session-watcher.js +110 -0
  29. package/dist/src/__tests__/schema-validation.test.js +170 -0
  30. package/dist/src/__tests__/server.integration.test.js +274 -0
  31. package/dist/src/add.js +1 -0
  32. package/dist/src/add.test.js +5 -0
  33. package/dist/src/server.js +163 -0
  34. package/dist/src/session/ResponseFormatter.js +163 -0
  35. package/dist/src/session/SessionManager.js +572 -0
  36. package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
  37. package/dist/src/session/__tests__/SessionManager.test.js +593 -0
  38. package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
  39. package/dist/src/session/__tests__/file-watcher.test.js +311 -0
  40. package/dist/src/session/atomic-operations.js +307 -0
  41. package/dist/src/session/file-watcher.js +227 -0
  42. package/dist/src/session/index.js +7 -0
  43. package/dist/src/session/types.js +20 -0
  44. package/dist/src/session/utils.js +180 -0
  45. package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
  46. package/dist/src/tui/components/AnimatedGradient.js +45 -0
  47. package/dist/src/tui/components/ConfirmationDialog.js +89 -0
  48. package/dist/src/tui/components/CustomInput.js +14 -0
  49. package/dist/src/tui/components/Footer.js +55 -0
  50. package/dist/src/tui/components/Header.js +35 -0
  51. package/dist/src/tui/components/MultiLineTextInput.js +65 -0
  52. package/dist/src/tui/components/OptionsList.js +115 -0
  53. package/dist/src/tui/components/QuestionDisplay.js +36 -0
  54. package/dist/src/tui/components/ReviewScreen.js +57 -0
  55. package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
  56. package/dist/src/tui/components/StepperView.js +166 -0
  57. package/dist/src/tui/components/TabBar.js +42 -0
  58. package/dist/src/tui/components/Toast.js +19 -0
  59. package/dist/src/tui/components/WaitingScreen.js +20 -0
  60. package/dist/src/tui/session-watcher.js +195 -0
  61. package/dist/src/tui/theme.js +114 -0
  62. package/dist/src/tui/utils/gradientText.js +24 -0
  63. package/dist/tui/__tests__/session-watcher.test.js +368 -0
  64. package/dist/tui/session-watcher.js +183 -0
  65. package/package.json +78 -0
  66. package/scripts/postinstall.cjs +51 -0
@@ -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
+ }