replicant-mcp 1.0.4 → 1.1.1

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,98 @@
1
+ // src/services/grid.ts
2
+ import sharp from "sharp";
3
+ export const GRID_COLS = 4;
4
+ export const GRID_ROWS = 6;
5
+ export const TOTAL_CELLS = GRID_COLS * GRID_ROWS; // 24
6
+ /**
7
+ * Calculate the pixel bounds for a grid cell (1-24).
8
+ * Grid is 4 columns x 6 rows, numbered left-to-right, top-to-bottom.
9
+ */
10
+ export function calculateGridCellBounds(cell, screenWidth, screenHeight) {
11
+ if (cell < 1 || cell > TOTAL_CELLS) {
12
+ throw new Error(`Invalid cell number: ${cell}. Must be 1-${TOTAL_CELLS}`);
13
+ }
14
+ const cellWidth = screenWidth / GRID_COLS;
15
+ const cellHeight = screenHeight / GRID_ROWS;
16
+ // Convert 1-based cell to 0-based row/col
17
+ const index = cell - 1;
18
+ const col = index % GRID_COLS;
19
+ const row = Math.floor(index / GRID_COLS);
20
+ return {
21
+ x0: Math.round(col * cellWidth),
22
+ y0: Math.round(row * cellHeight),
23
+ x1: Math.round((col + 1) * cellWidth),
24
+ y1: Math.round((row + 1) * cellHeight),
25
+ };
26
+ }
27
+ /**
28
+ * Calculate tap coordinates for a position within a cell.
29
+ * Position: 1=TL, 2=TR, 3=Center, 4=BL, 5=BR
30
+ */
31
+ export function calculatePositionCoordinates(position, cellBounds) {
32
+ const width = cellBounds.x1 - cellBounds.x0;
33
+ const height = cellBounds.y1 - cellBounds.y0;
34
+ // Position multipliers (fraction of width/height from x0/y0)
35
+ const positionMap = {
36
+ 1: { xMult: 0.25, yMult: 0.25 }, // Top-left
37
+ 2: { xMult: 0.75, yMult: 0.25 }, // Top-right
38
+ 3: { xMult: 0.5, yMult: 0.5 }, // Center
39
+ 4: { xMult: 0.25, yMult: 0.75 }, // Bottom-left
40
+ 5: { xMult: 0.75, yMult: 0.75 }, // Bottom-right
41
+ };
42
+ const { xMult, yMult } = positionMap[position];
43
+ return {
44
+ x: Math.round(cellBounds.x0 + width * xMult),
45
+ y: Math.round(cellBounds.y0 + height * yMult),
46
+ };
47
+ }
48
+ export const POSITION_LABELS = [
49
+ "Top-left",
50
+ "Top-right",
51
+ "Center",
52
+ "Bottom-left",
53
+ "Bottom-right",
54
+ ];
55
+ const GRID_LINE_WIDTH = 3;
56
+ const LABEL_FONT_SIZE = 36;
57
+ /**
58
+ * Create a 4x6 numbered grid overlay on a screenshot.
59
+ * Returns base64 PNG.
60
+ */
61
+ export async function createGridOverlay(imagePath) {
62
+ const image = sharp(imagePath);
63
+ const metadata = await image.metadata();
64
+ const width = metadata.width;
65
+ const height = metadata.height;
66
+ const cellWidth = width / GRID_COLS;
67
+ const cellHeight = height / GRID_ROWS;
68
+ // Create SVG overlay with grid lines and numbers
69
+ const svgParts = [];
70
+ // Grid lines (vertical)
71
+ for (let col = 1; col < GRID_COLS; col++) {
72
+ const x = Math.round(col * cellWidth);
73
+ svgParts.push(`<line x1="${x}" y1="0" x2="${x}" y2="${height}" stroke="rgba(255,165,0,0.8)" stroke-width="${GRID_LINE_WIDTH}"/>`);
74
+ }
75
+ // Grid lines (horizontal)
76
+ for (let row = 1; row < GRID_ROWS; row++) {
77
+ const y = Math.round(row * cellHeight);
78
+ svgParts.push(`<line x1="0" y1="${y}" x2="${width}" y2="${y}" stroke="rgba(255,165,0,0.8)" stroke-width="${GRID_LINE_WIDTH}"/>`);
79
+ }
80
+ // Cell numbers
81
+ for (let cell = 1; cell <= TOTAL_CELLS; cell++) {
82
+ const index = cell - 1;
83
+ const col = index % GRID_COLS;
84
+ const row = Math.floor(index / GRID_COLS);
85
+ const centerX = Math.round((col + 0.5) * cellWidth);
86
+ const centerY = Math.round((row + 0.5) * cellHeight);
87
+ // Background circle for visibility
88
+ svgParts.push(`<circle cx="${centerX}" cy="${centerY}" r="25" fill="rgba(0,0,0,0.6)"/>`);
89
+ // Number text
90
+ svgParts.push(`<text x="${centerX}" y="${centerY}" font-family="Arial, sans-serif" font-size="${LABEL_FONT_SIZE}" font-weight="bold" fill="white" text-anchor="middle" dominant-baseline="central">${cell}</text>`);
91
+ }
92
+ const svgOverlay = `<svg width="${width}" height="${height}">${svgParts.join("")}</svg>`;
93
+ const buffer = await image
94
+ .composite([{ input: Buffer.from(svgOverlay), top: 0, left: 0 }])
95
+ .png()
96
+ .toBuffer();
97
+ return buffer.toString("base64");
98
+ }
@@ -0,0 +1,10 @@
1
+ export declare const ICON_PATTERNS: Record<string, string[]>;
2
+ /**
3
+ * Match a user query to icon resourceId patterns.
4
+ * Returns array of patterns to search for, or null if no match.
5
+ */
6
+ export declare function matchIconPattern(query: string): string[] | null;
7
+ /**
8
+ * Check if a resourceId matches any of the given patterns.
9
+ */
10
+ export declare function matchesResourceId(resourceId: string, patterns: string[]): boolean;
@@ -0,0 +1,51 @@
1
+ export const ICON_PATTERNS = {
2
+ // Navigation
3
+ overflow: ["overflow", "more", "options", "menu", "dots", "kabob", "meatball"],
4
+ back: ["back", "navigate_up", "arrow_back", "return", "nav_back"],
5
+ close: ["close", "dismiss", "cancel", "ic_close", "btn_close"],
6
+ home: ["home", "nav_home", "ic_home"],
7
+ // Actions
8
+ search: ["search", "find", "magnify", "ic_search"],
9
+ settings: ["settings", "gear", "config", "preferences", "ic_settings"],
10
+ share: ["share", "ic_share", "btn_share"],
11
+ edit: ["edit", "pencil", "ic_edit", "btn_edit"],
12
+ delete: ["delete", "trash", "remove", "ic_delete"],
13
+ add: ["add", "plus", "create", "ic_add", "fab"],
14
+ // Media
15
+ play: ["play", "ic_play", "btn_play"],
16
+ pause: ["pause", "ic_pause"],
17
+ refresh: ["refresh", "reload", "sync", "ic_refresh"],
18
+ // Social
19
+ favorite: ["favorite", "heart", "like", "star", "ic_favorite"],
20
+ bookmark: ["bookmark", "save", "ic_bookmark"],
21
+ notification: ["notification", "bell", "ic_notification", "ic_notify"],
22
+ // Misc
23
+ filter: ["filter", "ic_filter", "btn_filter"],
24
+ sort: ["sort", "ic_sort", "btn_sort"],
25
+ download: ["download", "ic_download"],
26
+ upload: ["upload", "ic_upload"],
27
+ profile: ["profile", "account", "avatar", "user", "ic_profile"],
28
+ hamburger: ["hamburger", "drawer", "nav_drawer", "ic_menu"],
29
+ };
30
+ /**
31
+ * Match a user query to icon resourceId patterns.
32
+ * Returns array of patterns to search for, or null if no match.
33
+ */
34
+ export function matchIconPattern(query) {
35
+ const lowerQuery = query.toLowerCase();
36
+ // Check each icon category
37
+ for (const [category, patterns] of Object.entries(ICON_PATTERNS)) {
38
+ // Match if query contains the category name or any pattern
39
+ if (lowerQuery.includes(category) || patterns.some((p) => lowerQuery.includes(p))) {
40
+ return patterns;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Check if a resourceId matches any of the given patterns.
47
+ */
48
+ export function matchesResourceId(resourceId, patterns) {
49
+ const lowerId = resourceId.toLowerCase();
50
+ return patterns.some((pattern) => lowerId.includes(pattern));
51
+ }
@@ -2,4 +2,8 @@ export { ProcessRunner, type RunOptions, type RunResult } from "./process-runner
2
2
  export { CacheManager, type CacheStats } from "./cache-manager.js";
3
3
  export { DeviceStateManager } from "./device-state.js";
4
4
  export { EnvironmentService, type Environment } from "./environment.js";
5
+ export { ConfigManager, loadConfig } from "./config.js";
5
6
  export * from "./ocr.js";
7
+ export * from "./icon-patterns.js";
8
+ export * from "./grid.js";
9
+ export * from "./visual-candidates.js";
@@ -2,4 +2,8 @@ export { ProcessRunner } from "./process-runner.js";
2
2
  export { CacheManager } from "./cache-manager.js";
3
3
  export { DeviceStateManager } from "./device-state.js";
4
4
  export { EnvironmentService } from "./environment.js";
5
+ export { ConfigManager, loadConfig } from "./config.js";
5
6
  export * from "./ocr.js";
7
+ export * from "./icon-patterns.js";
8
+ export * from "./grid.js";
9
+ export * from "./visual-candidates.js";
@@ -0,0 +1,24 @@
1
+ import { AccessibilityNode, Bounds } from "../parsers/ui-dump.js";
2
+ export declare const MIN_ICON_SIZE = 16;
3
+ export declare const MAX_ICON_SIZE = 200;
4
+ export declare const MIN_ASPECT_RATIO = 0.5;
5
+ export declare const MAX_ASPECT_RATIO = 2;
6
+ export declare const MAX_CANDIDATES = 6;
7
+ /**
8
+ * Check if dimensions are within icon size constraints.
9
+ */
10
+ export declare function isIconSized(width: number, height: number): boolean;
11
+ /**
12
+ * Filter accessibility nodes to find unlabeled clickable elements that are icon-sized.
13
+ * Returns max 6 candidates sorted by position (top-to-bottom, left-to-right).
14
+ */
15
+ export declare function filterIconCandidates(nodes: AccessibilityNode[]): AccessibilityNode[];
16
+ /**
17
+ * Format candidate bounds as string "[x0,y0][x1,y1]"
18
+ */
19
+ export declare function formatBounds(node: AccessibilityNode): string;
20
+ /**
21
+ * Crop a region from an image and return as base64 JPEG.
22
+ * Scales down to max 128x128 if larger.
23
+ */
24
+ export declare function cropCandidateImage(imagePath: string, bounds: Bounds): Promise<string>;
@@ -0,0 +1,78 @@
1
+ import sharp from "sharp";
2
+ export const MIN_ICON_SIZE = 16;
3
+ export const MAX_ICON_SIZE = 200;
4
+ export const MIN_ASPECT_RATIO = 0.5;
5
+ export const MAX_ASPECT_RATIO = 2.0;
6
+ export const MAX_CANDIDATES = 6;
7
+ /**
8
+ * Check if dimensions are within icon size constraints.
9
+ */
10
+ export function isIconSized(width, height) {
11
+ if (width < MIN_ICON_SIZE || width > MAX_ICON_SIZE)
12
+ return false;
13
+ if (height < MIN_ICON_SIZE || height > MAX_ICON_SIZE)
14
+ return false;
15
+ const aspectRatio = width / height;
16
+ if (aspectRatio < MIN_ASPECT_RATIO || aspectRatio > MAX_ASPECT_RATIO)
17
+ return false;
18
+ return true;
19
+ }
20
+ /**
21
+ * Filter accessibility nodes to find unlabeled clickable elements that are icon-sized.
22
+ * Returns max 6 candidates sorted by position (top-to-bottom, left-to-right).
23
+ */
24
+ export function filterIconCandidates(nodes) {
25
+ const candidates = nodes.filter((node) => {
26
+ // Must be clickable
27
+ if (!node.clickable)
28
+ return false;
29
+ // Must not have text or contentDesc (we're looking for unlabeled icons)
30
+ if (node.text || node.contentDesc)
31
+ return false;
32
+ // Must be icon-sized
33
+ const width = node.bounds.right - node.bounds.left;
34
+ const height = node.bounds.bottom - node.bounds.top;
35
+ if (!isIconSized(width, height))
36
+ return false;
37
+ return true;
38
+ });
39
+ // Sort by Y first (top-to-bottom), then X (left-to-right)
40
+ candidates.sort((a, b) => {
41
+ if (a.centerY !== b.centerY)
42
+ return a.centerY - b.centerY;
43
+ return a.centerX - b.centerX;
44
+ });
45
+ // Limit to max candidates
46
+ return candidates.slice(0, MAX_CANDIDATES);
47
+ }
48
+ /**
49
+ * Format candidate bounds as string "[x0,y0][x1,y1]"
50
+ */
51
+ export function formatBounds(node) {
52
+ return `[${node.bounds.left},${node.bounds.top}][${node.bounds.right},${node.bounds.bottom}]`;
53
+ }
54
+ const MAX_CROP_SIZE = 128;
55
+ const JPEG_QUALITY = 70;
56
+ /**
57
+ * Crop a region from an image and return as base64 JPEG.
58
+ * Scales down to max 128x128 if larger.
59
+ */
60
+ export async function cropCandidateImage(imagePath, bounds) {
61
+ const width = bounds.right - bounds.left;
62
+ const height = bounds.bottom - bounds.top;
63
+ let pipeline = sharp(imagePath).extract({
64
+ left: bounds.left,
65
+ top: bounds.top,
66
+ width,
67
+ height,
68
+ });
69
+ // Scale down if larger than max size
70
+ if (width > MAX_CROP_SIZE || height > MAX_CROP_SIZE) {
71
+ pipeline = pipeline.resize(MAX_CROP_SIZE, MAX_CROP_SIZE, {
72
+ fit: "inside",
73
+ withoutEnlargement: true,
74
+ });
75
+ }
76
+ const buffer = await pipeline.jpeg({ quality: JPEG_QUALITY }).toBuffer();
77
+ return buffer.toString("base64");
78
+ }
@@ -3,8 +3,8 @@ import { ServerContext } from "../server.js";
3
3
  export declare const emulatorDeviceInputSchema: z.ZodObject<{
4
4
  operation: z.ZodEnum<{
5
5
  kill: "kill";
6
- list: "list";
7
6
  create: "create";
7
+ list: "list";
8
8
  start: "start";
9
9
  wipe: "wipe";
10
10
  "snapshot-save": "snapshot-save";
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ServerContext } from "../server.js";
3
+ import { UiConfig } from "../types/index.js";
3
4
  export declare const uiInputSchema: z.ZodObject<{
4
5
  operation: z.ZodEnum<{
5
6
  find: "find";
@@ -8,12 +9,14 @@ export declare const uiInputSchema: z.ZodObject<{
8
9
  input: "input";
9
10
  screenshot: "screenshot";
10
11
  "accessibility-check": "accessibility-check";
12
+ "visual-snapshot": "visual-snapshot";
11
13
  }>;
12
14
  selector: z.ZodOptional<z.ZodObject<{
13
15
  resourceId: z.ZodOptional<z.ZodString>;
14
16
  text: z.ZodOptional<z.ZodString>;
15
17
  textContains: z.ZodOptional<z.ZodString>;
16
18
  className: z.ZodOptional<z.ZodString>;
19
+ nearestTo: z.ZodOptional<z.ZodString>;
17
20
  }, z.core.$strip>>;
18
21
  x: z.ZodOptional<z.ZodNumber>;
19
22
  y: z.ZodOptional<z.ZodNumber>;
@@ -22,9 +25,11 @@ export declare const uiInputSchema: z.ZodObject<{
22
25
  localPath: z.ZodOptional<z.ZodString>;
23
26
  inline: z.ZodOptional<z.ZodBoolean>;
24
27
  debug: z.ZodOptional<z.ZodBoolean>;
28
+ gridCell: z.ZodOptional<z.ZodNumber>;
29
+ gridPosition: z.ZodOptional<z.ZodNumber>;
25
30
  }, z.core.$strip>;
26
31
  export type UiInput = z.infer<typeof uiInputSchema>;
27
- export declare function handleUiTool(input: UiInput, context: ServerContext): Promise<Record<string, unknown>>;
32
+ export declare function handleUiTool(input: UiInput, context: ServerContext, uiConfig?: UiConfig): Promise<Record<string, unknown>>;
28
33
  export declare const uiToolDefinition: {
29
34
  name: string;
30
35
  description: string;
@@ -50,6 +55,10 @@ export declare const uiToolDefinition: {
50
55
  className: {
51
56
  type: string;
52
57
  };
58
+ nearestTo: {
59
+ type: string;
60
+ description: string;
61
+ };
53
62
  };
54
63
  description: string;
55
64
  };
@@ -81,6 +90,18 @@ export declare const uiToolDefinition: {
81
90
  type: string;
82
91
  description: string;
83
92
  };
93
+ gridCell: {
94
+ type: string;
95
+ minimum: number;
96
+ maximum: number;
97
+ description: string;
98
+ };
99
+ gridPosition: {
100
+ type: string;
101
+ minimum: number;
102
+ maximum: number;
103
+ description: string;
104
+ };
84
105
  };
85
106
  required: string[];
86
107
  };