@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
@@ -1,9 +1,14 @@
1
1
  import asyncio
2
2
  import json
3
3
  import sys
4
+ import math
5
+ import traceback
6
+ import time
7
+ from types import SimpleNamespace
4
8
 
5
9
 
6
10
  _real_stdout = sys.stdout
11
+ _input_parse_ms = 0
7
12
  HELD = {
8
13
  "pause_requested",
9
14
  "paused_manual",
@@ -20,10 +25,14 @@ def send(message):
20
25
 
21
26
 
22
27
  def read_message():
28
+ global _input_parse_ms
23
29
  line = sys.stdin.readline()
24
30
  if not line:
25
31
  return None
26
- return json.loads(line)
32
+ started = time.perf_counter()
33
+ message = json.loads(line)
34
+ _input_parse_ms = (time.perf_counter() - started) * 1000
35
+ return message
27
36
 
28
37
 
29
38
  class HostContext:
@@ -120,22 +129,74 @@ class HostContext:
120
129
  return checkpoint["state"]
121
130
 
122
131
 
123
- def load_namespace(path):
132
+ def load_namespace(path, source_name=None):
124
133
  namespace = {}
125
134
  with open(path, "r", encoding="utf-8") as handle:
126
- exec(handle.read(), namespace, namespace)
135
+ exec(compile(handle.read(), source_name or path, "exec"), namespace, namespace)
127
136
  return namespace
128
137
 
129
138
 
139
+ def validate_json(value, location="", parents=None):
140
+ parents = set() if parents is None else parents
141
+
142
+ def invalid(reason):
143
+ error = TypeError(f"{location or '/'}: {reason}")
144
+ error.code = "extraction_type_error"
145
+ error.value_path = location
146
+ raise error
147
+
148
+ if value is None or isinstance(value, (str, bool)):
149
+ return
150
+ if isinstance(value, (int, float)):
151
+ if (isinstance(value, int) and abs(value) > 9007199254740991) or (isinstance(value, float) and
152
+ (not math.isfinite(value) or value.is_integer() and abs(value) > 9007199254740991)):
153
+ invalid("expected a finite number in the safe integer range")
154
+ return
155
+ if type(value) not in (list, dict):
156
+ invalid(f"unsupported JSON type: {type(value).__name__}")
157
+ if id(value) in parents:
158
+ invalid("cyclic value")
159
+ parents.add(id(value))
160
+ entries = enumerate(value) if isinstance(value, list) else value.items()
161
+ for key, item in entries:
162
+ if isinstance(value, dict) and not isinstance(key, str):
163
+ invalid("object keys must be strings")
164
+ suffix = str(key).replace("~", "~0").replace("/", "~1")
165
+ validate_json(item, f"{location}/{suffix}", parents)
166
+ parents.remove(id(value))
167
+
168
+
169
+ def bounded(value, size):
170
+ return str(value).encode("utf-8", errors="replace")[:size].decode("utf-8", errors="ignore")
171
+
172
+
173
+ def diagnostic(error):
174
+ frames = traceback.extract_tb(error.__traceback__)
175
+ stack = "Traceback (most recent call last):\n" + "".join(traceback.format_list(frames[-20:])) + "".join(traceback.format_exception_only(type(error), error))
176
+ result = {"type": type(error).__name__, "message": bounded(error, 2048),
177
+ "stack": bounded(stack, 8192), "stackTruncated": len(frames) > 20 or len(stack.encode("utf-8")) > 8192}
178
+ if isinstance(error, SyntaxError) and error.filename:
179
+ result["location"] = {"file": bounded(error.filename, 1024), "line": error.lineno, "column": error.offset}
180
+ elif frames:
181
+ frame = frames[-1]
182
+ result["location"] = {"file": bounded(frame.filename, 1024), "line": frame.lineno}
183
+ if hasattr(error, "value_path"):
184
+ result["valuePath"] = bounded(error.value_path, 1024)
185
+ return result
186
+
187
+
130
188
  def main():
131
189
  artifact_path = sys.argv[1]
132
190
  send({"type": "ready"})
133
191
  start = read_message()
134
192
  sys.stdout = sys.stderr
135
- ctx = HostContext(start.get("inputs") or {}, start.get("control") or {"status": "running", "pauseReason": None})
193
+ extraction = start.get("extraction") is not None
194
+ ctx = SimpleNamespace(inputs=start["inputs"]) if extraction else HostContext(start.get("inputs") or {}, start.get("control") or {"status": "running", "pauseReason": None})
136
195
  entry = start.get("entrypoint") or "main"
137
196
  try:
138
- namespace = load_namespace(artifact_path)
197
+ execution_started = time.perf_counter()
198
+ input_parse_ms = _input_parse_ms
199
+ namespace = load_namespace(artifact_path, start.get("sourceName"))
139
200
  loaded = namespace.get(entry)
140
201
  if not callable(loaded):
141
202
  send({"type": "fail", "error": "unsupported_entrypoint"})
@@ -143,9 +204,20 @@ def main():
143
204
  result = loaded(ctx)
144
205
  if asyncio.iscoroutine(result):
145
206
  result = asyncio.run(result)
146
- send({"type": "return", "result": result})
207
+ if extraction:
208
+ validate_json(result)
209
+ if len(json.dumps(result, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")) > 256 * 1024:
210
+ error = ValueError("Extraction result exceeds 256 KiB.")
211
+ error.code = "extraction_output_too_large"
212
+ raise error
213
+ frame = {"type": "return", "result": result}
214
+ if extraction:
215
+ frame["timings"] = {"inputParseMs": input_parse_ms, "executionMs": (time.perf_counter() - execution_started) * 1000}
216
+ send(frame)
147
217
  except Exception as error:
148
- send({"type": "fail", "error": str(error)})
218
+ detail = diagnostic(error)
219
+ send({"type": "fail", "error": getattr(error, "code", "extraction_failed") if extraction else detail["message"],
220
+ "message": detail["message"], "diagnostic": detail})
149
221
  sys.exit(1)
150
222
 
151
223
 
@@ -2,7 +2,8 @@
2
2
 
3
3
  const MAX_FRAME_BYTES = 1024 * 1024;
4
4
 
5
- function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } = {}) {
5
+ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES,
6
+ maxInputFrameBytes = maxFrameBytes, maxOutputFrameBytes = maxFrameBytes, maxDiagnosticBytes = 8192 } = {}) {
6
7
  let buffer = '';
7
8
  let closed = false;
8
9
  let failure = null;
@@ -12,10 +13,18 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
12
13
  const pending = [];
13
14
  const replies = new Map();
14
15
  const maxPending = 32;
16
+ let stderr = Buffer.alloc(0);
17
+ let stderrTotalBytes = 0;
15
18
 
16
19
  if (child.stderr) {
17
- child.stderr.on('data', () => {});
20
+ child.stderr.on('data', chunk => {
21
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
22
+ stderrTotalBytes += bytes.length;
23
+ stderr = bytes.length >= maxDiagnosticBytes ? Buffer.from(bytes.subarray(-maxDiagnosticBytes))
24
+ : Buffer.concat([stderr.subarray(Math.max(0, stderr.length + bytes.length - maxDiagnosticBytes)), bytes]);
25
+ });
18
26
  }
27
+ child.stdin.on('error', error => fail(error));
19
28
  child.stdout.setEncoding('utf8');
20
29
  child.stdout.on('data', (chunk) => {
21
30
  if (closed) return;
@@ -25,7 +34,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
25
34
  const line = buffer.slice(0, index);
26
35
  buffer = buffer.slice(index + 1);
27
36
  index = buffer.indexOf('\n');
28
- if (Buffer.byteLength(line, 'utf8') > maxFrameBytes) {
37
+ if (Buffer.byteLength(line, 'utf8') + 1 > maxOutputFrameBytes) {
29
38
  fail(new Error('frame_too_large'));
30
39
  return;
31
40
  }
@@ -57,7 +66,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
57
66
  }
58
67
  pending.push(message);
59
68
  }
60
- if (Buffer.byteLength(buffer, 'utf8') > maxFrameBytes) {
69
+ if (Buffer.byteLength(buffer, 'utf8') > maxOutputFrameBytes) {
61
70
  fail(new Error('frame_too_large'));
62
71
  }
63
72
  });
@@ -67,7 +76,7 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
67
76
  function send(message) {
68
77
  if (closed || !child.stdin.writable) return;
69
78
  const line = `${JSON.stringify(message)}\n`;
70
- if (Buffer.byteLength(line, 'utf8') > maxFrameBytes) {
79
+ if (Buffer.byteLength(line, 'utf8') > maxInputFrameBytes) {
71
80
  fail(new Error('frame_too_large'));
72
81
  return;
73
82
  }
@@ -76,14 +85,11 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
76
85
 
77
86
  function nextMessage() {
78
87
  return new Promise((resolve) => {
79
- if (closed) {
80
- resolve(failure);
81
- return;
82
- }
83
88
  if (pending.length > 0) {
84
89
  resolve(pending.shift());
85
90
  return;
86
91
  }
92
+ if (closed) { resolve(failure); return; }
87
93
  waiters.push(resolve);
88
94
  });
89
95
  }
@@ -108,7 +114,9 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
108
114
  closed = true;
109
115
  const frame = { type: 'fail', error: error.message || String(error) };
110
116
  failure = frame;
117
+ const terminal = error.message === 'channel_closed' ? pending.find(message => ['return', 'fail'].includes(message.type)) : null;
111
118
  pending.length = 0;
119
+ if (terminal) pending.push(terminal);
112
120
  buffer = '';
113
121
  while (waiters.length > 0) waiters.shift()(frame);
114
122
  for (const resolve of replies.values()) resolve(frame);
@@ -121,7 +129,16 @@ function createScriptSessionChannel(child, { maxFrameBytes = MAX_FRAME_BYTES } =
121
129
  if (child.kill('SIGTERM') && !killTimer) killTimer = setTimeout(() => child.kill('SIGKILL'), 250);
122
130
  }
123
131
 
124
- return { send, nextMessage, waitReply, stop };
132
+ function diagnostics() {
133
+ if (!stderrTotalBytes) return {};
134
+ // Invalid bytes expand to replacement characters. Bound delivered UTF-8
135
+ // text too, starting at a complete character in the retained tail.
136
+ const text = Buffer.from(stderr.toString('utf8'));
137
+ let start = Math.max(0, text.length - maxDiagnosticBytes);
138
+ while (start < text.length && (text[start] & 0xc0) === 0x80) start++;
139
+ return { stderr: text.subarray(start).toString('utf8'), stderrTruncated: stderrTotalBytes > stderr.length || start > 0 };
140
+ }
141
+ return { send, nextMessage, waitReply, stop, diagnostics };
125
142
  }
126
143
 
127
144
  module.exports = { createScriptSessionChannel };
@@ -174,6 +174,7 @@ function createScriptSupervisor({
174
174
  mutationUnknown: false,
175
175
  decisionRevision: 0,
176
176
  currentRequestId: null,
177
+ pendingQuestion: null,
177
178
  questionRevisions: new Map(),
178
179
  decisions: new Map(),
179
180
  ledger: scriptLedger,
@@ -431,8 +432,10 @@ function createScriptSupervisor({
431
432
  operationId: record.operationId,
432
433
  status: record.status,
433
434
  pauseReason: record.pauseReason,
435
+ pendingQuestion: record.status === 'waiting_for_agent' ? record.pendingQuestion : null,
434
436
  error: record.error,
435
437
  hash: record.hash,
438
+ ...(record.diagnostics ? { diagnostics: record.diagnostics } : {}),
436
439
  resultRef: record.resultRef ?? null,
437
440
  eventSequence: record.events.sequence,
438
441
  events: record.events.after(args.afterSequence, args.eventLimit ?? args.limit),
@@ -517,6 +520,7 @@ function persistRecord(record, kind, body) {
517
520
  }
518
521
 
519
522
  async function commitTerminal(record, status, error, now, result) {
523
+ record.diagnostics = status === 'failed' ? result?.diagnostics : undefined;
520
524
  let revision = record.checkpointRevision + 1;
521
525
  let resultRef = null;
522
526
  if (status === 'completed' && record.store) {
@@ -539,6 +543,7 @@ async function commitTerminal(record, status, error, now, result) {
539
543
  status,
540
544
  error,
541
545
  resultRef,
546
+ ...(record.diagnostics ? { diagnostics: record.diagnostics } : {}),
542
547
  ...(record.recording ? { recording: record.recording.info() } : {}),
543
548
  };
544
549
  let persisted = await persistRecord(record, 'checkpoint', checkpoint);
@@ -777,6 +782,9 @@ function trackAgent(agent, record, now) {
777
782
  const requestId = `ask-${record.decisionRevision}`;
778
783
  record.questionRevisions.set(requestId, record.decisionRevision);
779
784
  record.currentRequestId = requestId;
785
+ // The unanswered question stays on the record so a filtered event page
786
+ // cannot hide it from status/wait.
787
+ record.pendingQuestion = { requestId, revision: record.decisionRevision, request };
780
788
  record.status = 'waiting_for_agent';
781
789
  emitRecord(record, 'agent_question_created', {
782
790
  request,
@@ -14,19 +14,16 @@ function validateValue(value, schema, field = '') {
14
14
  if (Object.hasOwn(schema, 'const') && value !== schema.const) throw invalid(field, `${label} must be ${JSON.stringify(schema.const)}.`);
15
15
  if (schema.enum && !schema.enum.includes(value)) throw invalid(field, `${label} must be one of: ${schema.enum.join(', ')}.`);
16
16
  const type = schema.type;
17
- const matches = type === undefined || (type === 'array' ? Array.isArray(value)
18
- : type === 'null' ? value === null
19
- : type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
20
- : type === 'integer' ? Number.isSafeInteger(value)
21
- : type === 'number' ? typeof value === 'number' && Number.isFinite(value)
22
- : typeof value === type);
23
- if (!matches) throw invalid(field, `${label} must be ${type}; no implicit type conversion is performed.`);
17
+ if (!matchesType(value, type)) throw invalid(field, `${label} must be ${type}; no implicit type conversion is performed.`);
24
18
  if (schema.minLength !== undefined && value.length < schema.minLength) throw invalid(field, `${label} must not be empty.`);
25
19
  if (schema.maxLength !== undefined && value.length > schema.maxLength) throw invalid(field, `${label} must contain at most ${schema.maxLength} characters.`);
26
20
  if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) throw invalid(field, `${label} must match ${schema.pattern}.`);
27
- if (schema.minimum !== undefined && value < schema.minimum) throw invalid(field, `${label} must be >= ${schema.minimum}.`);
28
- if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) throw invalid(field, `${label} must be > ${schema.exclusiveMinimum}.`);
29
- if (schema.maximum !== undefined && value > schema.maximum) throw invalid(field, `${label} must be <= ${schema.maximum}.`);
21
+ // A range violation quotes the field's documented meaning, so the caller can
22
+ // correct the call without reading the nested schema.
23
+ const explained = message => schema.description ? `${message} ${schema.description}` : message;
24
+ if (schema.minimum !== undefined && value < schema.minimum) throw invalid(field, explained(`${label} must be >= ${schema.minimum}.`));
25
+ if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) throw invalid(field, explained(`${label} must be > ${schema.exclusiveMinimum}.`));
26
+ if (schema.maximum !== undefined && value > schema.maximum) throw invalid(field, explained(`${label} must be <= ${schema.maximum}.`));
30
27
  if (type === 'array') {
31
28
  if (schema.minItems !== undefined && value.length < schema.minItems) throw invalid(field, `${label} requires at least ${schema.minItems} item(s).`);
32
29
  if (schema.maxItems !== undefined && value.length > schema.maxItems) throw invalid(field, `${label} accepts at most ${schema.maxItems} item(s).`);
@@ -68,6 +65,15 @@ function validateValue(value, schema, field = '') {
68
65
  if (Object.keys(schema).every(key => ['description', 'default'].includes(key))) validateJson(value, field);
69
66
  }
70
67
 
68
+ function matchesType(value, type) {
69
+ return type === undefined || (type === 'array' ? Array.isArray(value)
70
+ : type === 'null' ? value === null
71
+ : type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
72
+ : type === 'integer' ? Number.isSafeInteger(value)
73
+ : type === 'number' ? typeof value === 'number' && Number.isFinite(value)
74
+ : typeof value === type);
75
+ }
76
+
71
77
  function accepts(value, schema, field) {
72
78
  try { validateValue(value, schema, field); return true; }
73
79
  catch (error) { if (!(error instanceof CommandError)) throw error; return false; }
@@ -82,22 +88,48 @@ function validateUnion(value, branches, field, exactlyOne) {
82
88
  }
83
89
  if (matched && (!exactlyOne || matched === 1)) return;
84
90
  if (!matched) {
91
+ // A branch for another JSON type cannot explain this value, e.g. the null
92
+ // branch of a nullable target never explains an object missing platform.
93
+ const explained = failures.filter(({ branch }) => matchesType(value, branch.type));
94
+ if (!explained.length) {
95
+ throw invalid(field, `${field || 'arguments'} must be ${[...new Set(failures.map(({ branch }) => branch.type))].join(' or ')}; no implicit type conversion is performed.`);
96
+ }
85
97
  // A tagged branch gives a useful precise error instead of a generic union
86
98
  // failure, e.g. decision.action.durationMs for an observed long press.
87
- const tagged = failures.filter(({ branch }) => {
99
+ const tagged = explained.filter(({ branch }) => {
88
100
  const tags = Object.entries(branch.properties || {}).filter(([, rule]) => Object.hasOwn(rule, 'const'));
89
101
  return tags.some(([key, rule]) => value?.[key] === rule.const)
90
102
  && tags.every(([key, rule]) => Object.hasOwn(value || {}, key) ? value[key] === rule.const : !branch.required?.includes(key));
91
103
  });
92
104
  if (tagged.length === 1) throw tagged[0].error;
93
- const candidates = tagged.length ? tagged : failures;
105
+ const candidates = tagged.length ? tagged : explained;
94
106
  if (candidates.every(({ error }) => error.code === candidates[0].error.code && error.field === candidates[0].error.field && error.message === candidates[0].error.message)) throw candidates[0].error;
107
+ // No branch accepts every supplied tag: name the tag the intended variants
108
+ // reject and its accepted values instead of listing every variant.
109
+ const rejected = tagged.length ? null : rejectedDiscriminator(value || {}, explained.map(({ branch }) => branch));
110
+ if (rejected) {
111
+ const child = fieldAt(field, rejected.key);
112
+ throw invalid(child, `${child} must be one of: ${rejected.values.map(item => JSON.stringify(item)).join(', ')}.`);
113
+ }
95
114
  if (branches.length === 1) throw failures[0].error;
96
115
  }
97
116
  throw new CommandError('invalid_argument', `${field || 'arguments'} must match ${exactlyOne ? 'exactly one' : 'one'} of the documented variants.`,
98
117
  { field: field || 'arguments', details: { variants: failures.map(({ error }) => ({ field: error.field, error: error.code, message: error.message })) } });
99
118
  }
100
119
 
120
+ // The branches agreeing with the most supplied tags are the intended variants,
121
+ // e.g. the two start variants for {operation:"start", mode:"wrong"}. A supplied
122
+ // tag they all reject is the field to correct; its accepted values are theirs.
123
+ function rejectedDiscriminator(value, branches) {
124
+ const suppliedTags = branch => Object.entries(branch.properties || {}).filter(([key, rule]) => Object.hasOwn(rule, 'const') && Object.hasOwn(value, key));
125
+ const agreement = branch => suppliedTags(branch).filter(([key, rule]) => value[key] === rule.const).length;
126
+ const best = Math.max(...branches.map(agreement));
127
+ const closest = branches.filter(branch => agreement(branch) === best);
128
+ const key = suppliedTags(closest[0]).map(([name]) => name)
129
+ .find(name => closest.every(branch => Object.hasOwn(branch.properties[name] || {}, 'const') && value[name] !== branch.properties[name].const));
130
+ return key === undefined ? null : { key, values: [...new Set(closest.map(branch => branch.properties[key].const))] };
131
+ }
132
+
101
133
  function validateJson(value, field = '', ancestors = new Set(), depth = 0) {
102
134
  if (depth > 64) throw invalid(field, 'JSON nesting must not exceed 64 levels.');
103
135
  if (value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) return;
@@ -3,7 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const crypto = require('node:crypto');
6
- const { checksumOf, canonicalJson, validateArchivedRecord, verifyChecksum } = require('./evidence-schema');
6
+ const { checksumOf, canonicalJson, validateArchivedRecord, verifyChecksum, NAMESPACES } = require('./evidence-schema');
7
7
  const { analyzeRecordedPayloads } = require('./recorded-payload-archive');
8
8
  const { readRegularFile: readRecordedFile } = require('./evidence-recording');
9
9
 
@@ -23,7 +23,7 @@ function textArgument(value, field) {
23
23
  }
24
24
 
25
25
  function namespaceArgument(namespace) {
26
- requireValue(namespace === 'intent' || namespace === 'script', 'invalid_argument', 'namespace');
26
+ requireValue(NAMESPACES.includes(namespace), 'invalid_argument', 'namespace');
27
27
  }
28
28
 
29
29
  async function handle(args = {}, { getFactStore } = {}) {
@@ -3,7 +3,7 @@
3
3
  const crypto = require('node:crypto');
4
4
  const { sanitizePersistentValue } = require('../fact-codec');
5
5
 
6
- const NAMESPACES = Object.freeze(['script', 'intent']);
6
+ const NAMESPACES = Object.freeze(['script', 'intent', 'response']);
7
7
  const PROVIDERS = Object.freeze(['native', 'uia', 'flutter', 'h5']);
8
8
  const RECORD_SCHEMA = 'aab.execution-evidence/v1';
9
9
  const AGENT_DECISIONS = Object.freeze(['act', 'complete', 'fail', 'inconclusive']);
@@ -17,6 +17,7 @@ const KINDS = Object.freeze([
17
17
  'checkpoint',
18
18
  'result',
19
19
  'attachment',
20
+ 'response',
20
21
  ]);
21
22
 
22
23
  function checksumOf(value) {
@@ -57,6 +58,7 @@ function verifyChecksum(record) {
57
58
  }
58
59
 
59
60
  function requiredFields(kind) {
61
+ if (kind === 'response') return ['operationId', 'revision', 'snapshotBase64'];
60
62
  if (kind === 'observation') {
61
63
  return ['operationId', 'revision', 'target', 'provider', 'capturedAtMs'];
62
64
  }
@@ -97,6 +99,7 @@ function validateRecord(namespace, kind, record, { archivedUnversioned = false }
97
99
  if (!record || typeof record !== 'object') {
98
100
  return { ok: false, error: 'invalid_record' };
99
101
  }
102
+ if ((namespace === 'response') !== (kind === 'response')) return { ok: false, error: 'invalid_kind' };
100
103
  if (!archivedUnversioned && record.schemaVersion !== undefined && record.schemaVersion !== RECORD_SCHEMA) {
101
104
  return { ok: false, error: 'unsupported_record_schema', field: 'schemaVersion' };
102
105
  }
@@ -107,6 +110,18 @@ function validateRecord(namespace, kind, record, { archivedUnversioned = false }
107
110
  return { ok: false, error: 'invalid_record', field };
108
111
  }
109
112
  }
113
+ if (kind === 'response') {
114
+ const encoded = record.snapshotBase64;
115
+ if (typeof encoded !== 'string' || Buffer.from(encoded, 'base64').toString('base64') !== encoded
116
+ || record.revision !== 1) return { ok: false, error: 'invalid_record', field: 'snapshotBase64' };
117
+ try {
118
+ const snapshot = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
119
+ if (!['json', 'text'].includes(snapshot.kind) || !Object.hasOwn(snapshot, 'value')
120
+ || !snapshot.execution || !snapshot.control || snapshot.control.source !== undefined
121
+ || snapshot.identity?.responseId !== record.operationId || typeof snapshot.identity.command !== 'string'
122
+ || !Number.isSafeInteger(snapshot.identity.capturedAtMs)) throw new Error('invalid_snapshot');
123
+ } catch { return { ok: false, error: 'invalid_record', field: 'snapshotBase64' }; }
124
+ }
110
125
  if (kind === 'result' && (namespace !== 'script' || !Object.hasOwn(record, 'result')
111
126
  || !Number.isSafeInteger(record.bytes) || record.bytes < 1
112
127
  || !/^[a-f0-9]{64}$/.test(record.sha256) || !/^[a-f0-9]{64}$/.test(record.originalSha256)
@@ -1,10 +1,10 @@
1
1
  'use strict';
2
2
 
3
- const { buildEnvelope, verifyChecksum } = require('./evidence-schema');
3
+ const { buildEnvelope, verifyChecksum, NAMESPACES } = require('./evidence-schema');
4
4
 
5
5
  function createEvidenceStore({ namespace, adapter, now = Date.now, maxBytes = 8 * 1024 * 1024 } = {}) {
6
- if (namespace !== 'script' && namespace !== 'intent') {
7
- throw new TypeError('namespace must be script or intent');
6
+ if (!NAMESPACES.includes(namespace)) {
7
+ throw new TypeError('namespace must be script, intent or response');
8
8
  }
9
9
  if (!adapter || typeof adapter.record !== 'function' || typeof adapter.readById !== 'function') {
10
10
  throw new TypeError('adapter with record/readById is required');
@@ -133,8 +133,15 @@ function scriptSpecSchema(permissionNames) {
133
133
  }
134
134
 
135
135
  function executionCommandSchema(command, permissionNames) {
136
- const read = { afterSequence, limit: pageLimit };
137
- const scriptRead = { ...read, eventLimit: pageLimit, includeCatalog: boolean };
136
+ // Script and Intent page with different cursors; the schema states which one.
137
+ const historyRead = {
138
+ afterSequence: { ...afterSequence, description: 'History page cursor: the previous response\'s history.lastSequence. Not an eventSequence.' },
139
+ limit: { ...pageLimit, description: 'History entries per page; it bounds entries, not response bytes.' },
140
+ };
141
+ const scriptRead = {
142
+ afterSequence: { ...afterSequence, description: 'Continuation cursor: the previous status/wait response\'s eventSequence; events and history after it are returned. Not history.lastSequence.' },
143
+ limit: pageLimit, eventLimit: pageLimit, includeCatalog: boolean,
144
+ };
138
145
  const operation = (name, properties, required = []) => object({ operation: { const: name }, ...properties }, ['operation', ...required]);
139
146
  const control = (name, properties = {}) => operation(name, { operationId: text, ...properties }, ['operationId']);
140
147
  if (command === 'script') return variants('operation', [
@@ -144,7 +151,8 @@ function executionCommandSchema(command, permissionNames) {
144
151
  { if: { required: ['recordingDir'] }, then: { properties: { script: { properties: { policy: { properties: { restartPolicy: { const: 'none' } } } } } } } },
145
152
  ] },
146
153
  control('result'),
147
- control('status', scriptRead), control('wait', { ...scriptRead, waitMs: { ...integer(0, 60000), default: 30000 } }),
154
+ control('status', scriptRead), control('wait', { ...scriptRead, waitMs: { ...integer(0, 60000), default: 30000,
155
+ description: 'Upper bound of one wait call in milliseconds. To keep waiting, call wait again with the same operationId and afterSequence set to the last eventSequence.' } }),
148
156
  ...['pause', 'resume', 'cancel'].map(name => control(name, scriptRead)),
149
157
  operation('decide', { operationId: text, requestId: text, revision, decision: { contentMediaType: 'application/json', description: 'JSON answer to the current ctx.askAgent request; business data is preserved.' }, ...scriptRead }, ['operationId', 'requestId', 'revision', 'decision']),
150
158
  operation('runtime-status', {}),
@@ -156,7 +164,7 @@ function executionCommandSchema(command, permissionNames) {
156
164
  return variants('operation', [
157
165
  operation('start', { ...start, mode: { const: 'supervised', default: 'supervised' } }, ['goal', 'target']),
158
166
  operation('start', { ...start, mode: { const: 'autonomous' }, agentModule: text, budget: intentBudget }, ['goal', 'target', 'mode', 'agentModule']),
159
- control('status', read), control('observe', { basedOnRevision: revision, observationTarget: observationTargetSchema,
167
+ control('status', historyRead), control('observe', { basedOnRevision: revision, observationTarget: observationTargetSchema,
160
168
  provider: { ...provider, description: 'Observe through this provider on the frozen target. Becomes selected only after the new observation and summary are committed; omitted retains the last committed selection.' } }),
161
169
  operation('decide', { operationId: text, decision: intentDecisionSchema() }, ['operationId', 'decision']),
162
170
  ...['pause', 'resume', 'cancel'].map(name => control(name)),
@@ -164,7 +172,7 @@ function executionCommandSchema(command, permissionNames) {
164
172
  ]);
165
173
  }
166
174
  if (command === 'evidence') return variants('operation', [
167
- operation('export', { namespace: { enum: ['intent', 'script'] }, operationId: text, outputDir: text, includeRecordedPayloads: boolean }, ['namespace', 'operationId', 'outputDir']),
175
+ operation('export', { namespace: { enum: ['intent', 'script', 'response'] }, operationId: text, outputDir: text, includeRecordedPayloads: boolean }, ['namespace', 'operationId', 'outputDir']),
168
176
  operation('verify', { archiveDir: text, manifestSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' } }, ['archiveDir', 'manifestSha256']),
169
177
  ]);
170
178
  throw new Error(`No execution contract for ${command}`);