@twin3-ai/agent-id 0.3.51 → 0.3.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/browser-sdk.js CHANGED
@@ -16,7 +16,7 @@
16
16
  // browser script and shipped in the package byte-for-byte, so it can
17
17
  // neither read the package at runtime nor go through a build step:
18
18
  // the version is written once here and CI refuses a mismatch.
19
- var SDK_VERSION = "0.3.51";
19
+ var SDK_VERSION = "0.3.52";
20
20
  var w = window;
21
21
  var d = document;
22
22
  var s = d.currentScript || {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twin3-ai/agent-id",
3
- "version": "0.3.51",
3
+ "version": "0.3.52",
4
4
  "description": "Domain-bound AgentX portable alias, AEO evidence, and website-Agent integration SDK.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
package/site-agent.js CHANGED
@@ -249,27 +249,45 @@ function createSiteAgentClient(options = {}) {
249
249
  const maxAttempts = Math.max(1, Math.min(5, Number(options.maxAttempts || 3)));
250
250
  const retryBaseMs = Math.max(0, Number(options.retryBaseMs == null ? 250 : options.retryBaseMs));
251
251
  const sleep = options.sleep || defaultSleep;
252
+ const timeoutMs = Math.max(1, Number(options.timeoutMs == null ? 60000 : options.timeoutMs));
253
+ const callerSignal = options.signal;
252
254
  if (typeof fetcher !== "function") {
253
255
  throw siteAgentError("FETCH_UNAVAILABLE", "A fetch implementation is required to call the Site Agent API.");
254
256
  }
255
257
 
256
- async function fetchWithRetry(url, request) {
258
+ function isRedirect(res) {
259
+ return Boolean(res) && (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400));
260
+ }
261
+
262
+ async function fetchWithRetry(url, request, signal) {
257
263
  let lastError;
258
264
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
259
265
  try {
260
- const res = await fetcher(url, request);
266
+ const res = await fetcher(url, { ...request, redirect: "manual", signal });
267
+ if (isRedirect(res)) {
268
+ throw siteAgentError(
269
+ "SITE_AGENT_REDIRECT_REFUSED",
270
+ `xAgent ID answered with a redirect (HTTP ${res.status}); credentials are never re-sent to another location.`
271
+ );
272
+ }
261
273
  const retryableStateConflict = res && res.status === 409 && res.headers
262
274
  && typeof res.headers.get === "function"
263
275
  && res.headers.get("x-agent-id-retryable") === "state-conflict";
264
276
  const retryable = res && (res.status === 429 || res.status >= 500 || retryableStateConflict);
265
277
  if (!retryable || attempt >= maxAttempts) return res;
266
278
  if (typeof res.text === "function") await res.text();
267
- await sleep(retryDelayMs(res, attempt, retryBaseMs));
279
+ await sleep(retryDelayMs(res, attempt, retryBaseMs), signal);
268
280
  } catch (err) {
281
+ if (err && err.code === "SITE_AGENT_REDIRECT_REFUSED") throw err;
282
+ if (signal.aborted) break;
269
283
  lastError = err;
270
284
  if (attempt >= maxAttempts) break;
271
- await sleep(Math.min(30000, retryBaseMs * (2 ** Math.max(0, attempt - 1))));
285
+ await sleep(Math.min(30000, retryBaseMs * (2 ** Math.max(0, attempt - 1))), signal);
272
286
  }
287
+ if (signal.aborted) break;
288
+ }
289
+ if (signal.aborted) {
290
+ throw siteAgentError("SITE_AGENT_TIMEOUT", `Site Agent request did not finish within ${timeoutMs} ms`);
273
291
  }
274
292
  throw siteAgentError(
275
293
  "SITE_AGENT_NETWORK_FAILED",
@@ -277,6 +295,30 @@ function createSiteAgentClient(options = {}) {
277
295
  );
278
296
  }
279
297
 
298
+ async function requestJson(url, request) {
299
+ const controller = new AbortController();
300
+ const abort = () => controller.abort();
301
+ const timer = setTimeout(abort, timeoutMs);
302
+ if (callerSignal) {
303
+ if (callerSignal.aborted) abort();
304
+ else callerSignal.addEventListener("abort", abort, { once: true });
305
+ }
306
+ try {
307
+ const res = await fetchWithRetry(url, request, controller.signal);
308
+ try {
309
+ return { res, data: await readJsonResponse(res) };
310
+ } catch (err) {
311
+ if (controller.signal.aborted) {
312
+ throw siteAgentError("SITE_AGENT_TIMEOUT", `Site Agent request did not finish within ${timeoutMs} ms`);
313
+ }
314
+ throw err;
315
+ }
316
+ } finally {
317
+ clearTimeout(timer);
318
+ if (callerSignal) callerSignal.removeEventListener("abort", abort);
319
+ }
320
+ }
321
+
280
322
  async function call(action, payload = {}) {
281
323
  const key = payload.agent_key || payload.agentKey || agentKey;
282
324
  if (!key) {
@@ -291,14 +333,13 @@ function createSiteAgentClient(options = {}) {
291
333
  action
292
334
  };
293
335
  delete body.agentKey;
294
- const res = await fetchWithRetry(endpoint + SITE_AGENT_KEY_POLICY.privateEndpoint, {
336
+ const { res, data } = await requestJson(endpoint + SITE_AGENT_KEY_POLICY.privateEndpoint, {
295
337
  method: "POST",
296
338
  // Authorization is the canonical credential channel; the body field
297
339
  // stays for compatibility with servers that predate header auth.
298
340
  headers: { "content-type": "application/json", authorization: `Bearer ${key}` },
299
341
  body: JSON.stringify(body)
300
342
  });
301
- const data = await readJsonResponse(res);
302
343
  if (!res.ok || data.ok === false) {
303
344
  const error = siteAgentError(
304
345
  data.code || "SITE_AGENT_CALL_FAILED",
@@ -313,12 +354,11 @@ function createSiteAgentClient(options = {}) {
313
354
  }
314
355
 
315
356
  async function publicPost(path, payload = {}) {
316
- const res = await fetchWithRetry(endpoint + path, {
357
+ const { res, data } = await requestJson(endpoint + path, {
317
358
  method: "POST",
318
359
  headers: { "content-type": "application/json" },
319
360
  body: JSON.stringify(payload || {})
320
361
  });
321
- const data = await readJsonResponse(res);
322
362
  if (!res.ok || data.ok === false) {
323
363
  const error = siteAgentError(
324
364
  data.code || "AGENT_ID_PUBLIC_CALL_FAILED",
@@ -353,8 +393,7 @@ function createSiteAgentClient(options = {}) {
353
393
  },
354
394
  ...(method === "POST" ? { body: JSON.stringify(body) } : {})
355
395
  };
356
- const res = await fetchWithRetry(endpoint + "/api/site_agents/permissions" + suffix, request);
357
- const data = await readJsonResponse(res);
396
+ const { res, data } = await requestJson(endpoint + "/api/site_agents/permissions" + suffix, request);
358
397
  if (!res.ok || data.ok === false) {
359
398
  const error = siteAgentError(
360
399
  data.error || "SITE_AGENT_PERMISSION_FAILED",
@@ -28,6 +28,39 @@ function bytes(value) { return Buffer.byteLength(JSON.stringify(value), "utf8");
28
28
  function emptyState() {
29
29
  return { schema: "agentx-telemetry-buffer-v1", events: [], dead_letters: [], retry_at: 0, attempts: 0, last_flush_at: 0, last_error: null };
30
30
  }
31
+ function journalPathFor(statePath) { return `${path.resolve(statePath)}.journal`; }
32
+ function applyJournal(state, journalPath, maxFileBytes, maxDeadLetters) {
33
+ let text;
34
+ try {
35
+ if (fs.statSync(journalPath).size > maxFileBytes) throw new Error("collector_state_too_large");
36
+ text = fs.readFileSync(journalPath, "utf8");
37
+ } catch (error) {
38
+ if (error.code === "ENOENT") return 0;
39
+ throw error;
40
+ }
41
+ const known = new Set(state.events.map((event) => event.event_id));
42
+ let applied = 0;
43
+ for (const line of text.split("\n")) {
44
+ if (!line) continue;
45
+ let entry;
46
+ try { entry = JSON.parse(line); } catch (_error) { break; }
47
+ if (entry.add && !known.has(entry.add.event_id)) {
48
+ state.events.push(entry.add);
49
+ known.add(entry.add.event_id);
50
+ }
51
+ if (Array.isArray(entry.drop) && entry.drop.length) {
52
+ const dropped = new Set(entry.drop);
53
+ state.events = state.events.filter((event) => !dropped.has(event.event_id));
54
+ for (const id of dropped) known.delete(id);
55
+ }
56
+ if (Array.isArray(entry.dead) && entry.dead.length) {
57
+ state.dead_letters.push(...entry.dead);
58
+ state.dead_letters = state.dead_letters.slice(-maxDeadLetters);
59
+ }
60
+ applied += 1;
61
+ }
62
+ return applied;
63
+ }
31
64
  function loadState(statePath, maxFileBytes) {
32
65
  try {
33
66
  if (fs.statSync(statePath).size > maxFileBytes) throw new Error("collector_state_too_large");
@@ -47,6 +80,7 @@ function writeState(statePath, state) {
47
80
  fs.renameSync(temporary, target);
48
81
  fs.chmodSync(target, 0o600);
49
82
  } finally { try { fs.rmSync(temporary, { force: true }); } catch (_error) {} }
83
+ fs.rmSync(journalPathFor(statePath), { force: true });
50
84
  }
51
85
  function createTelemetryCollector({
52
86
  client,
@@ -68,25 +102,70 @@ function createTelemetryCollector({
68
102
  maxBatchBytes = Math.max(512, Number(maxBatchBytes) || 256 * 1024);
69
103
  maxBatchEvents = Math.max(1, Math.min(250, Number(maxBatchEvents) || 250));
70
104
  maxDeadLetters = Math.max(1, Math.min(100, Number(maxDeadLetters) || 100));
105
+ const journalPath = journalPathFor(statePath);
71
106
  const state = loadState(statePath, maxBytes + 1024 * 1024);
72
- function persist() { writeState(statePath, state); }
73
- function prune(timestamp = now()) {
107
+ const replayed = applyJournal(state, journalPath, 2 * maxBytes + 1024 * 1024, maxDeadLetters);
108
+ const eventSizes = new WeakMap();
109
+ let journalBytes = 0;
110
+ let snapshotBytes = 0;
111
+ function eventBytes(event) {
112
+ let size = eventSizes.get(event);
113
+ if (size === undefined) { size = bytes(event); eventSizes.set(event, size); }
114
+ return size;
115
+ }
116
+ function bufferBytes(events = state.events) {
117
+ let total = 2 + Math.max(0, events.length - 1);
118
+ for (const event of events) total += eventBytes(event);
119
+ return total;
120
+ }
121
+ function knownIds() {
122
+ const ids = new Set(state.events.map((event) => event.event_id));
123
+ for (const item of state.dead_letters) ids.add(item.event_id);
124
+ return ids;
125
+ }
126
+ let ids = knownIds();
127
+ function persist() {
128
+ writeState(statePath, state);
129
+ snapshotBytes = bufferBytes();
130
+ journalBytes = 0;
131
+ ids = knownIds();
132
+ }
133
+ function journal(entry) {
134
+ const line = JSON.stringify(entry) + "\n";
135
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true, mode: 0o700 });
136
+ fs.appendFileSync(journalPath, line, { mode: 0o600 });
137
+ journalBytes += Buffer.byteLength(line, "utf8");
138
+ if (journalBytes > Math.max(1024 * 1024, snapshotBytes)) persist();
139
+ }
140
+ function evict(timestamp) {
74
141
  const cutoff = timestamp - maxAgeMs;
75
142
  const removed = [];
76
143
  state.events = state.events.filter((event) => {
77
144
  if (Number(event.ingestion_time) < cutoff) { removed.push({ ...event, dead_letter_reason: "expired_buffer_event" }); return false; }
78
145
  return true;
79
146
  });
80
- while (state.events.length > maxEvents || bytes(state.events) > maxBytes) removed.push({ ...state.events.shift(), dead_letter_reason: "buffer_limit_pruned" });
81
- state.dead_letters.push(...removed.map((event) => ({ event_id: event.event_id, source: event.source, dead_letter_reason: event.dead_letter_reason, failed_at: timestamp })));
147
+ let total = bufferBytes();
148
+ while (state.events.length > maxEvents || total > maxBytes) {
149
+ const event = state.events.shift();
150
+ total -= eventBytes(event) + (state.events.length ? 1 : 0);
151
+ removed.push({ ...event, dead_letter_reason: "buffer_limit_pruned" });
152
+ }
153
+ const dead = removed.map((event) => ({ event_id: event.event_id, source: event.source, dead_letter_reason: event.dead_letter_reason, failed_at: timestamp }));
154
+ state.dead_letters.push(...dead);
82
155
  state.dead_letters = state.dead_letters.slice(-maxDeadLetters);
83
- persist();
156
+ for (const item of dead) ids.add(item.event_id);
157
+ return dead;
158
+ }
159
+ function prune(timestamp = now()) {
160
+ const removed = evict(timestamp);
161
+ if (removed.length) persist();
84
162
  return { removed: removed.length, event_count: state.events.length };
85
163
  }
164
+ if (replayed) persist();
86
165
  async function record(event) {
87
166
  const timestamp = now();
88
167
  const eventId = String(event && (event.event_id || event.id) || crypto.randomUUID());
89
- if (state.events.some((item) => item.event_id === eventId) || state.dead_letters.some((item) => item.event_id === eventId)) return { recorded: false, duplicate: true, event_id: eventId };
168
+ if (ids.has(eventId)) return { recorded: false, duplicate: true, event_id: eventId };
90
169
  const cleaned = redact(event || {});
91
170
  const normalized = {
92
171
  ...cleaned,
@@ -100,7 +179,9 @@ function createTelemetryCollector({
100
179
  };
101
180
  if (normalized.user_agent && !normalized.verified_bot_identity) normalized.identity_confidence = "user_agent_only_spoofable";
102
181
  state.events.push(normalized);
103
- prune(timestamp);
182
+ ids.add(eventId);
183
+ const dead = evict(timestamp);
184
+ journal({ add: normalized, drop: dead.map((item) => item.event_id), dead });
104
185
  return { recorded: true, event_id: eventId };
105
186
  }
106
187
  function nextBatch(timestamp) {
@@ -155,7 +236,7 @@ function createTelemetryCollector({
155
236
  }
156
237
  async function status() {
157
238
  prune(now());
158
- return { schema: "agentx-telemetry-status-v1", event_count: state.events.length, dead_letter_count: state.dead_letters.length, bytes: bytes(state.events), retry_at: state.retry_at, last_flush_at: state.last_flush_at, last_error: state.last_error };
239
+ return { schema: "agentx-telemetry-status-v1", event_count: state.events.length, dead_letter_count: state.dead_letters.length, bytes: bufferBytes(), retry_at: state.retry_at, last_flush_at: state.last_flush_at, last_error: state.last_error };
159
240
  }
160
241
  return Object.freeze({ record, flush, status, prune });
161
242
  }