@mobileaidev/ai-app-bridge 0.3.8 → 0.4.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 (56) hide show
  1. package/README.md +52 -39
  2. package/bin/ai-app-bridge.js +56 -17
  3. package/bin/command-discovery.js +19 -4
  4. package/bin/command-registry.js +15 -4
  5. package/bin/command-request.js +68 -4
  6. package/bin/execution-host.js +31 -5
  7. package/bin/execution-runtime.js +17 -10
  8. package/bin/executors/preparation.js +19 -5
  9. package/bin/extraction/json-value.js +26 -0
  10. package/bin/extraction/prepare.js +57 -0
  11. package/bin/extraction/regex.js +30 -0
  12. package/bin/extraction/runner.js +77 -0
  13. package/bin/mcp-server.js +15 -20
  14. package/bin/public-reply.js +184 -0
  15. package/bin/response-store.js +60 -0
  16. package/bin/runtime-client.js +32 -15
  17. package/bin/runtime-directory.js +37 -8
  18. package/bin/script/node-runtime-adapter.js +139 -123
  19. package/bin/script/python-runtime-adapter.js +1 -1
  20. package/bin/script/script-diagnostics.js +21 -0
  21. package/bin/script/script-durable-restore.js +1 -0
  22. package/bin/script/script-sdk.js +39 -4
  23. package/bin/script/script-sdk.py +79 -7
  24. package/bin/script/script-session-channel.js +27 -10
  25. package/bin/script/script-supervisor.js +8 -0
  26. package/bin/shared-kernel/argument-schema.js +44 -12
  27. package/bin/shared-kernel/evidence-archive.js +2 -2
  28. package/bin/shared-kernel/evidence-schema.js +16 -1
  29. package/bin/shared-kernel/evidence-store.js +3 -3
  30. package/bin/shared-kernel/execution-contracts.js +13 -5
  31. package/docs/COMMAND_CONTRACT.md +91 -19
  32. package/docs/EVIDENCE_ARCHIVE.md +14 -1
  33. package/docs/INSTALLATION.md +73 -0
  34. package/docs/INTENT_FOREGROUND.md +4 -1
  35. package/docs/OPTIONAL_EXECUTORS.md +14 -14
  36. package/docs/RELEASE.md +60 -122
  37. package/docs/RESPONSE_EXTRACTION.md +122 -0
  38. package/docs/SCRIPT_AUTHORING.md +125 -6
  39. package/node_modules/@mobileaidev/segmented-fact-store-native/PREBUILDS.md +29 -0
  40. package/node_modules/@mobileaidev/segmented-fact-store-native/binding-path.js +29 -0
  41. package/node_modules/@mobileaidev/segmented-fact-store-native/binding.gyp +1 -0
  42. package/node_modules/@mobileaidev/segmented-fact-store-native/index.js +1 -3
  43. package/node_modules/@mobileaidev/segmented-fact-store-native/install.js +5 -0
  44. package/node_modules/@mobileaidev/segmented-fact-store-native/package.json +11 -5
  45. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-arm64/segmented_fact_store.node +0 -0
  46. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/darwin-x64/segmented_fact_store.node +0 -0
  47. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-arm64-glibc/segmented_fact_store.node +0 -0
  48. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/linux-x64-glibc/segmented_fact_store.node +0 -0
  49. package/node_modules/@mobileaidev/segmented-fact-store-native/prebuilds/manifest.json +27 -0
  50. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/build-release-prebuilds.js +33 -0
  51. package/node_modules/@mobileaidev/segmented-fact-store-native/scripts/stage-prebuild.js +17 -0
  52. package/package.json +12 -5
  53. package/runtime/executors/android/prepare.init.gradle +22 -0
  54. package/runtime/executors/playwright/package-lock.json +2 -2
  55. package/runtime/executors/playwright/package.json +1 -1
  56. package/skills/ai-app-bridge-use/SKILL.md +19 -4
@@ -66,9 +66,9 @@ async function withProject(projectDir, action, { home = executorHome() } = {}) {
66
66
  return result;
67
67
  } catch (error) {
68
68
  const result = { ok: false, bridgeVersion, directory, error: error.code || 'executor_prepare_failed',
69
- message: error.message, elapsedMs: Date.now() - startedAtMs };
69
+ message: error.message, ...(error.details ? { details: error.details } : {}), elapsedMs: Date.now() - startedAtMs };
70
70
  atomicJson(path.join(directory, 'result.json'), result);
71
- throw new CommandError(result.error, result.message, { details: { directory, resultFile: path.join(directory, 'result.json') } });
71
+ throw new CommandError(result.error, result.message, { details: { ...error.details, directory, resultFile: path.join(directory, 'result.json') } });
72
72
  }
73
73
  } finally { if (locked) lock.exec('ROLLBACK'); lock.close(); }
74
74
  }
@@ -111,21 +111,35 @@ async function prepareAndroid(args, { run = execFileBounded, home } = {}) {
111
111
  fs.writeFileSync(javaFile, androidEntry(adapters));
112
112
  const coordinate = module => `com.github.mobileAiDev.ai-app-bridge:${module}:${bridgeVersion}`;
113
113
  const config = { directory, module: args.module, variant: args.variant, repositoryUrl,
114
+ requirements: { minCompileSdk: adapters.includes('compose') ? 35 : 34, minAgp: '8.1.1',
115
+ dependencies: ['androidx.test.uiautomator:uiautomator:2.4.0', ...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4-android:1.8.3'] : [])] },
114
116
  pluginCoordinate: coordinate('ai-app-bridge-gradle-plugin'),
115
117
  dependencies: [coordinate('ai-app-bridge-test-instrumentation'), ...adapters.map(adapter => coordinate(`ai-app-bridge-test-${adapter}`)),
116
118
  ...(adapters.includes('compose') ? ['androidx.compose.ui:ui-test-junit4:1.8.3'] : [])] };
117
119
  const configFile = path.join(directory, 'config.json');
118
120
  atomicJson(configFile, config);
119
121
  const template = path.resolve(__dirname, '../../runtime/executors/android/prepare.init.gradle');
120
- await loggedRun(run, 'sh', [wrapper, '--init-script', template, `-Daab.prepare.config=${configFile}`,
122
+ const preflightFile = path.join(directory, 'android-preflight.json');
123
+ try { await loggedRun(run, 'sh', [wrapper, '--init-script', template, `-Daab.prepare.config=${configFile}`,
121
124
  `${args.module}:aiAppBridgePrepareExecutor`, '--console=plain', '--no-configuration-cache', '--max-workers=2'],
122
- directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 });
125
+ directory, 'gradle-build', { cwd: project, timeoutMs: args.timeoutMs ?? 600000 }); }
126
+ catch (error) {
127
+ const preflight = readJson(preflightFile);
128
+ if (preflight && preflight.status !== 'compatible') throw new CommandError(
129
+ preflight.status === 'incompatible' ? 'executor_prepare_incompatible' : 'executor_prepare_configuration_unresolved',
130
+ `Android executor requirements were not met before compilation: ${preflight.issues.join('; ')}. No project configuration was upgraded.`,
131
+ { details: { ...error.details, preflight, preflightFile } });
132
+ throw error;
133
+ }
134
+ const preflight = readJson(preflightFile);
135
+ if (preflight?.status !== 'compatible' || preflight.module !== args.module || preflight.variant !== args.variant)
136
+ throw new CommandError('executor_prepare_configuration_unresolved', 'The build did not report the selected module and variant preflight.', { details: { preflightFile } });
123
137
  const build = readJson(path.join(directory, 'android-build.json'));
124
138
  if (build?.schemaVersion !== 'aab.android-prepared-build/v1' || build.variant !== args.variant || build.module !== args.module
125
139
  || !build.packageName || !build.testPackageName || !build.runner || !build.applicationApks?.length || !build.testApks?.length)
126
140
  throw new CommandError('executor_prepare_artifact_missing', 'The requested application/test variant did not produce complete build metadata.');
127
141
  const artifacts = files => files.map(file => ({ path: requireFile(file), sha256: digest(fs.readFileSync(file)) }));
128
- return { platform: 'android', engine: 'android-instrumentation', ...build, testClass,
142
+ return { platform: 'android', engine: 'android-instrumentation', ...build, testClass, preflight,
129
143
  instrumentation: `${build.testPackageName}/${build.runner}`, repositoryUrl,
130
144
  adapters: ['uiautomator', 'espresso', ...adapters], applicationApks: artifacts(build.applicationApks), testApks: artifacts(build.testApks),
131
145
  lifecycle: 'built-not-installed', projectFilesEdited: [],
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ function validateJsonValue(value, location = '', parents = new Set()) {
4
+ function invalid(reason) { throw Object.assign(new TypeError(`${location || '/'}: ${reason}`),
5
+ { code: 'extraction_type_error', valuePath: location }); }
6
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
7
+ if (typeof value === 'number') {
8
+ if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) invalid('expected a finite number in the safe integer range');
9
+ return;
10
+ }
11
+ if (typeof value !== 'object') invalid(`unsupported JSON type: ${typeof value}`);
12
+ if (parents.has(value)) invalid('cyclic value');
13
+ if (!Array.isArray(value) && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid('expected a plain JSON object');
14
+ if (Object.getOwnPropertySymbols(value).length) invalid('symbol keys are unsupported');
15
+ parents.add(value);
16
+ const keyPath = key => `${location}/${String(key).replace(/~/g, '~0').replace(/\//g, '~1')}`;
17
+ if (Array.isArray(value)) {
18
+ if (Object.keys(value).length !== value.length) invalid('sparse arrays or extra array properties are unsupported');
19
+ for (let i = 0; i < value.length; i++) validateJsonValue(value[i], keyPath(i), parents);
20
+ } else {
21
+ for (const key of Object.keys(value)) validateJsonValue(value[key], keyPath(key), parents);
22
+ }
23
+ parents.delete(value);
24
+ }
25
+
26
+ module.exports = { validateJsonValue };
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const vm = require('node:vm');
6
+ const Module = require('node:module');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { CommandError } = require('../command-errors');
9
+ const { requestDirectory } = require('../shared-kernel/request-context');
10
+ const { inspectPython } = require('../script/python-runtime-adapter');
11
+ const { executablePath } = require('../shared-kernel/executable-path');
12
+
13
+ function prepareExtraction(extract) {
14
+ if (extract === null) return null;
15
+ const timeoutMs = extract.timeoutMs ?? 2000;
16
+ const reject = (message, field, code = 'invalid_argument') => { throw new CommandError(code, message, { field }); };
17
+ if (extract.mode === 'regex') {
18
+ const flags = extract.flags ?? '';
19
+ if (new Set(flags).size !== flags.length) reject('Regex flags must be unique.', 'extract.flags');
20
+ if (extract.inputPath !== '' && (!extract.inputPath.startsWith('/') || /~(?![01])/u.test(extract.inputPath))) {
21
+ reject('inputPath must be a JSON Pointer (for example /text; escape ~ as ~0 and / as ~1).', 'extract.inputPath');
22
+ }
23
+ try { new RegExp(extract.pattern, flags); }
24
+ catch (error) { reject(error.message, 'extract.pattern'); }
25
+ return { ...extract, flags, timeoutMs };
26
+ }
27
+ const cwd = requestDirectory();
28
+ const filename = extract.sourcePath ? path.resolve(cwd, extract.sourcePath) : `extract.${extract.language === 'python' ? 'py' : 'js'}`;
29
+ let source = extract.source;
30
+ if (extract.sourcePath) {
31
+ try {
32
+ if (fs.statSync(filename).size > 65536) reject('Extraction source exceeds 64 KiB UTF-8.', 'extract.sourcePath');
33
+ source = fs.readFileSync(filename, 'utf8');
34
+ } catch (error) {
35
+ if (error instanceof CommandError) throw error;
36
+ reject(`Cannot read extraction source: ${error.message}`, 'extract.sourcePath', 'extraction_source_unavailable');
37
+ }
38
+ }
39
+ if (!source.length || Buffer.byteLength(source) > 65536) reject('Extraction source must contain 1–65536 UTF-8 bytes.', extract.sourcePath ? 'extract.sourcePath' : 'extract.source');
40
+ let executable = process.execPath;
41
+ if (extract.language === 'javascript') {
42
+ try { new vm.Script(Module.wrap(source), { filename }); }
43
+ catch (error) { reject(`${filename}: ${error.message}`, 'extract.source', 'extraction_syntax_error'); }
44
+ } else {
45
+ const python = inspectPython();
46
+ if (!python.available) reject('Python 3.9+ is required only for Python extraction. Set AI_APP_BRIDGE_PYTHON to an available interpreter.', 'extract.language', 'extraction_runtime_unavailable');
47
+ executable = executablePath(python.executable);
48
+ if (!executable) reject('The selected Python interpreter is unavailable.', 'extract.language', 'extraction_runtime_unavailable');
49
+ const checked = spawnSync(executable, ['-c', 'import sys; compile(sys.stdin.read(), sys.argv[1], "exec")', filename],
50
+ { input: source, encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL', maxBuffer: 16384, cwd });
51
+ if (checked.error) reject(checked.error.message, 'extract.language', 'extraction_runtime_unavailable');
52
+ if (checked.status !== 0) reject(checked.stderr.trim(), 'extract.source', 'extraction_syntax_error');
53
+ }
54
+ return { mode: 'script', language: extract.language, source, filename, cwd, executable, timeoutMs };
55
+ }
56
+
57
+ module.exports = { prepareExtraction };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+
3
+ function extractRegex(response, { inputPath, pattern, flags = '' }) {
4
+ let input = response;
5
+ if (inputPath !== '') {
6
+ for (const part of inputPath.slice(1).split('/')) {
7
+ const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
8
+ if (input === null || typeof input !== 'object' || !Object.hasOwn(input, key)) {
9
+ throw Object.assign(new Error(`JSON Pointer does not exist: ${inputPath}`), { code: 'extraction_path_not_found' });
10
+ }
11
+ input = input[key];
12
+ }
13
+ }
14
+ if (typeof input !== 'string') throw Object.assign(new Error('Regex inputPath must select a string.'), { code: 'extraction_input_type' });
15
+ const expression = new RegExp(pattern, `${flags}g`);
16
+ const matches = [];
17
+ let match;
18
+ while ((match = expression.exec(input)) !== null) {
19
+ if (matches.length === 1000) throw Object.assign(new Error('Regex exceeded 1000 matches; no partial result was returned.'), { code: 'extraction_match_limit' });
20
+ matches.push({ match: match[0], groups: Array.from(match).slice(1).map(value => value === undefined ? null : value),
21
+ namedGroups: Object.fromEntries(Object.entries(match.groups || {}).map(([key, value]) => [key, value === undefined ? null : value])) });
22
+ if (match[0] === '') {
23
+ const code = input.codePointAt(expression.lastIndex);
24
+ expression.lastIndex += flags.includes('u') && code > 0xffff ? 2 : 1;
25
+ }
26
+ }
27
+ return matches;
28
+ }
29
+
30
+ module.exports = { extractRegex };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawn } = require('node:child_process');
7
+ const { createScriptSessionChannel } = require('../script/script-session-channel');
8
+
9
+ const MAX_INPUT_FRAME_BYTES = 8 * 1024 * 1024 + 64 * 1024;
10
+ const MAX_OUTPUT_FRAME_BYTES = 256 * 1024 + 64 * 1024;
11
+ let active = 0;
12
+
13
+ async function runExtraction(prepared, inputs) {
14
+ if (active >= 2) return { ok: false, error: 'extraction_busy', message: 'Two extraction workers are active. Retry extraction using the response ref; do not repeat the action.' };
15
+ active++;
16
+ let directory, child, channel, timer, exited;
17
+ let result;
18
+ let timings = {};
19
+ let started = performance.now();
20
+ try {
21
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'aab-extract-'));
22
+ const python = prepared.language === 'python';
23
+ const artifact = path.join(directory, python ? 'main.py' : 'main.js');
24
+ fs.writeFileSync(artifact, prepared.source ?? '');
25
+ const sdk = path.resolve(__dirname, '../script', python ? 'script-sdk.py' : 'script-sdk.js');
26
+ started = performance.now();
27
+ child = spawn(prepared.executable ?? process.execPath, [sdk, artifact], {
28
+ shell: false, cwd: prepared.cwd, stdio: ['pipe', 'pipe', 'pipe'],
29
+ });
30
+ channel = createScriptSessionChannel(child, { maxInputFrameBytes: MAX_INPUT_FRAME_BYTES, maxOutputFrameBytes: MAX_OUTPUT_FRAME_BYTES });
31
+ exited = new Promise(resolve => {
32
+ child.once('exit', resolve);
33
+ child.once('error', () => { if (child.pid == null) resolve(); });
34
+ });
35
+ const timeout = new Promise(resolve => { timer = setTimeout(() => resolve({ type: 'fail', error: 'extraction_timeout' }), prepared.timeoutMs); });
36
+ const next = () => Promise.race([channel.nextMessage(), timeout]);
37
+ let message = await next();
38
+ if (message.type === 'ready') {
39
+ timings.coldStartMs = performance.now() - started;
40
+ channel.send({ type: 'start', inputs, extraction: prepared.mode === 'regex'
41
+ ? { mode: 'regex', inputPath: prepared.inputPath, pattern: prepared.pattern, flags: prepared.flags }
42
+ : { mode: 'script' }, sourceName: prepared.filename, entrypoint: 'main' });
43
+ message = await next();
44
+ }
45
+ timings.responseMs = performance.now() - started;
46
+ if (message.timings) Object.assign(timings, message.timings);
47
+ if (message.type === 'return' && Object.hasOwn(message, 'result')) {
48
+ require('./json-value').validateJsonValue(message.result);
49
+ if (Buffer.byteLength(JSON.stringify(message.result)) > 256 * 1024) result = { ok: false, error: 'extraction_output_too_large' };
50
+ else result = { ok: true, result: message.result };
51
+ } else {
52
+ const code = ['channel_closed', 'stopped'].includes(message.error) ? 'extraction_worker_exited'
53
+ : message.error === 'frame_too_large' ? 'extraction_frame_too_large'
54
+ : message.error === 'malformed_frame' ? 'extraction_malformed_frame' : message.error;
55
+ result = { ok: false, error: typeof code === 'string' && /^extraction_[a-z0-9_]{1,110}$/.test(code) ? code : 'extraction_failed',
56
+ ...(message.message ? { message: message.message } : {}), ...(message.diagnostic ? { diagnostic: message.diagnostic } : {}) };
57
+ }
58
+ } catch (error) {
59
+ result = { ok: false, error: error.code === 'extraction_type_error' ? error.code : 'extraction_worker_failed', message: error.message };
60
+ } finally {
61
+ clearTimeout(timer);
62
+ channel?.stop();
63
+ if (exited) await exited;
64
+ // Descendants may inherit pipes, but are outside the worker lifecycle.
65
+ // Close our pipe handles after the worker exits instead of waiting for
66
+ // every descendant to close its copies of stdout/stderr.
67
+ child?.stdin?.destroy(); child?.stdout?.destroy(); child?.stderr?.destroy();
68
+ try { if (directory) fs.rmSync(directory, { recursive: true, force: true }); }
69
+ catch (error) { result = { ok: false, error: 'extraction_cleanup_failed', message: error.message }; }
70
+ finally { active--; }
71
+ }
72
+ const diagnostics = channel?.diagnostics();
73
+ return { ...result, durationMs: Math.round((performance.now() - started) * 100) / 100, timings,
74
+ ...(!result.ok && diagnostics && Object.keys(diagnostics).length ? { diagnostics } : {}) };
75
+ }
76
+
77
+ module.exports = { runExtraction, MAX_INPUT_FRAME_BYTES, MAX_OUTPUT_FRAME_BYTES };
package/bin/mcp-server.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const packageInfo = require('../package.json');
4
4
  const runtimeClient = require('./runtime-client');
5
5
  const discovery = require('./command-discovery');
6
+ const { publicRequestSchema } = require('./command-request');
6
7
  const clientConnection = new AbortController();
7
8
  const { supportedTargets, commandDomains, supportedTargetsText, commandDomainsText, discoveryText } = discovery;
8
9
  const supportedProtocolVersions = ['2025-06-18', '2024-11-05'];
@@ -10,6 +11,8 @@ const defaultProtocolVersion = supportedProtocolVersions[0];
10
11
  const serverInstructions = [
11
12
  'Intent, Script and individual commands share one runtime across CLI and MCP. Disconnecting a client leaves operations running; explicit task cancel or runtime stop owns cancellation. Platform capabilities do not imply full complex-App acceptance.',
12
13
  'Use script status/wait for progress and resultRef; read the final output with script operation=result and the same operationId, including after runtime restart. A completed execution is separate from the business verdict.',
14
+ 'run requires extract: null delivers the command\'s own result. Every run reply is {command, execution, control, extraction, delivery, kind, value, failureStage?}: execution holds the command\'s ok/error and dispatch facts, control the fields needed to continue (operationId, status, eventSequence, history cursor), and value the original result including _feedback. Read failureStage first when isError is set.',
15
+ 'For large UI/network/log results, choose a focused regex or JS/Python extraction; scripts receive ctx.inputs={kind,response,execution,control} without ctx.call. Default final-body budget is 96 KiB. On extraction failure or overflow, only a persisted control.source.ref can be read with response operation=read and a new extract. Never repeat the original action to repair extraction. control.pendingQuestion retains a current Script question even when events are filtered.',
13
16
  supportedTargetsText,
14
17
  discoveryText,
15
18
  'Prefer AI App Bridge over raw adb, devicectl, or browser-specific scripts when inspecting UI, text, WebView/WKWebView, logs, network, app install, launch, permissions, or app-level Web evidence.',
@@ -30,7 +33,7 @@ MCP surface:
30
33
  Discovery:
31
34
  1. Call capabilities with optional domain or command filters.
32
35
  2. Call run with a command name from capabilities.
33
- 3. Put command-specific options in arguments.
36
+ 3. Put command-specific options in arguments; pass extract (null for the command's own result).
34
37
 
35
38
  Target ids:
36
39
  Android app commands require packageName; port selects only the host forwarding port.
@@ -41,8 +44,8 @@ Target ids:
41
44
  Examples:
42
45
  capabilities { "domain": "webview" }
43
46
  capabilities { "command": "intent", "operation": "start" }
44
- run { "command": "screenshot", "arguments": { "packageName": "com.example.app" } }
45
- run { "command": "web-session-start", "arguments": { "webPort": 18180 } }
47
+ run { "command": "screenshot", "extract": null, "arguments": { "packageName": "com.example.app" } }
48
+ run { "command": "web-session-start", "extract": null, "arguments": { "webPort": 18180 } }
46
49
  `;
47
50
  let buffer = Buffer.alloc(0);
48
51
  let responseFormat = null;
@@ -247,10 +250,8 @@ function toolDefinitions() {
247
250
  provider: { enum: ['native', 'uia', 'flutter', 'h5'], description: 'Intent decide schema scope only; must be supported by the selected platform.' },
248
251
  action: { type: 'string', description: 'Intent decide schema scope only, for example tap or inputText.' },
249
252
  } } },
250
- { name: 'run', description: 'Execute a command from capabilities. All command parameters, including target identity, belong in arguments.',
251
- inputSchema: { type: 'object', additionalProperties: false, required: ['command'], properties: {
252
- command: { type: 'string' }, arguments: { type: 'object', additionalProperties: true },
253
- } } },
253
+ { name: 'run', description: 'Execute a command from capabilities. All command parameters, including target identity, belong in arguments. extract is required: null delivers the command\'s own result. The reply is {command, execution, control, extraction, delivery, kind, value, failureStage?}; the original result, including _feedback, is value.',
254
+ inputSchema: publicRequestSchema() },
254
255
  ];
255
256
  }
256
257
 
@@ -263,23 +264,16 @@ async function callTool(name, args) {
263
264
  return toolJson({ ok: false, error: 'unknown_tool', message: `Unknown tool: ${name}. Use capabilities or run.`, dispatched: false, ambiguous: false }, true);
264
265
  }
265
266
 
266
- // MCP only adapts the shared execution reply to its tool-result format.
267
+ // MCP only adapts the public reply to its tool-result format: the text is the
268
+ // compact public reply, isError follows failureStage. Nothing is duplicated
269
+ // into structuredContent or _meta.
267
270
  const capabilityPayload = discovery.capabilities;
268
271
  async function runGeneric(args = {}) {
269
272
  return toolResultForReply(await runtimeClient.run(args, { signal: clientConnection.signal }));
270
273
  }
271
274
 
272
- function toolResultForReply({ value: result, history }) {
273
- let tool;
274
- if (typeof result === 'string') tool = toolText(result);
275
- else if (Buffer.isBuffer(result)) tool = toolText(result.toString('utf8'));
276
- else if (result === undefined) tool = toolJson({ ok: false, error: 'runtime_result_missing', dispatched: null, ambiguous: true }, true);
277
- else {
278
- if (history && result && typeof result === 'object' && !Array.isArray(result)) result = { ...result, _history: history };
279
- tool = toolJson(result, Boolean(result && typeof result === 'object' && result.ok === false));
280
- }
281
- if (history) tool._meta = { 'ai-app-bridge/history': history };
282
- return tool;
275
+ function toolResultForReply({ value: reply }) {
276
+ return toolJson(reply, Boolean(reply.failureStage));
283
277
  }
284
278
 
285
279
  function toolText(text, isError = false) {
@@ -294,8 +288,9 @@ function toolText(text, isError = false) {
294
288
  };
295
289
  }
296
290
 
291
+ // Compact JSON: indentation is context cost for the calling model, not information.
297
292
  function toolJson(value, isError = false) {
298
- return toolText(JSON.stringify(value, null, 2), isError);
293
+ return toolText(JSON.stringify(value), isError);
299
294
  }
300
295
 
301
296
  function sendResult(id, result) {
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+
3
+ const { commandFailure } = require('./command-errors');
4
+ const { encodeReply } = require('./runtime-protocol');
5
+ const { randomUUID } = require('node:crypto');
6
+ const DEFAULT_OUTPUT_BYTES = 96 * 1024;
7
+
8
+ // The public reply of one run request, assembled once at the Runtime boundary
9
+ // and by the client for its local paths. The original result stays in `value`
10
+ // (including `_feedback`); execution facts and the fields needed to continue
11
+ // are copied next to it so an extraction cannot hide them.
12
+
13
+ const executionKeys = ['ok', 'error', 'message', 'field', 'details', 'dispatched', 'ambiguous', 'settled',
14
+ 'executionReceipt', 'executionReceipts', 'exitCode', 'target', 'matched', 'verified', 'inconclusive'];
15
+ // Continuation fields per response family. Only fields the response actually
16
+ // carries are copied; a business field of the same name in `value` is not one.
17
+ const scriptControlKeys = ['operationId', 'status', 'pauseReason', 'eventSequence', 'resultRef', 'persisted', 'timedOut', 'waitMs', 'pendingQuestion'];
18
+ const intentControlKeys = ['operationId', 'status', 'revision', 'lastDecisionId', 'eventSequence', 'eventGap', 'droppedEvents', 'evidenceId',
19
+ 'latestEvidenceIds', 'terminalEvidenceId', 'observationFailure', 'provider', 'observationTarget', 'deadlineMs', 'pendingOperations', 'lastAction'];
20
+ const commonControlKeys = ['cursor', 'nextCursor', 'factCursor', 'hasMore', 'truncated', 'dropped', 'gap', 'updatedAtMs', 'observedAtMs', 'capturedAtMs'];
21
+ const intentCommands = new Set(['intent', 'install-apk', 'permission-dialog']);
22
+ const captureCommands = new Set(['logs', 'network', 'state', 'events', 'ios-logs', 'ios-network', 'ios-state', 'ios-events',
23
+ 'web-logs', 'web-network', 'web-state', 'web-events', 'webview-console', 'webview-network']);
24
+ const captureControlKeys = ['stream', 'coverage', 'window', 'runtimeEpoch', 'targetKey', 'storeGeneration', 'watermarkCursor',
25
+ 'throughWatermark', 'barrier', 'committed'];
26
+
27
+ function isRecord(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && !Buffer.isBuffer(value);
29
+ }
30
+
31
+ function pick(source, keys) {
32
+ return Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
33
+ }
34
+
35
+ // The invoking layer can confirm completion for raw JSON/text/bytes. An object
36
+ // response must carry its own execution outcome; missing ok remains unknown.
37
+ function executionFacts(value, completed) {
38
+ if (!isRecord(value)) return { ok: completed ? true : null };
39
+ const facts = pick(value, executionKeys);
40
+ if (facts.ok === undefined) facts.ok = null;
41
+ return facts;
42
+ }
43
+
44
+ function controlFacts(command, value, history) {
45
+ const control = {};
46
+ if (isRecord(value)) {
47
+ if (command === 'script') Object.assign(control, pick(value, scriptControlKeys));
48
+ else if (intentCommands.has(command)) Object.assign(control, pick(value, intentControlKeys));
49
+ else Object.assign(control, pick(value, commonControlKeys));
50
+ if (captureCommands.has(command)) {
51
+ Object.assign(control, pick(value, captureControlKeys));
52
+ if (isRecord(value._factCache)) control._factCache = pick(value._factCache,
53
+ ['history', 'cursor', 'gap', 'cursorExpired', 'hasMore', 'scannedCount', 'targetKey', 'partitions']);
54
+ }
55
+ if (control.pendingQuestion === null) delete control.pendingQuestion;
56
+ // Script/Intent history is source content; only its page cursor is control.
57
+ if ((command === 'script' || intentCommands.has(command)) && isRecord(value.history)) {
58
+ control.history = pick(value.history, ['lastSequence', 'hasMore', 'gap']);
59
+ }
60
+ }
61
+ if (history) control.history = history;
62
+ return control;
63
+ }
64
+
65
+ // `reply` is the internal {value, history?} of the executed command; `stage`
66
+ // names the public stage that rejected the request, when one did.
67
+ function publicReply({ command, reply, stage, completed = false }) {
68
+ const { kind, value } = encodeReply(reply);
69
+ const execution = executionFacts(reply.value, completed);
70
+ const failureStage = stage || (execution.ok !== true || execution.ambiguous === true ? 'execution' : undefined);
71
+ const body = {
72
+ command: typeof command === 'string' && command ? command : null,
73
+ execution,
74
+ control: { ...controlFacts(command, reply.value, reply.history), source: { responseId: randomUUID(), capturedAtMs: Date.now(), persisted: false, reason: 'not_requested' } },
75
+ extraction: { status: 'skipped' },
76
+ delivery: { status: 'inline' },
77
+ kind, value,
78
+ ...(failureStage ? { failureStage } : {}),
79
+ };
80
+ try {
81
+ body.delivery.valueBytes = Buffer.byteLength(JSON.stringify(value));
82
+ JSON.stringify(body);
83
+ return body;
84
+ } catch { return serializationFailure(body); }
85
+ }
86
+
87
+ function publicFailure({ command, stage, error, maxBytes = DEFAULT_OUTPUT_BYTES }) {
88
+ return boundedReply(publicReply({ command, reply: { value: commandFailure(error, command) }, stage }), maxBytes);
89
+ }
90
+
91
+ function minimalExecution(execution) {
92
+ return { ok: execution.ok === true ? true : execution.ok === false ? false : null,
93
+ ...pick(execution, ['dispatched', 'ambiguous', 'settled', 'exitCode']),
94
+ ...(typeof execution.error === 'string' && execution.error.length < 128 ? { error: execution.error } : {}) };
95
+ }
96
+
97
+ function serializationFailure(body) {
98
+ return { command: body.command, execution: minimalExecution(body.execution),
99
+ control: { source: body.control.source, controlComplete: false }, extraction: { status: 'skipped' },
100
+ delivery: { status: 'unavailable', reason: 'response_serialization_failed', limitBytes: DEFAULT_OUTPUT_BYTES },
101
+ kind: body.kind, failureStage: body.failureStage || 'delivery' };
102
+ }
103
+
104
+ function replyBytes(body) { return Buffer.byteLength(JSON.stringify(body)); }
105
+
106
+ function boundedReply(body, limitBytes = DEFAULT_OUTPUT_BYTES) {
107
+ body.delivery.limitBytes = limitBytes;
108
+ const attemptedBytes = replyBytes(body);
109
+ if (attemptedBytes <= limitBytes) return body;
110
+ delete body.value;
111
+ body.delivery = { ...body.delivery, status: body.control.source.persisted ? 'reference' : 'unavailable',
112
+ reason: 'output_budget_exceeded', attemptedBytes, limitBytes };
113
+ body.failureStage ||= 'delivery';
114
+ if (replyBytes(body) <= limitBytes) return body;
115
+ // A large question, receipt or diagnostic must never become a silently
116
+ // incomplete continuation. Keep dispatch uncertainty and the real source.
117
+ const source = pick(body.control.source, ['responseId', 'capturedAtMs', 'persisted', 'ref', 'reason']);
118
+ if (body.control.source.error) source.error = 'source_unavailable';
119
+ return { command: typeof body.command === 'string' && body.command.length < 256 ? body.command : null,
120
+ execution: minimalExecution(body.execution), control: { source, controlComplete: false },
121
+ extraction: pick(body.extraction, ['status', 'mode', 'language', 'durationMs', 'error']),
122
+ delivery: { status: source.persisted ? 'reference' : 'unavailable', reason: 'control_over_budget', attemptedBytes, limitBytes },
123
+ kind: body.kind, failureStage: body.failureStage };
124
+ }
125
+
126
+ async function finishReply({ body, extract, output, frozen, getStore, sourceReason = 'offline' }) {
127
+ const limitBytes = output?.maxBytes ?? DEFAULT_OUTPUT_BYTES;
128
+ body.delivery.limitBytes = limitBytes;
129
+ if (body.delivery.reason === 'response_serialization_failed') return boundedReply(body, limitBytes);
130
+ const { freezeResponse, MAX_SNAPSHOT_BYTES } = require('./response-store');
131
+ const extracting = extract !== null;
132
+ const needsSource = extracting || replyBytes(body) > limitBytes;
133
+ if (!needsSource) return body;
134
+ if (!frozen) {
135
+ try { frozen = freezeResponse(body); }
136
+ catch { return boundedReply(serializationFailure(body), limitBytes); }
137
+ if (getStore && body.kind !== 'bytes') {
138
+ try { body.control.source = await getStore().save(frozen); }
139
+ catch (error) { body.control.source = { ...body.control.source, reason: undefined, error: error.code || 'snapshot_save_failed' }; }
140
+ } else body.control.source = { ...body.control.source, reason: body.kind === 'bytes' ? 'binary_snapshot_unsupported' : sourceReason };
141
+ }
142
+ if (extracting) {
143
+ let result;
144
+ if (frozen.snapshot.kind === 'bytes') result = { ok: false, error: 'extraction_binary_unsupported' };
145
+ else if (frozen.bytes.length > MAX_SNAPSHOT_BYTES) result = { ok: false, error: 'extraction_input_too_large', maxBytes: MAX_SNAPSHOT_BYTES };
146
+ else {
147
+ const { kind, value: response, execution, control } = frozen.snapshot;
148
+ result = await require('./extraction/runner').runExtraction(extract, { kind, response, execution, control });
149
+ }
150
+ const { ok, result: value, timings, ...details } = result;
151
+ body.extraction = { status: ok ? 'succeeded' : 'failed', mode: extract.mode,
152
+ ...(extract.language ? { language: extract.language } : {}), ...details };
153
+ if (ok) {
154
+ body.kind = 'json';
155
+ body.value = value;
156
+ body.delivery.valueBytes = Buffer.byteLength(JSON.stringify(value));
157
+ } else {
158
+ delete body.value;
159
+ body.delivery = { status: body.control.source.persisted ? 'reference' : 'unavailable', limitBytes, reason: 'extraction_failed' };
160
+ body.failureStage ||= 'extraction';
161
+ }
162
+ }
163
+ return boundedReply(body, limitBytes);
164
+ }
165
+
166
+ function responseReply({ snapshot, source }) {
167
+ return { command: 'response', execution: { ok: true, dispatched: false, ambiguous: false },
168
+ control: { ...snapshot.control, origin: { ...snapshot.identity, execution: snapshot.execution }, source },
169
+ extraction: { status: 'skipped' }, delivery: { status: 'inline', valueBytes: Buffer.byteLength(JSON.stringify(snapshot.value)) },
170
+ kind: snapshot.kind, value: snapshot.value };
171
+ }
172
+
173
+ function isPublicReply(value) {
174
+ return isRecord(value) && isRecord(value.execution) && isRecord(value.delivery) && ['json', 'text', 'bytes'].includes(value.kind);
175
+ }
176
+
177
+ // 0: delivered as requested. 1: rejected, failed or unknown execution.
178
+ // 2: the command succeeded but extraction or delivery failed.
179
+ function exitCodeFor(reply) {
180
+ if (!reply.failureStage) return 0;
181
+ return ['validation', 'execution'].includes(reply.failureStage) ? 1 : 2;
182
+ }
183
+
184
+ module.exports = { publicReply, publicFailure, responseReply, isPublicReply, exitCodeFor, finishReply, boundedReply, DEFAULT_OUTPUT_BYTES };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const { createEvidenceStore } = require('./shared-kernel/evidence-store');
4
+ const { canonicalJson, validateRecord } = require('./shared-kernel/evidence-schema');
5
+ const { CommandError } = require('./command-errors');
6
+ const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
7
+
8
+ function responseSchema() {
9
+ return { type: 'object', additionalProperties: false, required: ['operation', 'ref'], properties: {
10
+ operation: { const: 'read' },
11
+ ref: { type: 'object', additionalProperties: false, required: ['namespace', 'evidenceId', 'checksum', 'operationId'], properties: {
12
+ namespace: { const: 'response' }, evidenceId: { type: 'string', minLength: 1, maxLength: 256 },
13
+ operationId: { type: 'string', minLength: 1, maxLength: 128 }, checksum: { type: 'string', pattern: '^[a-f0-9]{64}$' },
14
+ }, description: 'Pass control.source.ref unchanged. Reads the saved response; never repeats the original device action.' },
15
+ } };
16
+ }
17
+
18
+ // Detach using the public JSON encoding before sorting keys. These same bytes
19
+ // supply immediate extraction and durable storage, after all feedback is added.
20
+ function freezeResponse(body) {
21
+ const { source, ...control } = body.control;
22
+ const snapshot = JSON.parse(JSON.stringify({ kind: body.kind, value: body.value, execution: body.execution, control,
23
+ identity: { command: body.command, responseId: source.responseId, capturedAtMs: source.capturedAtMs } }));
24
+ const bytes = Buffer.from(canonicalJson(snapshot));
25
+ return { snapshot: JSON.parse(bytes), bytes };
26
+ }
27
+
28
+ function createResponseStore({ adapter, now } = {}) {
29
+ const evidence = createEvidenceStore({ namespace: 'response', adapter, now,
30
+ maxBytes: Math.ceil(MAX_SNAPSHOT_BYTES / 3) * 4 + 64 * 1024 });
31
+ async function save({ snapshot, bytes }) {
32
+ const identity = snapshot.identity;
33
+ const source = { responseId: identity.responseId, capturedAtMs: identity.capturedAtMs, persisted: false };
34
+ if (snapshot.kind === 'bytes') return { ...source, error: 'binary_snapshot_unsupported' };
35
+ if (bytes.length > MAX_SNAPSHOT_BYTES) return { ...source, error: 'snapshot_too_large', bytes: bytes.length, maxBytes: MAX_SNAPSHOT_BYTES };
36
+ const stored = await evidence.persist('response', { operationId: identity.responseId, revision: 1, snapshotBase64: bytes.toString('base64') });
37
+ if (!stored.ok) return { ...source, error: stored.error };
38
+ const retained = evidence.read(stored.evidenceId);
39
+ if (!retained.ok || retained.record.checksum !== stored.checksum) return { ...source, error: 'snapshot_not_retained' };
40
+ return { ...source, persisted: true, ref: { namespace: 'response', evidenceId: stored.evidenceId,
41
+ checksum: stored.checksum, operationId: identity.responseId } };
42
+ }
43
+ function read(ref) {
44
+ const result = evidence.read(ref.evidenceId);
45
+ if (!result.ok) throw new CommandError(`response_${result.error}`, result.error === 'not_found'
46
+ ? 'Saved response is missing, expired or evicted. No device action was repeated.' : 'Saved response integrity verification failed.');
47
+ const record = result.record;
48
+ if (record.checksum !== ref.checksum) throw new CommandError('response_checksum_mismatch', 'The supplied ref does not match the saved response checksum.');
49
+ if (record.operationId !== ref.operationId) throw new CommandError('response_identity_mismatch', 'The supplied ref does not match the saved response identity.');
50
+ if (!validateRecord('response', 'response', record).ok) throw new CommandError('response_invalid_snapshot', 'The saved response has an invalid snapshot.');
51
+ const bytes = Buffer.from(record.snapshotBase64, 'base64');
52
+ if (bytes.length > MAX_SNAPSHOT_BYTES) throw new CommandError('response_snapshot_too_large', 'The saved response exceeds the 8 MiB input limit.');
53
+ const snapshot = JSON.parse(bytes);
54
+ return { snapshot, bytes, source: { responseId: snapshot.identity.responseId,
55
+ capturedAtMs: snapshot.identity.capturedAtMs, persisted: true, ref } };
56
+ }
57
+ return { save, read };
58
+ }
59
+
60
+ module.exports = { createResponseStore, freezeResponse, responseSchema, MAX_SNAPSHOT_BYTES };