mcp-maestro-mobile-ai 1.3.1 → 1.6.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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * YAML Cache System
3
+ *
4
+ * Caches generated YAML files for faster execution of recurring test scenarios.
5
+ *
6
+ * Features:
7
+ * - Prompt-based hashing for unique identification
8
+ * - Hidden internal storage (~/.maestro-mcp/cache/)
9
+ * - Automatic cache lookup on test execution
10
+ * - Save successful tests for future reuse
11
+ * - Invalidation when prompt changes
12
+ */
13
+
14
+ import { createHash } from "crypto";
15
+ import fs from "fs/promises";
16
+ import { existsSync } from "fs";
17
+ import { join } from "path";
18
+ import os from "os";
19
+ import { logger } from "./logger.js";
20
+
21
+ // Cache directory (hidden from user, alongside other maestro-mcp files)
22
+ const CACHE_DIR = join(os.homedir(), ".maestro-mcp", "cache");
23
+ const CACHE_INDEX_FILE = join(CACHE_DIR, "index.json");
24
+
25
+ /**
26
+ * Generate a hash from the prompt for unique identification
27
+ * Normalizes the prompt to handle minor formatting differences
28
+ */
29
+ export function generatePromptHash(prompt, appId = null) {
30
+ // Normalize the prompt: lowercase, trim, collapse whitespace
31
+ const normalized = prompt
32
+ .toLowerCase()
33
+ .trim()
34
+ .replace(/\s+/g, " ")
35
+ .replace(/['"]/g, ""); // Remove quotes for consistency
36
+
37
+ // Include appId in hash if provided
38
+ const hashInput = appId ? `${appId}::${normalized}` : normalized;
39
+
40
+ return createHash("sha256").update(hashInput).digest("hex").substring(0, 16); // Use first 16 chars for readability
41
+ }
42
+
43
+ /**
44
+ * Initialize cache directory
45
+ */
46
+ async function ensureCacheDir() {
47
+ try {
48
+ await fs.mkdir(CACHE_DIR, { recursive: true });
49
+ } catch (error) {
50
+ logger.error("Failed to create cache directory", { error: error.message });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Load the cache index
56
+ */
57
+ async function loadCacheIndex() {
58
+ try {
59
+ await ensureCacheDir();
60
+
61
+ if (!existsSync(CACHE_INDEX_FILE)) {
62
+ return {};
63
+ }
64
+
65
+ const content = await fs.readFile(CACHE_INDEX_FILE, "utf8");
66
+ return JSON.parse(content);
67
+ } catch (error) {
68
+ logger.warn("Failed to load cache index, starting fresh", {
69
+ error: error.message,
70
+ });
71
+ return {};
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Save the cache index
77
+ */
78
+ async function saveCacheIndex(index) {
79
+ try {
80
+ await ensureCacheDir();
81
+ await fs.writeFile(
82
+ CACHE_INDEX_FILE,
83
+ JSON.stringify(index, null, 2),
84
+ "utf8"
85
+ );
86
+ } catch (error) {
87
+ logger.error("Failed to save cache index", { error: error.message });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Look up cached YAML for a prompt
93
+ *
94
+ * @param {string} prompt - The original test prompt
95
+ * @param {string} appId - App ID for context
96
+ * @returns {object|null} Cached entry or null if not found
97
+ */
98
+ export async function lookupCache(prompt, appId = null) {
99
+ try {
100
+ const hash = generatePromptHash(prompt, appId);
101
+ const index = await loadCacheIndex();
102
+
103
+ if (!index[hash]) {
104
+ return null;
105
+ }
106
+
107
+ const entry = index[hash];
108
+ const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
109
+
110
+ // Verify the YAML file exists
111
+ if (!existsSync(yamlPath)) {
112
+ // Stale index entry, clean up
113
+ delete index[hash];
114
+ await saveCacheIndex(index);
115
+ return null;
116
+ }
117
+
118
+ // Read the cached YAML
119
+ const yaml = await fs.readFile(yamlPath, "utf8");
120
+
121
+ logger.info(`Cache hit for prompt hash: ${hash}`);
122
+
123
+ return {
124
+ found: true,
125
+ hash,
126
+ yaml,
127
+ testName: entry.testName,
128
+ appId: entry.appId,
129
+ cachedAt: entry.cachedAt,
130
+ executionCount: entry.executionCount || 1,
131
+ lastUsed: entry.lastUsed,
132
+ originalPrompt: entry.prompt,
133
+ };
134
+ } catch (error) {
135
+ logger.error("Cache lookup error", { error: error.message });
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Save YAML to cache
142
+ *
143
+ * @param {string} prompt - The original test prompt
144
+ * @param {string} yaml - The generated YAML content
145
+ * @param {string} testName - Name of the test
146
+ * @param {string} appId - App ID
147
+ * @returns {object} Save result
148
+ */
149
+ export async function saveToCache(prompt, yaml, testName, appId = null) {
150
+ try {
151
+ await ensureCacheDir();
152
+
153
+ const hash = generatePromptHash(prompt, appId);
154
+ const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
155
+
156
+ // Save YAML file
157
+ await fs.writeFile(yamlPath, yaml, "utf8");
158
+
159
+ // Update index
160
+ const index = await loadCacheIndex();
161
+ index[hash] = {
162
+ prompt: prompt.substring(0, 200), // Store truncated prompt for reference
163
+ testName,
164
+ appId,
165
+ cachedAt: new Date().toISOString(),
166
+ lastUsed: new Date().toISOString(),
167
+ executionCount: 1,
168
+ };
169
+ await saveCacheIndex(index);
170
+
171
+ logger.info(`Cached YAML for prompt hash: ${hash}`);
172
+
173
+ return {
174
+ success: true,
175
+ hash,
176
+ path: yamlPath,
177
+ message: `YAML cached successfully. Future executions of this prompt will use the cached version.`,
178
+ };
179
+ } catch (error) {
180
+ logger.error("Cache save error", { error: error.message });
181
+ return {
182
+ success: false,
183
+ error: error.message,
184
+ };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Update cache usage statistics
190
+ */
191
+ export async function updateCacheUsage(hash) {
192
+ try {
193
+ const index = await loadCacheIndex();
194
+
195
+ if (index[hash]) {
196
+ index[hash].lastUsed = new Date().toISOString();
197
+ index[hash].executionCount = (index[hash].executionCount || 0) + 1;
198
+ await saveCacheIndex(index);
199
+ }
200
+ } catch (error) {
201
+ logger.warn("Failed to update cache usage", { error: error.message });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Delete a cached entry
207
+ */
208
+ export async function deleteFromCache(hash) {
209
+ try {
210
+ const index = await loadCacheIndex();
211
+
212
+ if (!index[hash]) {
213
+ return { success: false, error: "Cache entry not found" };
214
+ }
215
+
216
+ // Delete YAML file
217
+ const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
218
+ try {
219
+ await fs.unlink(yamlPath);
220
+ } catch {
221
+ // File may not exist
222
+ }
223
+
224
+ // Remove from index
225
+ delete index[hash];
226
+ await saveCacheIndex(index);
227
+
228
+ return { success: true, message: "Cache entry deleted" };
229
+ } catch (error) {
230
+ return { success: false, error: error.message };
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Clear all cached entries
236
+ */
237
+ export async function clearCache() {
238
+ try {
239
+ const index = await loadCacheIndex();
240
+ const count = Object.keys(index).length;
241
+
242
+ // Delete all YAML files
243
+ for (const hash of Object.keys(index)) {
244
+ const yamlPath = join(CACHE_DIR, `${hash}.yaml`);
245
+ try {
246
+ await fs.unlink(yamlPath);
247
+ } catch {
248
+ // Ignore
249
+ }
250
+ }
251
+
252
+ // Clear index
253
+ await saveCacheIndex({});
254
+
255
+ return {
256
+ success: true,
257
+ cleared: count,
258
+ message: `Cleared ${count} cached test(s)`,
259
+ };
260
+ } catch (error) {
261
+ return { success: false, error: error.message };
262
+ }
263
+ }
264
+
265
+ /**
266
+ * List all cached entries
267
+ */
268
+ export async function listCache() {
269
+ try {
270
+ const index = await loadCacheIndex();
271
+
272
+ const entries = Object.entries(index).map(([hash, entry]) => ({
273
+ hash,
274
+ testName: entry.testName,
275
+ appId: entry.appId,
276
+ prompt: entry.prompt,
277
+ cachedAt: entry.cachedAt,
278
+ lastUsed: entry.lastUsed,
279
+ executionCount: entry.executionCount || 1,
280
+ }));
281
+
282
+ // Sort by last used (most recent first)
283
+ entries.sort((a, b) => new Date(b.lastUsed) - new Date(a.lastUsed));
284
+
285
+ return {
286
+ success: true,
287
+ count: entries.length,
288
+ cacheDir: CACHE_DIR,
289
+ entries,
290
+ };
291
+ } catch (error) {
292
+ return { success: false, error: error.message };
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Get cache statistics
298
+ */
299
+ export async function getCacheStats() {
300
+ try {
301
+ const index = await loadCacheIndex();
302
+ const entries = Object.values(index);
303
+
304
+ const totalExecutions = entries.reduce(
305
+ (sum, e) => sum + (e.executionCount || 1),
306
+ 0
307
+ );
308
+ const oldestEntry =
309
+ entries.length > 0
310
+ ? entries.reduce((oldest, e) =>
311
+ new Date(e.cachedAt) < new Date(oldest.cachedAt) ? e : oldest
312
+ )
313
+ : null;
314
+ const mostUsed =
315
+ entries.length > 0
316
+ ? entries.reduce((most, e) =>
317
+ (e.executionCount || 1) > (most.executionCount || 1) ? e : most
318
+ )
319
+ : null;
320
+
321
+ return {
322
+ success: true,
323
+ stats: {
324
+ totalCached: entries.length,
325
+ totalExecutions,
326
+ cacheDir: CACHE_DIR,
327
+ oldestEntry: oldestEntry
328
+ ? {
329
+ testName: oldestEntry.testName,
330
+ cachedAt: oldestEntry.cachedAt,
331
+ }
332
+ : null,
333
+ mostUsed: mostUsed
334
+ ? {
335
+ testName: mostUsed.testName,
336
+ executionCount: mostUsed.executionCount,
337
+ }
338
+ : null,
339
+ },
340
+ };
341
+ } catch (error) {
342
+ return { success: false, error: error.message };
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Check if a prompt matches a cached entry exactly
348
+ * Returns comparison info for user decision
349
+ */
350
+ export function promptMatchesCache(newPrompt, cachedPrompt) {
351
+ const normalizedNew = newPrompt.toLowerCase().trim().replace(/\s+/g, " ");
352
+ const normalizedCached = cachedPrompt
353
+ .toLowerCase()
354
+ .trim()
355
+ .replace(/\s+/g, " ");
356
+
357
+ if (normalizedNew === normalizedCached) {
358
+ return { exact: true, similarity: 100 };
359
+ }
360
+
361
+ // Calculate simple similarity
362
+ const words1 = new Set(normalizedNew.split(" "));
363
+ const words2 = new Set(normalizedCached.split(" "));
364
+ const intersection = [...words1].filter((w) => words2.has(w));
365
+ const union = new Set([...words1, ...words2]);
366
+ const similarity = Math.round((intersection.length / union.size) * 100);
367
+
368
+ return { exact: false, similarity };
369
+ }
370
+
371
+ export default {
372
+ generatePromptHash,
373
+ lookupCache,
374
+ saveToCache,
375
+ updateCacheUsage,
376
+ deleteFromCache,
377
+ clearCache,
378
+ listCache,
379
+ getCacheStats,
380
+ promptMatchesCache,
381
+ };