@mobileai/react-native 0.9.5 → 0.9.10
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 +73 -131
- package/ios/MobileAIPilotIntents.swift +51 -0
- package/lib/module/__cli_tmp__.js +21 -0
- package/lib/module/__cli_tmp__.js.map +1 -0
- package/lib/module/components/AIAgent.js.map +1 -1
- package/lib/module/components/AgentChatBar.js +2 -3
- package/lib/module/components/AgentChatBar.js.map +1 -1
- package/lib/module/components/HighlightOverlay.js +1 -0
- package/lib/module/components/HighlightOverlay.js.map +1 -1
- package/lib/module/core/ActionRegistry.js +102 -0
- package/lib/module/core/ActionRegistry.js.map +1 -0
- package/lib/module/core/AgentRuntime.js +25 -22
- package/lib/module/core/AgentRuntime.js.map +1 -1
- package/lib/module/core/MCPBridge.js +77 -14
- package/lib/module/core/MCPBridge.js.map +1 -1
- package/lib/module/hooks/useAction.js +47 -11
- package/lib/module/hooks/useAction.js.map +1 -1
- package/lib/module/index.js +3 -10
- package/lib/module/index.js.map +1 -1
- package/lib/module/plugin/withAppIntents.js +71 -0
- package/lib/module/plugin/withAppIntents.js.map +1 -0
- package/lib/module/services/AudioInputService.js +2 -2
- package/lib/module/services/AudioInputService.js.map +1 -1
- package/lib/module/services/AudioOutputService.js +3 -2
- package/lib/module/services/AudioOutputService.js.map +1 -1
- package/lib/module/tools/guideTool.js +11 -2
- package/lib/module/tools/guideTool.js.map +1 -1
- package/lib/typescript/src/__cli_tmp__.d.ts +2 -0
- package/lib/typescript/src/__cli_tmp__.d.ts.map +1 -0
- package/lib/typescript/src/components/AIAgent.d.ts +0 -3
- package/lib/typescript/src/components/AIAgent.d.ts.map +1 -1
- package/lib/typescript/src/components/AgentChatBar.d.ts.map +1 -1
- package/lib/typescript/src/core/ActionRegistry.d.ts +43 -0
- package/lib/typescript/src/core/ActionRegistry.d.ts.map +1 -0
- package/lib/typescript/src/core/AgentRuntime.d.ts +2 -4
- package/lib/typescript/src/core/AgentRuntime.d.ts.map +1 -1
- package/lib/typescript/src/core/MCPBridge.d.ts.map +1 -1
- package/lib/typescript/src/core/types.d.ts +20 -2
- package/lib/typescript/src/core/types.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useAction.d.ts +34 -2
- package/lib/typescript/src/hooks/useAction.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/plugin/withAppIntents.d.ts +10 -0
- package/lib/typescript/src/plugin/withAppIntents.d.ts.map +1 -0
- package/lib/typescript/src/services/AudioOutputService.d.ts.map +1 -1
- package/lib/typescript/src/tools/guideTool.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/__cli_tmp__.tsx +9 -0
- package/src/cli/generate-intents.ts +140 -0
- package/src/cli/generate-swift.ts +116 -0
- package/src/components/AIAgent.tsx +1 -4
- package/src/components/AgentChatBar.tsx +2 -3
- package/src/components/HighlightOverlay.tsx +1 -1
- package/src/core/ActionRegistry.ts +105 -0
- package/src/core/AgentRuntime.ts +23 -25
- package/src/core/MCPBridge.ts +68 -15
- package/src/core/types.ts +23 -2
- package/src/hooks/useAction.ts +51 -10
- package/src/index.ts +7 -9
- package/src/plugin/withAppIntents.ts +82 -0
- package/src/services/AudioInputService.ts +2 -2
- package/src/services/AudioOutputService.ts +3 -2
- package/src/tools/guideTool.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mobileai/react-native",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.10",
|
|
4
4
|
"description": "Build autonomous AI agents for React Native and Expo apps. Provides AI-native UI traversal, tool calling, and structured reasoning.",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"source": "./src/index.ts",
|
|
@@ -169,6 +169,9 @@
|
|
|
169
169
|
},
|
|
170
170
|
"jest": {
|
|
171
171
|
"preset": "react-native",
|
|
172
|
+
"setupFilesAfterEnv": [
|
|
173
|
+
"<rootDir>/src/__tests__/setup.ts"
|
|
174
|
+
],
|
|
172
175
|
"testMatch": [
|
|
173
176
|
"**/?(*.)+(spec|test).[jt]s?(x)"
|
|
174
177
|
],
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useAction } from './hooks/useAction';
|
|
2
|
+
|
|
3
|
+
export function CheckoutScreen() {
|
|
4
|
+
useAction('checkout_cart', 'Process checkout', {
|
|
5
|
+
amount: { type: 'number', description: 'Total amount' },
|
|
6
|
+
currency: { type: 'string', description: 'Currency code', enum: ['USD', 'EUR'] },
|
|
7
|
+
isExpress: { type: 'boolean', description: 'Express checkout' }
|
|
8
|
+
}, async () => {});
|
|
9
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { parse } from '@babel/parser';
|
|
6
|
+
import traverse from '@babel/traverse';
|
|
7
|
+
// @ts-ignore - no types installed
|
|
8
|
+
import glob from 'glob';
|
|
9
|
+
|
|
10
|
+
// Define the schema format for our extracted intents
|
|
11
|
+
export interface ExtractedIntent {
|
|
12
|
+
name: string;
|
|
13
|
+
description: string;
|
|
14
|
+
parameters: Record<string, any>;
|
|
15
|
+
sourceFile: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates and statically extracts `useAction` and `registerAction` definitions
|
|
20
|
+
* from a target directory by parsing the AST of all TS/JS files.
|
|
21
|
+
*/
|
|
22
|
+
export function extractIntentsFromAST(sourceDir: string): ExtractedIntent[] {
|
|
23
|
+
const files = glob.sync(`${sourceDir}/**/*.{ts,tsx,js,jsx}`, {
|
|
24
|
+
ignore: ['**/node_modules/**', '**/*.d.ts', '**/__tests__/**']
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const intents: ExtractedIntent[] = [];
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const code = fs.readFileSync(file, 'utf-8');
|
|
31
|
+
|
|
32
|
+
// Quick heuristic: ignore files that don't even talk about useAction
|
|
33
|
+
if (!code.includes('useAction') && !code.includes('registerAction')) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const ast = parse(code, {
|
|
39
|
+
sourceType: 'module',
|
|
40
|
+
plugins: ['jsx', 'typescript'],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
traverse(ast, {
|
|
44
|
+
CallExpression(pathNode: any) {
|
|
45
|
+
const callee = pathNode.node.callee;
|
|
46
|
+
if (
|
|
47
|
+
callee.type === 'Identifier' &&
|
|
48
|
+
(callee.name === 'useAction' || callee.name === 'registerAction')
|
|
49
|
+
) {
|
|
50
|
+
const args = pathNode.node.arguments;
|
|
51
|
+
if (args.length >= 2) {
|
|
52
|
+
const nameArg = args[0];
|
|
53
|
+
const descArg = args[1];
|
|
54
|
+
const schemaArg = args[2];
|
|
55
|
+
|
|
56
|
+
// We only process if name and desc are static string literals
|
|
57
|
+
if (nameArg.type === 'StringLiteral' && descArg.type === 'StringLiteral') {
|
|
58
|
+
const name = nameArg.value;
|
|
59
|
+
const description = descArg.value;
|
|
60
|
+
let parameters: Record<string, any> = {};
|
|
61
|
+
|
|
62
|
+
// Parse schema object if provided
|
|
63
|
+
if (schemaArg && schemaArg.type === 'ObjectExpression') {
|
|
64
|
+
parameters = parseObjectExpression(schemaArg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
intents.push({
|
|
68
|
+
name,
|
|
69
|
+
description,
|
|
70
|
+
parameters,
|
|
71
|
+
sourceFile: path.relative(process.cwd(), file)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
} catch (error: any) {
|
|
79
|
+
console.warn(`[WARN] Skipping file ${file} due to parse error: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return intents;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Naively converts an AST ObjectExpression back to a JS Runtime object.
|
|
88
|
+
* Assumes the schema is statically defined (no variables/computed keys).
|
|
89
|
+
*/
|
|
90
|
+
function parseObjectExpression(node: any): any {
|
|
91
|
+
const result: any = {};
|
|
92
|
+
for (const prop of node.properties) {
|
|
93
|
+
if (prop.type === 'ObjectProperty' && prop.key.type === 'Identifier') {
|
|
94
|
+
const key = prop.key.name;
|
|
95
|
+
|
|
96
|
+
if (prop.value.type === 'StringLiteral') {
|
|
97
|
+
result[key] = prop.value.value;
|
|
98
|
+
} else if (prop.value.type === 'NumericLiteral') {
|
|
99
|
+
result[key] = prop.value.value;
|
|
100
|
+
} else if (prop.value.type === 'BooleanLiteral') {
|
|
101
|
+
result[key] = prop.value.value;
|
|
102
|
+
} else if (prop.value.type === 'ObjectExpression') {
|
|
103
|
+
result[key] = parseObjectExpression(prop.value);
|
|
104
|
+
} else if (prop.value.type === 'ArrayExpression') {
|
|
105
|
+
result[key] = prop.value.elements.map((el: any) => {
|
|
106
|
+
if (el.type === 'StringLiteral') return el.value;
|
|
107
|
+
if (el.type === 'NumericLiteral') return el.value;
|
|
108
|
+
return null;
|
|
109
|
+
}).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function main() {
|
|
117
|
+
const sourceDir = process.argv[2] || 'src';
|
|
118
|
+
const outPath = process.argv[3] || 'intent-manifest.json';
|
|
119
|
+
|
|
120
|
+
const absoluteSource = path.resolve(process.cwd(), sourceDir);
|
|
121
|
+
const absoluteOut = path.resolve(process.cwd(), outPath);
|
|
122
|
+
|
|
123
|
+
console.log(`Scanning ${absoluteSource} for AI Actions...`);
|
|
124
|
+
|
|
125
|
+
const intents = extractIntentsFromAST(absoluteSource);
|
|
126
|
+
|
|
127
|
+
console.log(`Found ${intents.length} actions.`);
|
|
128
|
+
intents.forEach(i => console.log(` - ${i.name} (from ${i.sourceFile})`));
|
|
129
|
+
|
|
130
|
+
fs.writeFileSync(absoluteOut, JSON.stringify(intents, null, 2));
|
|
131
|
+
console.log(`\n✅ Wrote intent manifest to ${outPath}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Run if called directly
|
|
135
|
+
if (require.main === module) {
|
|
136
|
+
main().catch(err => {
|
|
137
|
+
console.error(err);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
|
|
6
|
+
export function generateSwiftCode(manifestPath: string, appScheme: string): string {
|
|
7
|
+
const manifests = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
8
|
+
|
|
9
|
+
let swiftCode = `import AppIntents
|
|
10
|
+
import UIKit
|
|
11
|
+
|
|
12
|
+
// Auto-generated by react-native-ai-agent
|
|
13
|
+
// Do not edit manually.
|
|
14
|
+
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
for (const intent of manifests) {
|
|
18
|
+
// Convert snake_case to CamelCaseIntent
|
|
19
|
+
const structName = intent.name
|
|
20
|
+
.split('_')
|
|
21
|
+
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
22
|
+
.join('') + 'Intent';
|
|
23
|
+
|
|
24
|
+
swiftCode += `@available(iOS 16.0, *)\n`;
|
|
25
|
+
swiftCode += `struct ${structName}: AppIntent {\n`;
|
|
26
|
+
swiftCode += ` static var title: LocalizedStringResource = "${intent.name}"\n`;
|
|
27
|
+
swiftCode += ` static var description = IntentDescription("${intent.description.replace(/"/g, '\\"')}")\n`;
|
|
28
|
+
swiftCode += ` static var openAppWhenRun: Bool = true\n\n`;
|
|
29
|
+
|
|
30
|
+
// Parameters
|
|
31
|
+
const params = intent.parameters || {};
|
|
32
|
+
for (const [key, param] of Object.entries<any>(params)) {
|
|
33
|
+
let swiftType = 'String';
|
|
34
|
+
if (param.type === 'number') swiftType = 'Double';
|
|
35
|
+
if (param.type === 'boolean') swiftType = 'Bool';
|
|
36
|
+
|
|
37
|
+
swiftCode += ` @Parameter(title: "${param.description || key}")\n`;
|
|
38
|
+
swiftCode += ` var ${key}: ${swiftType}?\n\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Perform function
|
|
42
|
+
swiftCode += ` @MainActor\n`;
|
|
43
|
+
swiftCode += ` func perform() async throws -> some IntentResult {\n`;
|
|
44
|
+
swiftCode += ` var components = URLComponents()\n`;
|
|
45
|
+
swiftCode += ` components.scheme = "${appScheme}"\n`;
|
|
46
|
+
swiftCode += ` components.host = "ai-action"\n`;
|
|
47
|
+
swiftCode += ` components.path = "/${intent.name}"\n`;
|
|
48
|
+
|
|
49
|
+
if (Object.keys(params).length > 0) {
|
|
50
|
+
swiftCode += ` var queryItems: [URLQueryItem] = []\n`;
|
|
51
|
+
for (const [key, _param] of Object.entries<any>(params)) {
|
|
52
|
+
swiftCode += ` if let val = ${key} {\n`;
|
|
53
|
+
swiftCode += ` queryItems.append(URLQueryItem(name: "${key}", value: String(describing: val)))\n`;
|
|
54
|
+
swiftCode += ` }\n`;
|
|
55
|
+
}
|
|
56
|
+
swiftCode += ` components.queryItems = queryItems\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
swiftCode += ` if let url = components.url {\n`;
|
|
60
|
+
swiftCode += ` await UIApplication.shared.open(url)\n`;
|
|
61
|
+
swiftCode += ` }\n`;
|
|
62
|
+
swiftCode += ` return .result()\n`;
|
|
63
|
+
swiftCode += ` }\n`;
|
|
64
|
+
swiftCode += `}\n\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// AppShortcuts provider (exposes intents to Siri automatically)
|
|
68
|
+
if (manifests.length > 0) {
|
|
69
|
+
swiftCode += `@available(iOS 16.0, *)\n`;
|
|
70
|
+
swiftCode += `struct MobileAIAppShortcuts: AppShortcutsProvider {\n`;
|
|
71
|
+
swiftCode += ` static var appShortcuts: [AppShortcut] {\n`;
|
|
72
|
+
for (const intent of manifests) {
|
|
73
|
+
const structName = intent.name.split('_').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)).join('') + 'Intent';
|
|
74
|
+
// Generate a basic short phrase mapping
|
|
75
|
+
swiftCode += ` AppShortcut(intent: ${structName}(), phrases: ["\\\\(.applicationName) ${intent.name.replace(/_/g, ' ')}"])\n`;
|
|
76
|
+
}
|
|
77
|
+
swiftCode += ` }\n`;
|
|
78
|
+
swiftCode += `}\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return swiftCode;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
const manifestPath = process.argv[2] || 'intent-manifest.json';
|
|
86
|
+
const appScheme = process.argv[3] || 'mobileai';
|
|
87
|
+
const outPath = process.argv[4] || 'ios/MobileAIPilotIntents.swift';
|
|
88
|
+
|
|
89
|
+
const absoluteManifest = path.resolve(process.cwd(), manifestPath);
|
|
90
|
+
const absoluteOut = path.resolve(process.cwd(), outPath);
|
|
91
|
+
|
|
92
|
+
if (!fs.existsSync(absoluteManifest)) {
|
|
93
|
+
console.error(`❌ Manifest file not found at ${absoluteManifest}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log(`Generating Swift code for schema ${appScheme}...`);
|
|
98
|
+
const swiftCode = generateSwiftCode(absoluteManifest, appScheme);
|
|
99
|
+
|
|
100
|
+
// Ensure dir exists
|
|
101
|
+
const targetDir = path.dirname(absoluteOut);
|
|
102
|
+
if (!fs.existsSync(targetDir)) {
|
|
103
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fs.writeFileSync(absoluteOut, swiftCode);
|
|
107
|
+
console.log(`✅ Wrote Swift AppIntents to ${outPath}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Run if called directly
|
|
111
|
+
if (require.main === module) {
|
|
112
|
+
main().catch(err => {
|
|
113
|
+
console.error(err);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -171,20 +171,17 @@ interface AIAgentProps {
|
|
|
171
171
|
*/
|
|
172
172
|
useScreenMap?: boolean;
|
|
173
173
|
|
|
174
|
-
// ── Analytics (opt-in) ──
|
|
174
|
+
// ── Analytics (opt-in) ──
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* @internal Requires api.mobileai.dev — not yet available.
|
|
178
177
|
* Publishable analytics key (mobileai_pub_xxx).
|
|
179
178
|
*/
|
|
180
179
|
analyticsKey?: string;
|
|
181
180
|
/**
|
|
182
|
-
* @internal Requires api.mobileai.dev — not yet available.
|
|
183
181
|
* Proxy URL for enterprise customers — routes events through your backend.
|
|
184
182
|
*/
|
|
185
183
|
analyticsProxyUrl?: string;
|
|
186
184
|
/**
|
|
187
|
-
* @internal Requires api.mobileai.dev — not yet available.
|
|
188
185
|
* Custom headers for analyticsProxyUrl (e.g., auth tokens).
|
|
189
186
|
*/
|
|
190
187
|
analyticsProxyHeaders?: Record<string, string>;
|
|
@@ -150,9 +150,8 @@ function AudioControlButton({
|
|
|
150
150
|
*/
|
|
151
151
|
let SpeechModule: any = null;
|
|
152
152
|
try {
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
SpeechModule = require(speechModuleName);
|
|
153
|
+
// Static require — Metro needs a literal string for bundling.
|
|
154
|
+
SpeechModule = require('expo-speech-recognition');
|
|
156
155
|
} catch {
|
|
157
156
|
// Not installed — dictation button won't appear
|
|
158
157
|
}
|
|
@@ -53,7 +53,7 @@ export function HighlightOverlay() {
|
|
|
53
53
|
return (
|
|
54
54
|
<View style={StyleSheet.absoluteFill} pointerEvents="box-none">
|
|
55
55
|
{/* Invisible pressable to dismiss on tap anywhere */}
|
|
56
|
-
<Pressable style={StyleSheet.absoluteFill} onPress={() => setHighlight(null)} />
|
|
56
|
+
<Pressable testID="highlight-close-zone" style={StyleSheet.absoluteFill} onPress={() => setHighlight(null)} />
|
|
57
57
|
|
|
58
58
|
{/* The Animated Ring */}
|
|
59
59
|
<Animated.View
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ActionDefinition, ActionParameterDef } from './types';
|
|
2
|
+
|
|
3
|
+
export interface MCPToolDeclaration {
|
|
4
|
+
name: string;
|
|
5
|
+
description: string;
|
|
6
|
+
inputSchema: {
|
|
7
|
+
type: string;
|
|
8
|
+
properties: Record<string, any>;
|
|
9
|
+
required: string[];
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A central registry for all actions registered via `useAction`.
|
|
15
|
+
* This acts as the single source of truth for:
|
|
16
|
+
* 1. The in-app AI Agent (AgentRuntime)
|
|
17
|
+
* 2. The MCP Server (external agents)
|
|
18
|
+
* 3. iOS App Intents (Siri)
|
|
19
|
+
* 4. Android AppFunctions (Gemini)
|
|
20
|
+
*/
|
|
21
|
+
export class ActionRegistry {
|
|
22
|
+
private actions = new Map<string, ActionDefinition>();
|
|
23
|
+
private listeners = new Set<() => void>();
|
|
24
|
+
|
|
25
|
+
/** Register a new action definition */
|
|
26
|
+
register(action: ActionDefinition): void {
|
|
27
|
+
this.actions.set(action.name, action);
|
|
28
|
+
this.notify();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Unregister an action by name */
|
|
32
|
+
unregister(name: string): void {
|
|
33
|
+
this.actions.delete(name);
|
|
34
|
+
this.notify();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Get a specific action by name */
|
|
38
|
+
get(name: string): ActionDefinition | undefined {
|
|
39
|
+
return this.actions.get(name);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get all registered actions */
|
|
43
|
+
getAll(): ActionDefinition[] {
|
|
44
|
+
return Array.from(this.actions.values());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clear all registered actions (useful for testing) */
|
|
48
|
+
clear(): void {
|
|
49
|
+
this.actions.clear();
|
|
50
|
+
this.notify();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Subscribe to changes (e.g. when a new screen mounts and registers actions).
|
|
55
|
+
* Useful for the MCP server to re-announce tools.
|
|
56
|
+
*/
|
|
57
|
+
onChange(listener: () => void): () => void {
|
|
58
|
+
this.listeners.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
this.listeners.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Serialize all actions as strictly-typed MCP tool declarations */
|
|
65
|
+
toMCPTools(): MCPToolDeclaration[] {
|
|
66
|
+
return this.getAll().map((a) => ({
|
|
67
|
+
name: a.name,
|
|
68
|
+
description: a.description,
|
|
69
|
+
inputSchema: this.buildInputSchema(a.parameters),
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private buildInputSchema(params: Record<string, string | ActionParameterDef>) {
|
|
74
|
+
const properties: Record<string, any> = {};
|
|
75
|
+
const required: string[] = [];
|
|
76
|
+
|
|
77
|
+
for (const [key, val] of Object.entries(params)) {
|
|
78
|
+
if (typeof val === 'string') {
|
|
79
|
+
// Backward compatibility: passing a string means it's a required string param.
|
|
80
|
+
properties[key] = { type: 'string', description: val };
|
|
81
|
+
required.push(key);
|
|
82
|
+
} else {
|
|
83
|
+
// New strict parameter definition
|
|
84
|
+
properties[key] = { type: val.type, description: val.description };
|
|
85
|
+
if (val.enum) {
|
|
86
|
+
properties[key].enum = val.enum;
|
|
87
|
+
}
|
|
88
|
+
if (val.required !== false) {
|
|
89
|
+
required.push(key);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { type: 'object', properties, required };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private notify() {
|
|
98
|
+
this.listeners.forEach((l) => l());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Export a singleton instance.
|
|
103
|
+
// This allows background channels (like App Intents bridging) to access actions
|
|
104
|
+
// even if the React tree hasn't accessed the AIAgent context yet.
|
|
105
|
+
export const actionRegistry = new ActionRegistry();
|
package/src/core/AgentRuntime.ts
CHANGED
|
@@ -35,9 +35,9 @@ import type {
|
|
|
35
35
|
AgentStep,
|
|
36
36
|
ExecutionResult,
|
|
37
37
|
ToolDefinition,
|
|
38
|
-
ActionDefinition,
|
|
39
38
|
TokenUsage,
|
|
40
39
|
} from './types';
|
|
40
|
+
import { actionRegistry } from './ActionRegistry';
|
|
41
41
|
|
|
42
42
|
const DEFAULT_MAX_STEPS = 25;
|
|
43
43
|
|
|
@@ -49,7 +49,6 @@ export class AgentRuntime {
|
|
|
49
49
|
private rootRef: any;
|
|
50
50
|
private navRef: any;
|
|
51
51
|
private tools: Map<string, ToolDefinition> = new Map();
|
|
52
|
-
private actions: Map<string, ActionDefinition> = new Map();
|
|
53
52
|
private history: AgentStep[] = [];
|
|
54
53
|
private isRunning = false;
|
|
55
54
|
private isCancelRequested = false;
|
|
@@ -67,6 +66,10 @@ export class AgentRuntime {
|
|
|
67
66
|
private graceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
67
|
private originalReportErrorsAsExceptions: boolean | undefined = undefined;
|
|
69
68
|
|
|
69
|
+
public getConfig(): AgentConfig {
|
|
70
|
+
return this.config;
|
|
71
|
+
}
|
|
72
|
+
|
|
70
73
|
constructor(
|
|
71
74
|
provider: AIProvider,
|
|
72
75
|
config: AgentConfig,
|
|
@@ -340,17 +343,6 @@ export class AgentRuntime {
|
|
|
340
343
|
}
|
|
341
344
|
}
|
|
342
345
|
|
|
343
|
-
// ─── Action Registration (useAction hook) ──────────────────
|
|
344
|
-
|
|
345
|
-
registerAction(action: ActionDefinition): void {
|
|
346
|
-
this.actions.set(action.name, action);
|
|
347
|
-
logger.info('AgentRuntime', `Registered action: ${action.name}`);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
unregisterAction(name: string): void {
|
|
351
|
-
this.actions.delete(name);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
346
|
// ─── Navigation Helpers ────────────────────────────────────
|
|
355
347
|
|
|
356
348
|
/**
|
|
@@ -518,11 +510,8 @@ export class AgentRuntime {
|
|
|
518
510
|
*/
|
|
519
511
|
private async captureScreenshot(): Promise<string | undefined> {
|
|
520
512
|
try {
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
// and crashes with "unknown module" when the package isn't installed.
|
|
524
|
-
const moduleName = ['react-native', 'view-shot'].join('-');
|
|
525
|
-
const viewShot = require(moduleName);
|
|
513
|
+
// Static require — Metro needs a literal string; the try/catch handles MODULE_NOT_FOUND.
|
|
514
|
+
const viewShot = require('react-native-view-shot');
|
|
526
515
|
const captureRef = viewShot.captureRef || viewShot.default?.captureRef;
|
|
527
516
|
if (!captureRef || !this.rootRef) return undefined;
|
|
528
517
|
|
|
@@ -645,16 +634,25 @@ ${screen.elementsText}
|
|
|
645
634
|
const allTools = [...this.tools.values()];
|
|
646
635
|
|
|
647
636
|
// Add registered actions as tools
|
|
648
|
-
for (const action of
|
|
637
|
+
for (const action of actionRegistry.getAll()) {
|
|
638
|
+
const toolParams: Record<string, any> = {};
|
|
639
|
+
for (const [key, val] of Object.entries(action.parameters)) {
|
|
640
|
+
if (typeof val === 'string') {
|
|
641
|
+
toolParams[key] = { type: 'string', description: val, required: true };
|
|
642
|
+
} else {
|
|
643
|
+
toolParams[key] = {
|
|
644
|
+
type: val.type,
|
|
645
|
+
description: val.description,
|
|
646
|
+
required: val.required !== false,
|
|
647
|
+
enum: val.enum
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
649
652
|
allTools.push({
|
|
650
653
|
name: action.name,
|
|
651
654
|
description: action.description,
|
|
652
|
-
parameters:
|
|
653
|
-
Object.entries(action.parameters).map(([key, typeStr]) => [
|
|
654
|
-
key,
|
|
655
|
-
{ type: typeStr as any, description: key, required: true },
|
|
656
|
-
]),
|
|
657
|
-
),
|
|
655
|
+
parameters: toolParams,
|
|
658
656
|
execute: async (args) => {
|
|
659
657
|
try {
|
|
660
658
|
const result = await action.handler(args);
|
package/src/core/MCPBridge.ts
CHANGED
|
@@ -41,23 +41,76 @@ export class MCPBridge {
|
|
|
41
41
|
this.ws.onmessage = async (event) => {
|
|
42
42
|
try {
|
|
43
43
|
const data = JSON.parse(event.data);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
const serverMode = this.runtime.getConfig().mcpServerMode ?? 'auto';
|
|
45
|
+
const serverEnabled = serverMode === 'enabled' || (serverMode !== 'disabled' && __DEV__);
|
|
46
|
+
|
|
47
|
+
switch (data.type) {
|
|
48
|
+
case 'request': {
|
|
49
|
+
if (!data.command || !data.requestId) return;
|
|
50
|
+
logger.info('MCPBridge', `Received task from MCP: "${data.command}"`);
|
|
51
|
+
|
|
52
|
+
if (this.runtime.getIsRunning()) {
|
|
53
|
+
this.sendResponse(data.requestId, {
|
|
54
|
+
success: false,
|
|
55
|
+
message: 'Agent is already running a task. Please wait.',
|
|
56
|
+
steps: [],
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Execute the task using the SDK's existing runtime loop
|
|
62
|
+
const result = await this.runtime.execute(data.command);
|
|
63
|
+
|
|
64
|
+
// Send result back to MCP server
|
|
65
|
+
this.sendResponse(data.requestId, result);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
case 'tools/list': {
|
|
70
|
+
if (!serverEnabled) {
|
|
71
|
+
this.sendResponse(data.requestId, { error: 'MCP server mode is disabled.' });
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tools = this.runtime.getTools().map(t => ({
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: t.description,
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: t.parameters || {},
|
|
81
|
+
required: Object.entries(t.parameters || {})
|
|
82
|
+
.filter(([_, p]: [string, any]) => p.required !== false)
|
|
83
|
+
.map(([k]) => k),
|
|
84
|
+
}
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
this.sendResponse(data.requestId, { tools });
|
|
88
|
+
break;
|
|
54
89
|
}
|
|
55
90
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
case 'tools/call': {
|
|
92
|
+
if (!serverEnabled) {
|
|
93
|
+
this.sendResponse(data.requestId, { error: 'MCP server mode is disabled.' });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await this.runtime.executeTool(data.name, data.arguments || {});
|
|
98
|
+
this.sendResponse(data.requestId, { result });
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
this.sendResponse(data.requestId, { error: err.message });
|
|
101
|
+
}
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'screen/state': {
|
|
106
|
+
if (!serverEnabled) {
|
|
107
|
+
this.sendResponse(data.requestId, { error: 'MCP server mode is disabled.' });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
const screen = this.runtime.getScreenContext();
|
|
111
|
+
this.sendResponse(data.requestId, { screen });
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
61
114
|
}
|
|
62
115
|
} catch (err) {
|
|
63
116
|
logger.error('MCPBridge', 'Error handling message:', err);
|
package/src/core/types.ts
CHANGED
|
@@ -114,9 +114,19 @@ export interface AgentConfig {
|
|
|
114
114
|
*/
|
|
115
115
|
voiceProxyHeaders?: Record<string, string>;
|
|
116
116
|
|
|
117
|
-
model?: string;
|
|
117
|
+
model?: string;
|
|
118
|
+
|
|
119
|
+
/** Maximum steps per task */
|
|
118
120
|
maxSteps?: number;
|
|
119
121
|
|
|
122
|
+
/**
|
|
123
|
+
* MCP server mode — controls whether external agents can discover and invoke actions.
|
|
124
|
+
* 'auto' (default): enabled in __DEV__, disabled in production
|
|
125
|
+
* 'enabled': always on (opt-in for production)
|
|
126
|
+
* 'disabled': always off
|
|
127
|
+
*/
|
|
128
|
+
mcpServerMode?: 'auto' | 'enabled' | 'disabled';
|
|
129
|
+
|
|
120
130
|
// ─── Element Gating ──
|
|
121
131
|
|
|
122
132
|
/**
|
|
@@ -309,10 +319,21 @@ export interface ToolParam {
|
|
|
309
319
|
|
|
310
320
|
// ─── Action (optional useAction hook) ─────────────────────────
|
|
311
321
|
|
|
322
|
+
export interface ActionParameterDef {
|
|
323
|
+
/** The primitive type of the parameter. Maps to MCP schemas and native iOS/Android types. */
|
|
324
|
+
type: 'string' | 'number' | 'boolean';
|
|
325
|
+
/** A clear description of what the parameter is for (read by the AI). */
|
|
326
|
+
description: string;
|
|
327
|
+
/** Whether the AI must provide this parameter. Defaults to true. */
|
|
328
|
+
required?: boolean;
|
|
329
|
+
/** If provided, the AI is restricted to these specific string values. */
|
|
330
|
+
enum?: string[];
|
|
331
|
+
}
|
|
332
|
+
|
|
312
333
|
export interface ActionDefinition {
|
|
313
334
|
name: string;
|
|
314
335
|
description: string;
|
|
315
|
-
parameters: Record<string, string>;
|
|
336
|
+
parameters: Record<string, string | ActionParameterDef>;
|
|
316
337
|
handler: (args: Record<string, any>) => any;
|
|
317
338
|
}
|
|
318
339
|
|