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,130 @@
1
+ /**
2
+ * Response Formatter
3
+ *
4
+ * Formats user answers according to PRD specification for returning to AI models
5
+ */
6
+ /**
7
+ * ResponseFormatter - Formats session answers into human-readable text
8
+ * according to the PRD specification
9
+ */
10
+ export class ResponseFormatter {
11
+ /**
12
+ * Format user answers into PRD-compliant text response
13
+ *
14
+ * Format specification:
15
+ * - Header: "Here are the user's answers:"
16
+ * - Numbered questions: "1. {prompt}"
17
+ * - Arrow symbol for answers: "→ {label} — {description}"
18
+ * - Custom text: "→ Other: '{customText}'"
19
+ * - Double newline separation between questions
20
+ *
21
+ * @param answers - Session answer data containing user responses
22
+ * @param questions - Original questions asked to the user
23
+ * @returns Formatted text response ready to send to AI model
24
+ */
25
+ static formatUserResponse(answers, questions) {
26
+ // Validate that we have matching questions and answers
27
+ if (answers.answers.length === 0) {
28
+ throw new Error("No answers provided in session");
29
+ }
30
+ if (questions.length === 0) {
31
+ throw new Error("No questions provided");
32
+ }
33
+ // Start with header
34
+ const lines = ["Here are the user's answers:", ""];
35
+ // Format each question and its answer
36
+ const formattedQuestions = [];
37
+ for (let i = 0; i < questions.length; i++) {
38
+ const question = questions[i];
39
+ const answer = answers.answers.find((a) => a.questionIndex === i);
40
+ if (!answer) {
41
+ // If no answer found for this question, skip it
42
+ // (This shouldn't happen in normal operation, but handle gracefully)
43
+ continue;
44
+ }
45
+ // Format the question and answer
46
+ const formattedQA = this.formatQuestion(question, answer, i + 1);
47
+ formattedQuestions.push(formattedQA);
48
+ }
49
+ // Join formatted questions with blank lines between them
50
+ lines.push(formattedQuestions.join("\n\n"));
51
+ return lines.join("\n");
52
+ }
53
+ /**
54
+ * Validate that answers match the questions
55
+ *
56
+ * @param answers - Session answer data
57
+ * @param questions - Original questions
58
+ * @throws Error if validation fails
59
+ */
60
+ static validateAnswers(answers, questions) {
61
+ // Check that we have answers
62
+ if (!answers || !answers.answers || answers.answers.length === 0) {
63
+ throw new Error("No answers provided");
64
+ }
65
+ // Check that we have questions
66
+ if (!questions || questions.length === 0) {
67
+ throw new Error("No questions provided");
68
+ }
69
+ // Check each answer references a valid question
70
+ for (const answer of answers.answers) {
71
+ if (answer.questionIndex < 0 ||
72
+ answer.questionIndex >= questions.length) {
73
+ throw new Error(`Answer references invalid question index: ${answer.questionIndex}`);
74
+ }
75
+ // Check that answer has either selectedOption or customText
76
+ if (!answer.selectedOption && !answer.customText) {
77
+ throw new Error(`Answer for question ${answer.questionIndex} has neither selectedOption nor customText`);
78
+ }
79
+ // If selectedOption is provided, verify it exists in the question's options
80
+ if (answer.selectedOption) {
81
+ const question = questions[answer.questionIndex];
82
+ const optionExists = question.options.some((opt) => opt.label === answer.selectedOption);
83
+ if (!optionExists) {
84
+ throw new Error(`Answer for question ${answer.questionIndex} references non-existent option: ${answer.selectedOption}`);
85
+ }
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Format a single question and its answer
91
+ *
92
+ * @param question - The question data
93
+ * @param answer - The user's answer
94
+ * @param index - Question number (1-indexed for display)
95
+ * @returns Formatted string for this question/answer pair
96
+ */
97
+ static formatQuestion(question, answer, index) {
98
+ const lines = [];
99
+ // Add question with number
100
+ lines.push(`${index}. ${question.prompt}`);
101
+ // Format the answer
102
+ if (answer.customText) {
103
+ // Custom text answer - escape single quotes for display
104
+ const escapedText = answer.customText.replace(/'/g, "\\'");
105
+ lines.push(`→ Other: '${escapedText}'`);
106
+ }
107
+ else if (answer.selectedOption) {
108
+ // Selected option - find the option details
109
+ const option = question.options.find((opt) => opt.label === answer.selectedOption);
110
+ if (option) {
111
+ // Format with description if available
112
+ if (option.description) {
113
+ lines.push(`→ ${option.label} — ${option.description}`);
114
+ }
115
+ else {
116
+ lines.push(`→ ${option.label}`);
117
+ }
118
+ }
119
+ else {
120
+ // Option not found - shouldn't happen, but handle gracefully
121
+ lines.push(`→ ${answer.selectedOption}`);
122
+ }
123
+ }
124
+ else {
125
+ // No answer provided - this shouldn't happen
126
+ lines.push("→ No answer provided");
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+ }
@@ -0,0 +1,474 @@
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
+ }