replicant-mcp 1.0.4 → 1.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.
- package/README.md +59 -9
- package/dist/adapters/ui-automator.d.ts +25 -10
- package/dist/adapters/ui-automator.js +197 -13
- package/dist/server.d.ts +2 -1
- package/dist/server.js +20 -2
- package/dist/services/config.d.ts +16 -0
- package/dist/services/config.js +62 -0
- package/dist/services/grid.d.ts +28 -0
- package/dist/services/grid.js +98 -0
- package/dist/services/icon-patterns.d.ts +10 -0
- package/dist/services/icon-patterns.js +51 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +4 -0
- package/dist/services/visual-candidates.d.ts +24 -0
- package/dist/services/visual-candidates.js +78 -0
- package/dist/tools/emulator-device.d.ts +1 -1
- package/dist/tools/ui.d.ts +22 -1
- package/dist/tools/ui.js +195 -17
- package/dist/types/config.d.ts +34 -0
- package/dist/types/config.js +11 -0
- package/dist/types/icon-recognition.d.ts +50 -0
- package/dist/types/icon-recognition.js +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/package.json +4 -1
|
@@ -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
|
+
}
|
package/dist/services/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/services/index.js
CHANGED
|
@@ -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";
|
package/dist/tools/ui.d.ts
CHANGED
|
@@ -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
|
};
|