auq-mcp-server 0.1.9 → 0.1.24

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 (39) hide show
  1. package/README.md +24 -0
  2. package/dist/bin/auq.js +127 -7
  3. package/dist/package.json +15 -5
  4. package/dist/src/__tests__/schema-validation.test.js +32 -24
  5. package/dist/src/core/ask-user-questions.js +74 -0
  6. package/dist/src/server.js +11 -74
  7. package/dist/src/tui/components/Header.js +9 -1
  8. package/dist/src/tui/components/QuestionDisplay.js +10 -6
  9. package/dist/src/tui/components/ReviewScreen.js +6 -2
  10. package/dist/src/tui/components/StepperView.js +25 -3
  11. package/dist/src/tui/components/WaitingScreen.js +31 -4
  12. package/package.json +7 -1
  13. package/dist/__tests__/schema-validation.test.js +0 -137
  14. package/dist/__tests__/server.integration.test.js +0 -263
  15. package/dist/add.js +0 -1
  16. package/dist/add.test.js +0 -5
  17. package/dist/bin/test-session-menu.js +0 -28
  18. package/dist/bin/test-tabbar.js +0 -42
  19. package/dist/file-utils.js +0 -59
  20. package/dist/format/ResponseFormatter.js +0 -206
  21. package/dist/format/__tests__/ResponseFormatter.test.js +0 -380
  22. package/dist/server.js +0 -107
  23. package/dist/session/ResponseFormatter.js +0 -130
  24. package/dist/session/SessionManager.js +0 -474
  25. package/dist/session/__tests__/ResponseFormatter.test.js +0 -417
  26. package/dist/session/__tests__/SessionManager.test.js +0 -553
  27. package/dist/session/__tests__/atomic-operations.test.js +0 -345
  28. package/dist/session/__tests__/file-watcher.test.js +0 -311
  29. package/dist/session/__tests__/workflow.integration.test.js +0 -334
  30. package/dist/session/atomic-operations.js +0 -307
  31. package/dist/session/file-watcher.js +0 -218
  32. package/dist/session/index.js +0 -7
  33. package/dist/session/types.js +0 -20
  34. package/dist/session/utils.js +0 -125
  35. package/dist/session-manager.js +0 -171
  36. package/dist/session-watcher.js +0 -110
  37. package/dist/src/tui/components/SessionSelectionMenu.js +0 -151
  38. package/dist/tui/__tests__/session-watcher.test.js +0 -368
  39. package/dist/tui/session-watcher.js +0 -183
@@ -1,474 +0,0 @@
1
- /**
2
- * SessionManager - Core session management for AskUserQuery MCP server
3
- */
4
- import { promises as fs } from "fs";
5
- import { join } from "path";
6
- import { v4 as uuidv4 } from "uuid";
7
- import { atomicDeleteFile, AtomicReadError, atomicReadFile, AtomicWriteError, atomicWriteFile, } from "./atomic-operations.js";
8
- import { PromiseFileWatcher } from "./file-watcher.js";
9
- import { ResponseFormatter } from "./ResponseFormatter.js";
10
- import { DEFAULT_SESSION_CONFIG, SESSION_FILES } from "./types.js";
11
- import { createSafeFilename, ensureDirectoryExists, fileExists, getCurrentTimestamp, isTimestampExpired, resolveSessionDirectory, sanitizeSessionId, validateSessionDirectory, } from "./utils.js";
12
- export class SessionManager {
13
- baseDir;
14
- config;
15
- fileWatcher;
16
- sessionsDir;
17
- constructor(config = {}) {
18
- this.config = {
19
- ...DEFAULT_SESSION_CONFIG,
20
- ...config,
21
- };
22
- // Resolve the directory path using XDG-compliant resolution
23
- this.baseDir = resolveSessionDirectory(this.config.baseDir);
24
- this.sessionsDir = this.baseDir;
25
- }
26
- /**
27
- * Clean up old sessions that have exceeded the retention period (garbage collection)
28
- * This is different from session timeout - retention period determines when old
29
- * sessions are permanently deleted, regardless of their completion status.
30
- */
31
- async cleanupExpiredSessions() {
32
- try {
33
- const sessionIds = await this.getAllSessionIds();
34
- let cleanedCount = 0;
35
- for (const sessionId of sessionIds) {
36
- const status = await this.getSessionStatus(sessionId);
37
- if (status && this.isSessionRetentionExpired(status)) {
38
- await this.deleteSession(sessionId);
39
- cleanedCount++;
40
- }
41
- }
42
- return cleanedCount;
43
- }
44
- catch (error) {
45
- console.warn("Failed to cleanup expired sessions:", error);
46
- return 0;
47
- }
48
- }
49
- /**
50
- * Create a new session with unique ID
51
- */
52
- async createSession(questions) {
53
- if (!questions || questions.length === 0) {
54
- throw new Error("At least one question is required to create a session");
55
- }
56
- const sessionId = uuidv4();
57
- const sessionDir = this.getSessionDir(sessionId);
58
- // Create session directory with secure permissions
59
- await ensureDirectoryExists(sessionDir);
60
- const timestamp = getCurrentTimestamp();
61
- // Create session request
62
- const sessionRequest = {
63
- questions,
64
- sessionId,
65
- status: "pending",
66
- timestamp,
67
- };
68
- // Create session status
69
- const sessionStatus = {
70
- createdAt: timestamp,
71
- lastModified: timestamp,
72
- sessionId,
73
- status: "pending",
74
- totalQuestions: questions.length,
75
- };
76
- // Write session files
77
- await Promise.all([
78
- this.writeSessionFile(sessionId, SESSION_FILES.REQUEST, sessionRequest),
79
- this.writeSessionFile(sessionId, SESSION_FILES.STATUS, sessionStatus),
80
- ]);
81
- return sessionId;
82
- }
83
- /**
84
- * Delete a session directory and all files using atomic operations
85
- */
86
- async deleteSession(sessionId) {
87
- if (!this.isValidSessionId(sessionId)) {
88
- throw new Error(`Invalid session ID format: ${sessionId}`);
89
- }
90
- const sessionDir = this.getSessionDir(sessionId);
91
- try {
92
- // Delete session files atomically
93
- const filesToDelete = [
94
- SESSION_FILES.REQUEST,
95
- SESSION_FILES.STATUS,
96
- SESSION_FILES.ANSWERS,
97
- ];
98
- for (const filename of filesToDelete) {
99
- const filePath = join(sessionDir, createSafeFilename(sessionId, filename));
100
- try {
101
- await atomicDeleteFile(filePath);
102
- }
103
- catch (error) {
104
- // Ignore file not found errors during cleanup
105
- if (!error?.cause?.message.includes("ENOENT")) {
106
- console.warn(`Warning: Failed to delete session file ${filename}: ${error}`);
107
- }
108
- }
109
- }
110
- // Delete session directory
111
- await fs.rm(sessionDir, { force: true, recursive: true });
112
- }
113
- catch (error) {
114
- throw new Error(`Failed to delete session ${sessionId}: ${error}`);
115
- }
116
- }
117
- /**
118
- * Get all session IDs
119
- */
120
- async getAllSessionIds() {
121
- try {
122
- return await fs.readdir(this.sessionsDir);
123
- }
124
- catch (error) {
125
- if (error.code === "ENOENT") {
126
- return [];
127
- }
128
- throw error;
129
- }
130
- }
131
- /**
132
- * Get configuration
133
- */
134
- getConfig() {
135
- return { ...this.config };
136
- }
137
- /**
138
- * Get session answers
139
- */
140
- async getSessionAnswers(sessionId) {
141
- return this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
142
- }
143
- /**
144
- * Get session count
145
- */
146
- async getSessionCount() {
147
- const sessionIds = await this.getAllSessionIds();
148
- return sessionIds.length;
149
- }
150
- /**
151
- * Get session request (questions)
152
- */
153
- async getSessionRequest(sessionId) {
154
- return this.readSessionFile(sessionId, SESSION_FILES.REQUEST);
155
- }
156
- /**
157
- * Get session status
158
- */
159
- async getSessionStatus(sessionId) {
160
- return this.readSessionFile(sessionId, SESSION_FILES.STATUS);
161
- }
162
- /**
163
- * Initialize the session manager - create base directories
164
- */
165
- async initialize() {
166
- try {
167
- await ensureDirectoryExists(this.sessionsDir);
168
- // Validate the directory was created and is accessible
169
- const isValid = await validateSessionDirectory(this.sessionsDir);
170
- if (!isValid) {
171
- throw new Error(`Failed to create or access session directory: ${this.sessionsDir}`);
172
- }
173
- }
174
- catch (error) {
175
- throw new Error(`Failed to initialize session directories: ${error}`);
176
- }
177
- }
178
- /**
179
- * Check if maximum session limit is reached
180
- */
181
- async isSessionLimitReached() {
182
- const maxSessions = this.config.maxSessions || 100;
183
- const currentCount = await this.getSessionCount();
184
- return currentCount >= maxSessions;
185
- }
186
- /**
187
- * Save session answers
188
- */
189
- async saveSessionAnswers(sessionId, answers) {
190
- const exists = await this.sessionExists(sessionId);
191
- if (!exists) {
192
- throw new Error(`Session not found: ${sessionId}`);
193
- }
194
- await this.writeSessionFile(sessionId, SESSION_FILES.ANSWERS, answers);
195
- // Update session status to completed
196
- await this.updateSessionStatus(sessionId, "completed");
197
- }
198
- /**
199
- * Check if session exists
200
- */
201
- async sessionExists(sessionId) {
202
- // First validate session ID format
203
- if (!this.isValidSessionId(sessionId)) {
204
- return false;
205
- }
206
- const sessionDir = this.getSessionDir(sessionId);
207
- return await fileExists(sessionDir);
208
- }
209
- /**
210
- * Start a complete session lifecycle from creation to formatted response
211
- *
212
- * This is the main orchestration method that:
213
- * 1. Creates a new session with the provided questions
214
- * 2. Waits for user to submit answers (with optional timeout)
215
- * 3. Reads and validates the answers
216
- * 4. Formats the response according to PRD specification
217
- * 5. Updates session status to completed
218
- * 6. Returns the formatted response for the AI model
219
- *
220
- * @param questions - Array of questions to ask the user
221
- * @returns Object containing sessionId and formatted response text
222
- * @throws Error if timeout occurs, validation fails, or file operations fail
223
- */
224
- async startSession(questions) {
225
- // Step 1: Create the session
226
- const sessionId = await this.createSession(questions);
227
- try {
228
- // Step 2: Calculate timeouts
229
- const sessionTimeout = this.config.sessionTimeout ?? 0; // 0 = infinite
230
- const watcherTimeout = sessionTimeout > 0
231
- ? Math.floor(sessionTimeout * 0.9) // 90% of session timeout
232
- : 0; // Also infinite if session is infinite
233
- // Step 3: Wait for answers with timeout
234
- try {
235
- await this.waitForAnswers(sessionId, watcherTimeout);
236
- }
237
- catch (error) {
238
- // Watcher timeout occurred
239
- await this.updateSessionStatus(sessionId, "timed_out");
240
- throw new Error(`Session ${sessionId} timed out waiting for user response: ${error instanceof Error ? error.message : String(error)}`);
241
- }
242
- // Step 4: Read and validate answers
243
- let answers;
244
- try {
245
- answers = await this.getSessionAnswers(sessionId);
246
- }
247
- catch (error) {
248
- // Handle JSON parse errors or other read failures
249
- await this.updateSessionStatus(sessionId, "abandoned");
250
- throw error;
251
- }
252
- if (!answers) {
253
- await this.updateSessionStatus(sessionId, "abandoned");
254
- throw new Error(`Answers file was created but is invalid for session ${sessionId}`);
255
- }
256
- const request = await this.getSessionRequest(sessionId);
257
- if (!request) {
258
- await this.updateSessionStatus(sessionId, "abandoned");
259
- throw new Error(`Session request not found: ${sessionId}`);
260
- }
261
- // Step 5: Validate answers match questions
262
- try {
263
- ResponseFormatter.validateAnswers(answers, request.questions);
264
- }
265
- catch (error) {
266
- await this.updateSessionStatus(sessionId, "abandoned");
267
- throw new Error(`Answer validation failed for session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
268
- }
269
- // Step 6: Format the response according to PRD specification
270
- const formattedResponse = ResponseFormatter.formatUserResponse(answers, request.questions);
271
- // Step 7: Update final status
272
- await this.updateSessionStatus(sessionId, "completed");
273
- // Step 8: Return results
274
- return {
275
- formattedResponse,
276
- sessionId,
277
- };
278
- }
279
- catch (error) {
280
- // Ensure any errors are properly propagated with session context
281
- if (error instanceof Error) {
282
- throw error;
283
- }
284
- throw new Error(`Session ${sessionId} failed: ${String(error)}`);
285
- }
286
- }
287
- /**
288
- * Update session status
289
- */
290
- async updateSessionStatus(sessionId, status, additionalData) {
291
- // First validate session ID format
292
- if (!this.isValidSessionId(sessionId)) {
293
- throw new Error(`Session not found: ${sessionId}`);
294
- }
295
- const currentStatus = await this.getSessionStatus(sessionId);
296
- if (!currentStatus) {
297
- throw new Error(`Session not found: ${sessionId}`);
298
- }
299
- const updatedStatus = {
300
- ...currentStatus,
301
- lastModified: getCurrentTimestamp(),
302
- status,
303
- ...additionalData,
304
- };
305
- await this.writeSessionFile(sessionId, SESSION_FILES.STATUS, updatedStatus);
306
- }
307
- /**
308
- * Validate session data integrity
309
- */
310
- async validateSession(sessionId) {
311
- const issues = [];
312
- // Check if session exists
313
- if (!(await this.sessionExists(sessionId))) {
314
- issues.push("Session directory does not exist");
315
- return { issues, isValid: false };
316
- }
317
- // Check required files
318
- const requiredFiles = [SESSION_FILES.REQUEST, SESSION_FILES.STATUS];
319
- for (const filename of requiredFiles) {
320
- const filePath = join(this.getSessionDir(sessionId), createSafeFilename(sessionId, filename));
321
- if (!(await fileExists(filePath))) {
322
- issues.push(`Required file missing: ${filename}`);
323
- }
324
- }
325
- // If any required files are missing, don't try to read them
326
- if (issues.length > 0) {
327
- return {
328
- issues,
329
- isValid: false,
330
- };
331
- }
332
- // Validate session status consistency
333
- const status = await this.getSessionStatus(sessionId);
334
- const request = await this.getSessionRequest(sessionId);
335
- if (status && request) {
336
- if (status.sessionId !== sessionId) {
337
- issues.push("Session ID mismatch in status file");
338
- }
339
- if (request.sessionId !== sessionId) {
340
- issues.push("Session ID mismatch in request file");
341
- }
342
- if (status.totalQuestions !== request.questions.length) {
343
- issues.push("Question count mismatch between status and request");
344
- }
345
- }
346
- else {
347
- issues.push("Could not read session status or request");
348
- }
349
- return {
350
- issues,
351
- isValid: issues.length === 0,
352
- };
353
- }
354
- /**
355
- * Wait for user answers to be submitted for a specific session
356
- * Returns the session ID when answers are detected, or rejects on timeout
357
- */
358
- async waitForAnswers(sessionId, timeoutMs) {
359
- if (!this.fileWatcher) {
360
- this.fileWatcher = new PromiseFileWatcher({
361
- debounceMs: 100,
362
- ignoreInitial: true,
363
- timeoutMs: timeoutMs ?? 0, // 0 means infinite wait time by default
364
- });
365
- }
366
- const sessionDir = this.getSessionDir(sessionId);
367
- try {
368
- const answersPath = await this.fileWatcher.waitForFile(sessionDir, SESSION_FILES.ANSWERS);
369
- // Clean up the watcher after successful wait
370
- this.fileWatcher.cleanup();
371
- this.fileWatcher = undefined;
372
- // Verify the answers file exists and return the session ID
373
- console.debug(`Answers file created: ${answersPath}`);
374
- return sessionId;
375
- }
376
- catch (error) {
377
- // Clean up on error
378
- if (this.fileWatcher) {
379
- this.fileWatcher.cleanup();
380
- this.fileWatcher = undefined;
381
- }
382
- throw error;
383
- }
384
- }
385
- /**
386
- * Get session directory path for a given session ID
387
- */
388
- getSessionDir(sessionId) {
389
- return join(this.sessionsDir, sessionId);
390
- }
391
- /**
392
- * Check if a session is expired based on timeout
393
- */
394
- isSessionExpired(status) {
395
- const timeout = this.config.sessionTimeout || 0;
396
- if (timeout <= 0)
397
- return false;
398
- return isTimestampExpired(status.lastModified, timeout);
399
- }
400
- /**
401
- * Check if a session has exceeded the retention period and should be garbage collected
402
- */
403
- isSessionRetentionExpired(status) {
404
- const retentionPeriod = this.config.retentionPeriod ?? 604800000; // Default 7 days
405
- if (retentionPeriod <= 0)
406
- return false;
407
- // Check the more recent timestamp (lastModified or createdAt)
408
- // to determine if session is old enough for cleanup
409
- const recentTimestamp = status.lastModified > status.createdAt
410
- ? status.lastModified
411
- : status.createdAt;
412
- return isTimestampExpired(recentTimestamp, retentionPeriod);
413
- }
414
- /**
415
- * Validate session ID format
416
- */
417
- isValidSessionId(sessionId) {
418
- return sanitizeSessionId(sessionId);
419
- }
420
- /**
421
- * Read data from a session file using atomic operations
422
- */
423
- async readSessionFile(sessionId, filename, fallback = null) {
424
- // First validate session ID format
425
- if (!this.isValidSessionId(sessionId)) {
426
- return fallback;
427
- }
428
- const safeFilename = createSafeFilename(sessionId, filename);
429
- const filePath = join(this.getSessionDir(sessionId), safeFilename);
430
- try {
431
- const content = await atomicReadFile(filePath, {
432
- encoding: "utf8",
433
- maxRetries: 3,
434
- retryDelay: 100,
435
- });
436
- try {
437
- return JSON.parse(content);
438
- }
439
- catch (parseError) {
440
- throw new Error(`Failed to parse JSON from session file ${filename} for session ${sessionId}: ${parseError}`);
441
- }
442
- }
443
- catch (error) {
444
- if (error instanceof AtomicReadError) {
445
- // Check if the error is just that the file doesn't exist
446
- if (error.cause?.message.includes("File does not exist") ||
447
- error.message.includes("File does not exist")) {
448
- return fallback;
449
- }
450
- throw new Error(`Failed to read session file ${filename} for session ${sessionId}: ${error.message}`);
451
- }
452
- throw error;
453
- }
454
- }
455
- /**
456
- * Write data to a session file using atomic operations
457
- */
458
- async writeSessionFile(sessionId, filename, data) {
459
- const safeFilename = createSafeFilename(sessionId, filename);
460
- const filePath = join(this.getSessionDir(sessionId), safeFilename);
461
- try {
462
- await atomicWriteFile(filePath, JSON.stringify(data, null, 2), {
463
- encoding: "utf8",
464
- mode: 0o600,
465
- });
466
- }
467
- catch (error) {
468
- if (error instanceof AtomicWriteError) {
469
- throw new Error(`Failed to write session file ${filename} for session ${sessionId}: ${error.message}`);
470
- }
471
- throw error;
472
- }
473
- }
474
- }