@evomap/evolver-adapter-public 2.0.0-beta.9 → 2.0.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.
@@ -1,15 +1,17 @@
1
- import { bootstrap, hub as hubNs } from '@evomap/evolver-core';
1
+ import { createHash } from 'node:crypto';
2
+ import { bootstrap, hub as hubNs, signals } from '@evomap/evolver-core';
2
3
  import { AuthError, HubFetch, HubClientError, isHubUnreachableError } from './hubFetch.js';
3
4
  import { isNodeSecret, parseNodeSecretVersion } from './auth/legacyShim.js';
4
- import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire } from './wireMap.js';
5
+ import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire, searchQueryToSearchOnlyWire, } from './wireMap.js';
5
6
  import { antiAbuseTelemetryMode, buildHeartbeatAntiAbuseTelemetry, } from './antiAbuseTelemetry.js';
6
7
  import { getWorkspaceKeychainMode } from './auth/workspaceKeychain.js';
7
8
  import { agentDirectoryFailure, parsePublicAgentPage, parsePublicAgentProfile, paginatePublicAgentPage, mergePublicAgentPages, publicAgentSearchQuery, publicTaskDiscoveryQuery, PUBLIC_TASK_DISCOVERY_MAX_CANDIDATES, unsupportedPublicAvailability, unsupportedPublicSort, withDirectoryTimeout, } from './agentDirectory.js';
9
+ import { assetMatchesId } from './hubReuse.js';
8
10
  export const INBOUND_LIMIT = 100;
9
11
  export const OUTBOUND_MAX_BATCH = 50;
10
12
  export const OUTBOUND_MAX_BODY_BYTES = 4 * 1024 * 1024;
11
13
  export const PUBLIC_PROTOCOL_VERSION = 'gep-a2a/1.0.0';
12
- export const PUBLIC_HUB_CAPABILITIES = ['publish', 'fetch', 'search', 'task', 'mailbox', 'auth', 'marketplace', 'economy', 'questions', 'recipes', 'agent_directory'];
14
+ export const PUBLIC_HUB_CAPABILITIES = ['publish', 'fetch', 'search', 'task', 'mailbox', 'auth', 'marketplace', 'economy', 'questions', 'recipes', 'agent_directory', 'learning_assets'];
13
15
  const QUESTION_SUBMIT_FAST_PATH_BYPASS_CONTENT_HASH = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
14
16
  const DRY_RUN_RECIPE_ID = 'dry-run-recipe';
15
17
  const HUB_DRY_RUN_VALUES = new Set(['1', 'true', 'yes', 'on']);
@@ -18,14 +20,23 @@ const HUB_DRY_RUN_VALUES = new Set(['1', 'true', 'yes', 'on']);
18
20
  // equals what the hub will KEEP.
19
21
  export const USED_ASSET_IDS_MAX = 50;
20
22
  export const USED_ASSET_ID_MAX_LEN = 200;
23
+ export const LEARNING_ASSET_IDS_MAX = 50;
24
+ export const LEARNING_ASSET_ID_MAX_LEN = 128;
21
25
  /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
22
- export function gepEnvelope(messageType, payload) {
26
+ export function gepEnvelope(messageType, payload, options = {}) {
23
27
  return {
24
28
  protocol: 'gep-a2a', protocol_version: '1.0.0', message_type: messageType,
25
- message_id: `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
29
+ message_id: options.messageId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
26
30
  timestamp: new Date().toISOString(), payload,
27
31
  };
28
32
  }
33
+ function stablePublishMessageId(idempotencyKey) {
34
+ const digest = createHash('sha256')
35
+ .update(idempotencyKey.trim())
36
+ .digest('hex')
37
+ .slice(0, 40);
38
+ return `msg_idem_${digest}`;
39
+ }
29
40
  // v1 a2aProtocol.js L1999-2003: the three app-level rejection reasons that mean
30
41
  // our cached node_secret has DIVERGED from the hub's record (hub-side reset,
31
42
  // restored-from-backup machine, manual unlink) — not a transport/generic failure.
@@ -83,7 +94,7 @@ export class PublicHubCapability {
83
94
  auth;
84
95
  recipes = {
85
96
  create: async (request) => this.createRecipe(request),
86
- publish: async (recipeId) => this.publishRecipe(recipeId),
97
+ publish: async (recipeId, options) => this.publishRecipe(recipeId, options),
87
98
  get: async (recipeId) => this.getRecipe(recipeId),
88
99
  express: async (recipeId, request = {}) => this.expressRecipe(recipeId, request),
89
100
  };
@@ -92,9 +103,27 @@ export class PublicHubCapability {
92
103
  this.auth = opts.auth;
93
104
  this.http = new HubFetch({ baseUrl: opts.baseUrl, auth: opts.auth, fetchFn: opts.fetchFn, senderId: opts.senderId });
94
105
  }
106
+ evolverVersionForWire(explicitVersion) {
107
+ const antiAbuse = this.opts.antiAbuse;
108
+ return bootstrap.normalizeEvolverVersion(explicitVersion !== undefined
109
+ ? explicitVersion
110
+ : antiAbuse?.evolverVersion ?? antiAbuse?.envFingerprint?.evolver_version);
111
+ }
112
+ envFingerprintForWire(evolverVersion) {
113
+ const fingerprint = {
114
+ ...(this.opts.antiAbuse?.envFingerprint
115
+ ?? bootstrap.captureEnvFingerprint({ env: this.opts.antiAbuse?.env ?? process.env })),
116
+ };
117
+ if (evolverVersion)
118
+ fingerprint.evolver_version = evolverVersion;
119
+ else
120
+ delete fingerprint.evolver_version;
121
+ return fingerprint;
122
+ }
95
123
  async hello(opts) {
96
124
  try {
97
125
  const sender = this.opts.senderId();
126
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
98
127
  const body = await this.http.call('POST', '/a2a/hello', gepEnvelope('hello', {
99
128
  rotate_secret: opts.rotate,
100
129
  capabilities: { supported_types: ['publish', 'fetch', 'mailbox', 'questions'] },
@@ -102,12 +131,12 @@ export class PublicHubCapability {
102
131
  status: 'active',
103
132
  timestamp: new Date().toISOString(),
104
133
  ...(sender ? { node_id: sender } : {}),
105
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
134
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
106
135
  // v1 parity (a2aProtocol.js buildHello): every hello carries the env fingerprint — it is how the
107
136
  // hub builds node/IP trust for its anti-abuse layer. v2 had moved it to heartbeat-only meta, which
108
137
  // one-shot CLI paths never send; the hub then answers heartbeats with resend_hello
109
138
  // `missing_env_fingerprint` and 403-antibodies /a2a/fetch (#555).
110
- env_fingerprint: bootstrap.captureEnvFingerprint({ env: this.opts.antiAbuse?.env ?? process.env }),
139
+ env_fingerprint: this.envFingerprintForWire(evolverVersion),
111
140
  }));
112
141
  const payload = asRecord(body['payload']) ?? body;
113
142
  const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
@@ -141,6 +170,8 @@ export class PublicHubCapability {
141
170
  ?? this.opts.senderId();
142
171
  const nodeSecret = stringField(payload, 'node_secret') ?? stringField(payload, 'nodeSecret');
143
172
  const nodeSecretVersion = parseNodeSecretVersion(payload['node_secret_version'] ?? payload['nodeSecretVersion']);
173
+ const claimCode = stringField(payload, 'claim_code') ?? stringField(payload, 'claimCode');
174
+ const claimUrl = stringField(payload, 'claim_url') ?? stringField(payload, 'claimUrl');
144
175
  if (!opts.preserveCredentials) {
145
176
  if (nodeSecret && isNodeSecret(nodeSecret)) {
146
177
  this.auth.adoptNodeSecret?.(nodeSecret, nodeSecretVersion);
@@ -152,6 +183,8 @@ export class PublicHubCapability {
152
183
  return {
153
184
  ok: payload['ok'] !== false && Boolean(nodeId),
154
185
  ...(nodeId ? { nodeId } : {}),
186
+ ...(claimCode ? { claimCode } : {}),
187
+ ...(claimUrl ? { claimUrl } : {}),
155
188
  ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
156
189
  ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
157
190
  ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
@@ -170,12 +203,13 @@ export class PublicHubCapability {
170
203
  async heartbeat(opts = {}) {
171
204
  try {
172
205
  const nodeSecretVersion = this.auth.getNodeSecretVersion?.();
173
- const meta = this.heartbeatMeta(opts, nodeSecretVersion);
206
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
207
+ const meta = this.heartbeatMeta(evolverVersion, nodeSecretVersion);
174
208
  const body = await this.http.call('POST', '/a2a/heartbeat', {
175
209
  ...(this.opts.senderId() ? { node_id: this.opts.senderId() } : {}),
176
210
  timestamp: new Date().toISOString(),
177
211
  status: 'active',
178
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
212
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
179
213
  ...(opts.lastUpdate ? { last_update: opts.lastUpdate } : {}),
180
214
  ...(nodeSecretVersion !== undefined ? { node_secret_version: nodeSecretVersion } : {}),
181
215
  ...(meta ? { meta } : {}),
@@ -192,7 +226,7 @@ export class PublicHubCapability {
192
226
  throw err;
193
227
  }
194
228
  }
195
- heartbeatMeta(opts, nodeSecretVersion) {
229
+ heartbeatMeta(evolverVersion, nodeSecretVersion) {
196
230
  const meta = {};
197
231
  if (nodeSecretVersion !== undefined)
198
232
  meta['node_secret_version'] = nodeSecretVersion;
@@ -202,7 +236,7 @@ export class PublicHubCapability {
202
236
  meta['anti_abuse'] = buildHeartbeatAntiAbuseTelemetry({
203
237
  ...antiAbuse,
204
238
  nodeId: this.opts.senderId(),
205
- evolverVersion: opts.evolverVersion,
239
+ evolverVersion,
206
240
  });
207
241
  }
208
242
  catch (err) {
@@ -213,15 +247,29 @@ export class PublicHubCapability {
213
247
  }
214
248
  return Object.keys(meta).length > 0 ? meta : undefined;
215
249
  }
216
- async publish(bundle) {
250
+ async publish(bundle, options = {}) {
251
+ const normalizedIdempotencyKey = options.idempotencyKey?.trim();
252
+ if (options.idempotencyKey !== undefined && !normalizedIdempotencyKey) {
253
+ return {
254
+ receiptId: 'local_invalid_idempotency_key',
255
+ status: 'rejected',
256
+ terminal: true,
257
+ reason: 'publish idempotency key must not be blank',
258
+ };
259
+ }
217
260
  try {
218
261
  // 公版 /a2a/publish 收 payload.assets=[Gene,Capsule,(Event)] 捆绑(实测 dev).
219
- const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }));
262
+ const idempotencyKey = normalizedIdempotencyKey;
263
+ const messageId = idempotencyKey !== undefined
264
+ ? stablePublishMessageId(idempotencyKey)
265
+ : undefined;
266
+ const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }, messageId ? { messageId } : {}));
220
267
  return publishRespToReceipt(200, body);
221
268
  }
222
269
  catch (e) {
223
- if (e instanceof HubClientError)
224
- return publishRespToReceipt(e.status, e.body ?? {});
270
+ if (e instanceof HubClientError) {
271
+ return publishRespToReceipt(e.status, e.body ?? {}, e.retryAfterMs);
272
+ }
225
273
  throw e; // 5xx/网络 → 重试
226
274
  }
227
275
  }
@@ -230,29 +278,35 @@ export class PublicHubCapability {
230
278
  // /a2a/fetch responses are FULL GEP envelopes (buildResponse('fetch', …)); the rows live at payload.results,
231
279
  // NOT at the top level. Reading body.results here always yielded [] — every fetch silently returned nothing.
232
280
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToFetchWire(query)));
233
- return (body.payload?.results ?? []);
281
+ return assetsFromBody(body);
234
282
  }
235
283
  async fetchAssetById(assetId) {
236
284
  const id = assetId.trim();
237
285
  if (!id)
238
286
  return null;
239
287
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
240
- return assetsFromBody(body).find((asset) => assetMatchesId(asset, id)) ?? null;
288
+ return assetsFromBody(body).find((asset) => fetchResultMatchesId(asset, id)) ?? null;
241
289
  }
242
290
  /**
243
291
  * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
244
- * signals/id queries fall through to fetch. /a2a/fetch does NOT do semantic, so text must not go there.
292
+ * signal/id queries use the Hub's free search-only phase on /a2a/fetch. /a2a/fetch does NOT do semantic,
293
+ * so text must not go there and paid/full fetch must remain an explicit follow-up.
245
294
  */
246
295
  async search(query) {
247
296
  if (query.text && query.text.trim()) {
248
297
  // GET /a2a/assets/semantic-search returns a FLAT object keyed `assets` (no GEP envelope), plus a
249
- // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Reading body.results
250
- // here always yielded [] the semantic path could never return a hit. (Status not surfaced yet: the
251
- // HubCapability.search contract is AssetRecord[]; honoring search_status needs an interface change — later.)
252
- const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, { q: query.text, ...(query.limit !== undefined ? { limit: query.limit } : {}) });
253
- return (body.assets ?? []);
298
+ // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Only an explicit
299
+ // no_match is a verified empty result; degraded or malformed 200 responses must not trigger ATP spend.
300
+ const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, {
301
+ q: query.text,
302
+ ...(query.kind !== undefined ? { type: query.kind } : {}),
303
+ ...(query.domain !== undefined ? { domain: query.domain } : {}),
304
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
305
+ });
306
+ return semanticSearchAssets(body);
254
307
  }
255
- return this.fetch(query);
308
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToSearchOnlyWire(query)));
309
+ return signalSearchAssets(body);
256
310
  }
257
311
  agentDirectory = {
258
312
  search: async (request) => {
@@ -402,6 +456,52 @@ export class PublicHubCapability {
402
456
  return { recorded: false, reason: e instanceof Error ? e.message : String(e) };
403
457
  }
404
458
  }
459
+ async listLearningAssets(options = {}) {
460
+ const limit = normalizeLearningAssetLimit(options.limit);
461
+ if (!this.opts.senderId()?.trim())
462
+ return { assets: [], limit, reason: 'sender_id_required' };
463
+ try {
464
+ const body = await this.http.call('GET', '/a2a/learning-assets', undefined, learningAssetListQuery(options, limit));
465
+ const payload = asRecord(body['payload']) ?? body;
466
+ return {
467
+ assets: learningAssetsFromPayload(payload),
468
+ limit: numberField(payload, 'limit') ?? limit,
469
+ };
470
+ }
471
+ catch (e) {
472
+ return { assets: [], limit, reason: failureReason(e) };
473
+ }
474
+ }
475
+ async recordLearningAssetUsage(report) {
476
+ if (!this.opts.senderId()?.trim())
477
+ return { recorded: false, reason: 'sender_id_required', results: [] };
478
+ const sourceEventId = trimStringField(report.sourceEventId, 160);
479
+ if (!sourceEventId)
480
+ return { recorded: false, reason: 'source_event_id_required', results: [] };
481
+ const assetIds = normalizeLearningAssetIds(report.usedAssetIds && report.usedAssetIds.length > 0 ? report.usedAssetIds : [report.assetId]);
482
+ if (assetIds.length === 0)
483
+ return { recorded: false, reason: 'asset_id_required', results: [] };
484
+ const outcome = normalizeLearningAssetOutcome(report.outcome);
485
+ if (!outcome)
486
+ return { recorded: false, reason: 'invalid_learning_asset_outcome', results: [] };
487
+ const score = optionalLearningAssetScore(report.score);
488
+ if ('reason' in score)
489
+ return { recorded: false, reason: score.reason, results: [] };
490
+ const reason = trimStringField(report.reason, 2_000);
491
+ try {
492
+ const body = await this.http.call('POST', '/a2a/learning-assets/usage', {
493
+ ...(assetIds.length === 1 ? { asset_id: assetIds[0] } : { used_asset_ids: assetIds }),
494
+ outcome,
495
+ source_event_id: sourceEventId,
496
+ ...(score.value !== undefined ? { score: score.value } : {}),
497
+ ...(reason ? { reason } : {}),
498
+ });
499
+ return learningAssetUsageReceiptFromBody(body);
500
+ }
501
+ catch (e) {
502
+ return { recorded: false, reason: failureReason(e), results: [] };
503
+ }
504
+ }
405
505
  /**
406
506
  * Pre-publish dry-run (POST /a2a/validate). The hub runs the same hub-side quality +
407
507
  * content-safety gate as publish but stores nothing and charges no credits. This adapter is
@@ -457,14 +557,14 @@ export class PublicHubCapability {
457
557
  ...(request.pricePerExecution !== undefined ? { price_per_execution: request.pricePerExecution } : {}),
458
558
  ...(request.currency ? { currency: request.currency } : {}),
459
559
  ...(request.maxConcurrent !== undefined ? { max_concurrent: request.maxConcurrent } : {}),
460
- });
560
+ }, undefined, request.idempotencyKey ? { 'idempotency-key': request.idempotencyKey } : undefined);
461
561
  return recipeReceiptFromBody(body);
462
562
  }
463
- async publishRecipe(recipeId) {
563
+ async publishRecipe(recipeId, options) {
464
564
  if (isHubDryRunEnabled())
465
565
  return dryRunRecipeReceipt('publish_recipe', recipeId);
466
566
  const sender = this.opts.senderId();
467
- const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/publish`, { ...(sender ? { node_id: sender } : {}) });
567
+ const body = await this.http.call('POST', `/a2a/recipe/${encodeURIComponent(recipeId)}/publish`, { ...(sender ? { node_id: sender } : {}) }, undefined, options?.idempotencyKey ? { 'idempotency-key': options.idempotencyKey } : undefined);
468
568
  return recipeReceiptFromBody(body);
469
569
  }
470
570
  async getRecipe(recipeId) {
@@ -555,7 +655,10 @@ export class PublicHubCapability {
555
655
  const body = await this.http.call('POST', '/a2a/events/poll', gepEnvelope('events_poll', { timeout_ms: 1000 }));
556
656
  for (const e of body.events ?? []) {
557
657
  if (String(e['type']).startsWith('task_')) {
558
- yield { taskId: String(e['payload']?.taskId ?? e['id']), type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
658
+ const payload = asRecord(e['payload']);
659
+ const wireTaskId = payload?.['task_id'] ?? payload?.['taskId'];
660
+ const taskId = typeof wireTaskId === 'string' && wireTaskId.length > 0 ? wireTaskId : String(e['id']);
661
+ yield { taskId, type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
559
662
  }
560
663
  }
561
664
  }
@@ -655,6 +758,44 @@ export class PublicHubCapability {
655
758
  function asRecord(value) {
656
759
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
657
760
  }
761
+ function searchAssets(value, source) {
762
+ if (!Array.isArray(value))
763
+ throw new Error(`${source}_results_invalid`);
764
+ return value.map((candidate) => {
765
+ const record = asRecord(candidate);
766
+ if (!record)
767
+ throw new Error(`${source}_asset_invalid`);
768
+ const asset = unwrapFetchDeliveryRow(record);
769
+ const assetId = stringField(asset, 'asset_id') ?? stringField(asset, 'assetId');
770
+ if (!assetId)
771
+ throw new Error(`${source}_asset_invalid`);
772
+ return asset;
773
+ });
774
+ }
775
+ function semanticSearchAssets(body) {
776
+ const status = stringField(body, 'search_status');
777
+ if (status === 'degraded' || body['retryable'] === true)
778
+ throw new Error('semantic_search_degraded');
779
+ const assets = searchAssets(body['assets'], 'semantic_search');
780
+ if (status === 'no_match') {
781
+ if (assets.length !== 0)
782
+ throw new Error('semantic_search_status_invalid');
783
+ return assets;
784
+ }
785
+ if (status === 'found' || status === 'low_confidence_only') {
786
+ if (assets.length === 0)
787
+ throw new Error('semantic_search_status_invalid');
788
+ return assets;
789
+ }
790
+ // Older successful Hub responses are usable only when they carry a concrete candidate.
791
+ if (status === undefined && assets.length > 0)
792
+ return assets;
793
+ throw new Error('semantic_search_status_invalid');
794
+ }
795
+ function signalSearchAssets(body) {
796
+ const payload = asRecord(body['payload']);
797
+ return searchAssets(payload?.['results'], 'signal_search');
798
+ }
658
799
  function recipeStepToWire(step) {
659
800
  return {
660
801
  asset_id: step.assetId,
@@ -761,12 +902,115 @@ function accountAssetsFromPayload(payload) {
761
902
  for (const candidate of candidates) {
762
903
  if (!Array.isArray(candidate))
763
904
  continue;
764
- return candidate.filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)));
905
+ return candidate
906
+ .filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)))
907
+ .map(unwrapFetchDeliveryRow);
908
+ }
909
+ return [];
910
+ }
911
+ function learningAssetsFromPayload(payload) {
912
+ const candidates = [
913
+ payload['assets'],
914
+ payload['results'],
915
+ payload['items'],
916
+ ];
917
+ for (const candidate of candidates) {
918
+ if (!Array.isArray(candidate))
919
+ continue;
920
+ return candidate.filter((asset) => isLearningAssetRecord(asset));
765
921
  }
766
922
  return [];
767
923
  }
768
- function assetMatchesId(asset, assetId) {
769
- return Boolean(asset && (asset.asset_id === assetId || stringField(asset, 'id') === assetId));
924
+ function isLearningAssetRecord(value) {
925
+ const record = asRecord(value);
926
+ return Boolean(record && typeof record['asset_id'] === 'string' && typeof record['type'] === 'string');
927
+ }
928
+ function normalizeLearningAssetLimit(value) {
929
+ if (!Number.isFinite(value))
930
+ return 20;
931
+ return Math.min(100, Math.max(1, Math.floor(value)));
932
+ }
933
+ function learningAssetListQuery(options, limit) {
934
+ const status = normalizeLearningAssetStatusParam(options.status);
935
+ const query = {
936
+ limit,
937
+ runtime: options.includeExpired === true ? undefined : 'true',
938
+ include_expired: options.includeExpired === true ? 'true' : undefined,
939
+ include_payload: options.includePayload === true ? 'true' : undefined,
940
+ ...(options.type ? { type: options.type } : {}),
941
+ ...(status ? { status } : options.includeExpired === true ? { status: 'active' } : {}),
942
+ };
943
+ const scope = compactLearningAssetParam(options.scope);
944
+ if (scope)
945
+ query['scope'] = scope;
946
+ return query;
947
+ }
948
+ function normalizeLearningAssetStatusParam(status) {
949
+ if (Array.isArray(status))
950
+ return compactLearningAssetParam(status);
951
+ return typeof status === 'string' && status.trim() ? status.trim() : undefined;
952
+ }
953
+ function compactLearningAssetParam(values) {
954
+ if (!values)
955
+ return undefined;
956
+ const out = [...new Set(values.map((value) => String(value).trim()).filter(Boolean))];
957
+ return out.length > 0 ? out.join(',') : undefined;
958
+ }
959
+ function trimStringField(value, maxLen) {
960
+ return typeof value === 'string' && value.trim() ? value.trim().slice(0, maxLen) : undefined;
961
+ }
962
+ function normalizeLearningAssetIds(values) {
963
+ const out = [];
964
+ const seen = new Set();
965
+ for (const value of values) {
966
+ if (typeof value !== 'string')
967
+ continue;
968
+ const trimmed = value.trim();
969
+ if (!trimmed || trimmed.length > LEARNING_ASSET_ID_MAX_LEN || seen.has(trimmed))
970
+ continue;
971
+ seen.add(trimmed);
972
+ out.push(trimmed);
973
+ if (out.length >= LEARNING_ASSET_IDS_MAX)
974
+ break;
975
+ }
976
+ return out;
977
+ }
978
+ function normalizeLearningAssetOutcome(value) {
979
+ if (value === 'success' || value === 'failed' || value === 'mismatched' || value === 'stale' || value === 'unsafe')
980
+ return value;
981
+ return undefined;
982
+ }
983
+ function optionalLearningAssetScore(value) {
984
+ if (value === undefined)
985
+ return { value: undefined };
986
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1)
987
+ return { reason: 'invalid_score' };
988
+ return { value };
989
+ }
990
+ function learningAssetUsageReceiptFromBody(body) {
991
+ const payload = asRecord(body['payload']) ?? body;
992
+ const rows = Array.isArray(payload['results'])
993
+ ? payload['results'].filter((row) => Boolean(asRecord(row)))
994
+ : [];
995
+ const reason = stringField(payload, 'reason') ?? stringField(payload, 'error');
996
+ return {
997
+ recorded: rows.length > 0 && rows.some((row) => row.recorded === true),
998
+ ...(reason ? { reason } : {}),
999
+ results: rows,
1000
+ };
1001
+ }
1002
+ function failureReason(error) {
1003
+ if (error instanceof HubClientError) {
1004
+ const body = asRecord(error.body) ?? {};
1005
+ const payload = asRecord(body['payload']) ?? body;
1006
+ return stringField(payload, 'reason') ?? stringField(payload, 'error') ?? `hub ${error.status}`;
1007
+ }
1008
+ return error instanceof Error ? error.message : String(error);
1009
+ }
1010
+ function fetchResultMatchesId(asset, requestedId) {
1011
+ if (assetMatchesId(asset, requestedId))
1012
+ return true;
1013
+ return !requestedId.startsWith('sha256:') && Boolean(asset && stringField(asset, 'id') === requestedId);
770
1014
  }
771
1015
  function stringField(value, key) {
772
1016
  return typeof value[key] === 'string' && value[key].length > 0 ? value[key] : undefined;
@@ -840,14 +1084,39 @@ function mailboxPushResultFromBody(body, events) {
840
1084
  if (results.length === 0) {
841
1085
  return { outcomes: events.map((event) => ({ id: event.id, status: 'accepted' })) };
842
1086
  }
1087
+ const resultIds = results.map(mailboxPushResultId);
1088
+ const hasCompletePositions = results.length === events.length;
843
1089
  return {
844
- outcomes: events.map((event, index) => mailboxPushOutcomeFromRow(event.id, results, index)),
1090
+ outcomes: events.map((event, index) => {
1091
+ const matches = results.filter((_, resultIndex) => resultIds[resultIndex] === event.id);
1092
+ if (matches.length === 1)
1093
+ return mailboxPushOutcomeFromRow(event.id, matches[0]);
1094
+ const positionalMatch = matches.length === 0
1095
+ && hasCompletePositions
1096
+ && resultIds[index] === undefined
1097
+ ? results[index]
1098
+ : undefined;
1099
+ return mailboxPushOutcomeFromRow(event.id, positionalMatch);
1100
+ }),
845
1101
  };
846
1102
  }
847
- function mailboxPushOutcomeFromRow(eventId, results, index) {
848
- const match = results.find((result) => String(result['id'] ?? result['message_id'] ?? '') === eventId) ?? results[index];
849
- if (!match)
850
- return { id: eventId, status: 'accepted' };
1103
+ function mailboxPushResultId(row) {
1104
+ const value = row['id'] ?? row['message_id'];
1105
+ if (typeof value !== 'string' && typeof value !== 'number')
1106
+ return undefined;
1107
+ const id = String(value);
1108
+ return id.length > 0 ? id : undefined;
1109
+ }
1110
+ function mailboxPushOutcomeFromRow(eventId, match) {
1111
+ if (!match) {
1112
+ return {
1113
+ id: eventId,
1114
+ status: 'failed',
1115
+ reason: 'mailbox_response_incomplete',
1116
+ retryable: true,
1117
+ terminal: false,
1118
+ };
1119
+ }
851
1120
  const reason = mailboxPushFailureReason(match);
852
1121
  if (!reason)
853
1122
  return { id: eventId, status: 'accepted', raw: match };
@@ -900,9 +1169,12 @@ function hubErrorStatus(err) {
900
1169
  }
901
1170
  return undefined;
902
1171
  }
903
- // Retry-After hint from a HubClientError JSON body (429 cooldown). Headers aren't
904
- // carried on HubClientError, so we read the body's retry_after_ms / retry_after.
1172
+ // Prefer the standardized Retry-After header carried by HubClientError, then
1173
+ // preserve the existing JSON body hints as a compatibility fallback.
905
1174
  function clientErrorRetryAfterMs(err) {
1175
+ if (err instanceof HubClientError && typeof err.retryAfterMs === 'number' && Number.isFinite(err.retryAfterMs)) {
1176
+ return err.retryAfterMs;
1177
+ }
906
1178
  const body = err instanceof HubClientError ? asRecord(err.body) : undefined;
907
1179
  if (!body)
908
1180
  return undefined;
@@ -1046,6 +1318,10 @@ function heartbeatResultFromBody(httpStatus, body) {
1046
1318
  const details = payload['details'] ?? body['details'];
1047
1319
  const ack = asRecord(payload['last_update_ack']);
1048
1320
  const forceUpdate = forceUpdateFromRecord(asRecord(payload['force_update']));
1321
+ const rawCapabilityGaps = payload['capability_gaps'] ?? payload['capabilityGaps'];
1322
+ const capabilityGaps = Array.isArray(rawCapabilityGaps)
1323
+ ? signals.normalizeCapabilityGaps(rawCapabilityGaps)
1324
+ : undefined;
1049
1325
  const ok = httpStatus >= 200
1050
1326
  && httpStatus < 300
1051
1327
  && payload['ok'] !== false
@@ -1063,6 +1339,7 @@ function heartbeatResultFromBody(httpStatus, body) {
1063
1339
  ...(typeof ack['reason'] === 'string' ? { reason: ack['reason'] } : {}),
1064
1340
  } } : {}),
1065
1341
  ...(forceUpdate ? { forceUpdate } : {}),
1342
+ ...(capabilityGaps !== undefined ? { capabilityGaps } : {}),
1066
1343
  };
1067
1344
  }
1068
1345
  function forceUpdateFromRecord(value) {