twin3-sdk 0.2.20-pre-alpha → 0.3.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.
package/README.md CHANGED
@@ -116,7 +116,7 @@ pip install -e /path/to/twin3-sdk
116
116
  * Any protocol or algorithm improvement in `twin3-sdk` is reflected in both applications in **0 milliseconds**.
117
117
 
118
118
  #### 2. Production Distribution
119
- `v0.2.20-pre-alpha` is the current release candidate: `0.2.20-pre-alpha` for npm and `0.2.20a0` for PyPI. It is published from reviewed main through the existing Cloud Build tag trigger. Consumers must pin the exact released version and lock its registry integrity; never use a mutable range for a cryptographic protocol dependency. This release stays pre-alpha. Marketplace or sales copy may offer it for evaluation; it is not a stable release, and the npm dist-tag is `next`, not `latest`.
119
+ `v0.3.0` is the current production release: `0.3.0` for npm and `0.3.0` for PyPI, published on the npm dist-tag `latest` as an immutable GitHub release for Google Cloud Marketplace GM-13. It is the first production, non-prerelease release and has no API changes from 0.2.21-pre-alpha. Consumers must pin the exact released version and lock its registry integrity; never use a mutable range for a cryptographic protocol dependency.
120
120
 
121
121
  The additive `twin3-sdk/runtime` subpath provides a single-action Agent runtime candidate. Products retain authenticated authority, context egress consent, durable storage and trusted tool brokers. Python `twin3.runtime` provides compatible contracts and digests only. OpenShell, ADK and Hermes adapters are not included. See [runtime scope and limits](docs/architecture/PERSONAL-AGENT-RUNTIME-V1.md).
122
122
 
@@ -125,15 +125,15 @@ The additive `twin3-sdk/runtime` subpath provides a single-action Agent runtime
125
125
  After publication, install the exact released version from the registry:
126
126
 
127
127
  ```bash
128
- npm install twin3-sdk@0.2.20-pre-alpha
129
- pip install twin3-sdk==0.2.20a0
128
+ npm install twin3-sdk@0.3.0
129
+ pip install twin3-sdk==0.3.0
130
130
  ```
131
131
 
132
132
  For local evaluation, you can also use the packed npm tarball. Do not install from a git URL or a version range:
133
133
 
134
134
  ```bash
135
135
  npm pack --ignore-scripts
136
- npm install ./twin3-sdk-0.2.20-pre-alpha.tgz
136
+ npm install ./twin3-sdk-0.3.0.tgz
137
137
  ```
138
138
 
139
139
  ---
package/index.d.ts CHANGED
@@ -224,6 +224,85 @@ export interface EngineObservationReading {
224
224
  export declare function normalizeEngineObservation(row: EngineObservationInput, options: { host: string }): EngineObservation;
225
225
  export declare function summarizeEngineObservations(observations: EngineObservation[], options: { host: string; sourceLevel?: boolean }): EngineObservationReading;
226
226
  export declare function summarizeCohortVisibility(readingsBySubject: Record<string, EngineObservationReading>, subjectId: string, options?: { minCohortSize?: number }): Record<string, any>;
227
+ export declare class AiVisibilityError extends Error { code: string; }
228
+ export declare const AI_REFERRAL_SCHEMA: "twin3-discovery-ai-referral-v1";
229
+ export declare const AI_CRAWLER_SCHEMA: "twin3-discovery-ai-crawler-v1";
230
+ export declare const AI_READINESS_SCHEMA: "twin3-discovery-ai-readiness-v1";
231
+ export declare const AI_TRAJECTORY_SCHEMA: "twin3-discovery-ai-trajectory-v1";
232
+ export type AiCrawlerPurpose = "training" | "search" | "user" | "agent" | "control";
233
+ export type AiTrajectoryStage = "crawled" | "search_indexed" | "user_fetched" | "retrieved" | "mentioned" | "cited" | "clicked";
234
+ export interface AiVisibilityCatalog {
235
+ version: number;
236
+ checked_on: string;
237
+ provenance: Array<{ name: string; url: string; license: string; used: string[] }>;
238
+ referral_sources: Array<{ key: string; operator: string; hosts: string[]; aliases: string[]; surfaces?: Array<{ key: string; medium: string }>; referrer_path?: string; utm?: boolean }>;
239
+ crawlers: Array<{ token: string; operator: string; purpose: AiCrawlerPurpose; robots: string; user_agent: boolean; claim?: string }>;
240
+ stages: Array<{ key: AiTrajectoryStage; en: string; "zh-TW": string }>;
241
+ }
242
+ export declare const AI_VISIBILITY_CATALOG: AiVisibilityCatalog;
243
+ export interface AiReferral {
244
+ schema: "twin3-discovery-ai-referral-v1";
245
+ source: string;
246
+ operator: string;
247
+ origin: "referrer" | "utm";
248
+ surface: string | null;
249
+ catalog_version: number;
250
+ }
251
+ export interface AiCrawler {
252
+ schema: "twin3-discovery-ai-crawler-v1";
253
+ token: string;
254
+ operator: string;
255
+ purpose: AiCrawlerPurpose;
256
+ robots: string;
257
+ catalog_version: number;
258
+ }
259
+ export interface AiReadinessCheck {
260
+ id: string;
261
+ priority: "P0" | "P1" | "P2" | "P3";
262
+ status: "pass" | "warn" | "fail" | "info" | "na";
263
+ evidence: Record<string, unknown>;
264
+ action: string | null;
265
+ }
266
+ export interface AiReadinessReport {
267
+ schema: "twin3-discovery-ai-readiness-v1";
268
+ host: string;
269
+ path: string;
270
+ catalog_version: number;
271
+ checks: AiReadinessCheck[];
272
+ crawler_access: Record<string, { operator: string; purpose: AiCrawlerPurpose; allowed: boolean; matched: "named" | "star" | "none" }>;
273
+ gate: { passed: boolean; blocking: string[] };
274
+ actions: Array<{ check: string; priority: "P0" | "P1" | "P2" | "P3"; action: string }>;
275
+ non_claims: string[];
276
+ digest: string;
277
+ }
278
+ export interface AiTrajectorySignal { state: "measured" | "withheld" | "unknown"; rate_bp: number | null; }
279
+ export interface AiTrajectoryOperator {
280
+ crawl: Record<"training" | "search" | "user" | "agent" | "other", number>;
281
+ refused: number;
282
+ engines: Record<string, Record<"retrieved" | "mentioned" | "cited", AiTrajectorySignal> & { answer_visibility_bp: number | null }>;
283
+ clicks: number;
284
+ click_sources: Record<string, number>;
285
+ click_surfaces: Record<string, number>;
286
+ stages: AiTrajectoryStage[];
287
+ }
288
+ export interface AiTrajectory {
289
+ schema: "twin3-discovery-ai-trajectory-v1";
290
+ host: string;
291
+ catalog_version: number;
292
+ operators: Record<string, AiTrajectoryOperator>;
293
+ totals: { fetched_requests: number; refused_requests: number; unclassified_requests: number; ai_sessions: number; non_ai_sessions: number };
294
+ reading_digest: string | null;
295
+ reading_valid: boolean | null;
296
+ unattributed_engines: string[];
297
+ score_weights: { mentioned: number; cited: number; divisor: number; source: string };
298
+ non_claims: string[];
299
+ digest: string;
300
+ }
301
+ export interface AiReferralRow { referrer?: string | null; landing_url?: string | null; utm_source?: string | null; sessions?: number; }
302
+ export declare function classifyAiReferral(referrer?: string | null, options?: { landingUrl?: string | null; utmSource?: string | null }): AiReferral | null;
303
+ export declare function classifyAiCrawler(userAgent: string): AiCrawler | null;
304
+ export declare function auditAiReadiness(options: { host: string; path?: string; robotsTxt?: string | null; html?: string | null; llmsTxt?: string | null }): AiReadinessReport;
305
+ export declare function summarizeAiTrajectory(options: { host: string; visits?: DiscoveryVisitEvidence[]; reading?: EngineObservationReading | null; referrals?: AiReferralRow[]; engineOperators?: Record<string, string> | null }): AiTrajectory;
227
306
  export declare function buildSignedReferral(options: Record<string, any>): Record<string, any>;
228
307
  export declare function verifySignedReferral(token: Record<string, any>, options: Record<string, any>): Record<string, any>;
229
308
 
@@ -0,0 +1,616 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const CATALOG = require("../twin3/discovery/ai-visibility-catalog.json");
5
+
6
+ const CATALOG_VERSION = CATALOG.version;
7
+ const VISIT_EVIDENCE_SCHEMA = "twin3-discovery-visit-evidence-v1";
8
+ const ENGINE_READING_SCHEMA = "twin3-discovery-engine-reading-v1";
9
+ const AI_REFERRAL_SCHEMA = "twin3-discovery-ai-referral-v1";
10
+ const AI_CRAWLER_SCHEMA = "twin3-discovery-ai-crawler-v1";
11
+ const AI_READINESS_SCHEMA = "twin3-discovery-ai-readiness-v1";
12
+ const AI_TRAJECTORY_SCHEMA = "twin3-discovery-ai-trajectory-v1";
13
+
14
+ const PURPOSES = ["training", "search", "user", "agent", "other"];
15
+ const SIGNALS = ["retrieved", "mentioned", "cited"];
16
+ const STAGES = CATALOG.stages.map(stage => stage.key);
17
+ const MIN_VISIBLE_CHARS = 200;
18
+ const SCORE_WEIGHTS = {
19
+ mentioned: 12,
20
+ cited: 5,
21
+ divisor: 17,
22
+ source: "ansvisor visibility-score 0.60 mention / 0.25 citation, position term dropped",
23
+ };
24
+ const READINESS_NON_CLAIMS = [
25
+ "robots_allow_is_not_proof_of_indexing",
26
+ "readiness_is_not_visibility",
27
+ "unverified_user_agent_responses_not_tested",
28
+ "llms_txt_has_no_documented_ranking_effect",
29
+ ];
30
+ const TRAJECTORY_NON_CLAIMS = [
31
+ "user_agent_claims_are_not_verified_operator_identity",
32
+ "referrer_counts_undercount_apps_that_strip_referrers",
33
+ "rates_do_not_pool_across_engines",
34
+ "stages_are_not_a_causal_funnel",
35
+ ];
36
+ const ENTITY_TYPES = new Set([
37
+ "Organization", "Corporation", "LocalBusiness", "NGO", "Person", "ProfilePage", "Brand",
38
+ "SoftwareApplication", "WebApplication", "WebAPI", "Product", "Service",
39
+ ]);
40
+
41
+ const WS = " \t\n\r\f";
42
+ const HOST = /^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/;
43
+ const OPERATOR = /^[A-Za-z0-9](?:[A-Za-z0-9 ._-]{0,78}[A-Za-z0-9.])?$/;
44
+ const ENGINE = /^[A-Za-z0-9._:/-](?:[A-Za-z0-9 ._:/-]{0,78}[A-Za-z0-9._:/-])?$/;
45
+ const SHA256 = /^[0-9a-f]{64}$/;
46
+ const DIRECTIVE = /^[a-z0-9_:-]{1,40}$/;
47
+ const TYPE_NAME = /^[A-Za-z0-9:/._#-]{1,100}$/;
48
+ const RAW_TEXT = /<(script|style|template)(?=[\t\n\f\r />])([^>]*)>([\s\S]*?)<\/\1[\t\n\f\r ]*>/gi;
49
+ const ATTR = /([A-Za-z_:][-A-Za-z0-9_:.]*)(?:[\t\n\f\r ]*=[\t\n\f\r ]*(?:"([^"]*)"|'([^']*)'|([^\t\n\f\r "'>`=<]+)))?/g;
50
+ const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ const CRAWLER_PATTERNS = CATALOG.crawlers.filter(crawler => crawler.user_agent).map(crawler =>
52
+ [crawler, new RegExp("(?:^|[^A-Za-z0-9_-])" + escapeRegex(crawler.token) + "(?:$|[^A-Za-z0-9_@.-])", "i")]);
53
+ const CRAWLERS_BY_TOKEN = new Map(CATALOG.crawlers.map(crawler => [crawler.token, crawler]));
54
+ const PRIORITY = { P0: 0, P1: 1, P2: 2, P3: 3 };
55
+ const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
56
+
57
+ class AiVisibilityError extends Error {
58
+ constructor(code) {
59
+ super(code);
60
+ this.name = "AiVisibilityError";
61
+ this.code = code;
62
+ }
63
+ }
64
+
65
+ function requireValue(condition, code) {
66
+ if (!condition) throw new AiVisibilityError(code);
67
+ }
68
+
69
+ function isRecord(value) {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value)
71
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
72
+ }
73
+
74
+ function get(record, key) {
75
+ return Object.hasOwn(record, key) && record[key] !== undefined ? record[key] : null;
76
+ }
77
+
78
+ function asciiLower(value) {
79
+ return value.replace(/[A-Z]/g, char => String.fromCharCode(char.charCodeAt(0) + 32));
80
+ }
81
+
82
+ function strip(value, chars = WS) {
83
+ let start = 0;
84
+ let end = value.length;
85
+ while (start < end && chars.includes(value[start])) start += 1;
86
+ while (end > start && chars.includes(value[end - 1])) end -= 1;
87
+ return value.slice(start, end);
88
+ }
89
+
90
+ function codePoints(value) {
91
+ let count = 0;
92
+ for (const _ of value) count += 1;
93
+ return count;
94
+ }
95
+
96
+ function text(value, limit, code) {
97
+ requireValue(typeof value === "string" && value.length <= limit * 2 && !/[\ud800-\udfff]/u.test(value) && codePoints(value) <= limit, code);
98
+ return value;
99
+ }
100
+
101
+ function optionalText(value, limit, code) {
102
+ return value === null || value === undefined ? null : text(value, limit, code);
103
+ }
104
+
105
+ function hostValue(value, code = "host_invalid") {
106
+ requireValue(typeof value === "string" && !/[^\x00-\x7f]/.test(value), code);
107
+ const host = asciiLower(strip(value, WS + "\v")).replace(/\.$/, "");
108
+ requireValue(HOST.test(host) && !host.includes(".."), code);
109
+ return host;
110
+ }
111
+
112
+ function count(value, code, minimum = 0) {
113
+ requireValue(Number.isSafeInteger(value) && minimum <= value && value <= 1_000_000_000, code);
114
+ return value;
115
+ }
116
+
117
+ function stripWww(host) {
118
+ return host.startsWith("www.") ? host.slice(4) : host;
119
+ }
120
+
121
+ function under(value, host) {
122
+ return value === host || value.endsWith("." + host);
123
+ }
124
+
125
+ function urlParts(value) {
126
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#[\s\S]*)?$/.exec(value);
127
+ if (match === null || !["http", "https"].includes(asciiLower(match[1]))) return null;
128
+ const authority = match[2];
129
+ if (authority.includes("@") || authority.includes("[")) return null;
130
+ let host;
131
+ try {
132
+ host = hostValue(authority.split(":")[0]);
133
+ } catch {
134
+ return null;
135
+ }
136
+ return [host, match[3] || "/", (match[4] || "").slice(1)];
137
+ }
138
+
139
+ function landingMedium(landingUrl) {
140
+ const parts = landingUrl === null ? null : urlParts(landingUrl);
141
+ if (parts === null) return "";
142
+ for (const pair of parts[2].split("&")) {
143
+ const index = pair.indexOf("=");
144
+ const name = index < 0 ? pair : pair.slice(0, index);
145
+ if (asciiLower(name) === "utm_medium") return asciiLower(index < 0 ? "" : pair.slice(index + 1));
146
+ }
147
+ return "";
148
+ }
149
+
150
+ function surfaceOf(source, landingUrl) {
151
+ const medium = landingMedium(landingUrl);
152
+ if (!medium) return null;
153
+ const surface = (source.surfaces || []).find(item => item.medium === medium);
154
+ return surface ? surface.key : null;
155
+ }
156
+
157
+ function classifyAiReferral(referrer = null, { landingUrl = null, utmSource = null } = {}) {
158
+ referrer = optionalText(referrer, 2048, "referral_invalid");
159
+ landingUrl = optionalText(landingUrl, 4096, "referral_invalid");
160
+ utmSource = optionalText(utmSource, 256, "referral_invalid");
161
+ let match = null;
162
+ if (referrer) {
163
+ let parts = referrer.includes("://") ? urlParts(referrer) : null;
164
+ if (parts === null && !referrer.includes("://")) {
165
+ try {
166
+ parts = [hostValue(referrer), null, ""];
167
+ } catch {
168
+ parts = null;
169
+ }
170
+ }
171
+ if (parts !== null) {
172
+ const host = stripWww(parts[0]);
173
+ const path = parts[1];
174
+ for (const source of CATALOG.referral_sources) {
175
+ if (!source.hosts.some(candidate => under(host, candidate))) continue;
176
+ const required = source.referrer_path;
177
+ if (required !== undefined && (path === null || !path.startsWith(required))) continue;
178
+ match = [source, "referrer"];
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ if (match === null && utmSource) {
184
+ const value = stripWww(asciiLower(strip(utmSource)));
185
+ for (const source of CATALOG.referral_sources) {
186
+ if (source.utm === false) continue;
187
+ if (source.aliases.includes(value) || source.hosts.some(candidate => under(value, candidate))) {
188
+ match = [source, "utm"];
189
+ break;
190
+ }
191
+ }
192
+ }
193
+ if (match === null) return null;
194
+ const [source, origin] = match;
195
+ return {
196
+ schema: AI_REFERRAL_SCHEMA,
197
+ source: source.key,
198
+ operator: source.operator,
199
+ origin,
200
+ surface: surfaceOf(source, landingUrl),
201
+ catalog_version: CATALOG_VERSION,
202
+ };
203
+ }
204
+
205
+ function classifyAiCrawler(userAgent) {
206
+ userAgent = text(userAgent, 2048, "crawler_user_agent_invalid");
207
+ for (const [crawler, pattern] of CRAWLER_PATTERNS) {
208
+ if (pattern.test(userAgent)) {
209
+ return {
210
+ schema: AI_CRAWLER_SCHEMA,
211
+ token: crawler.token,
212
+ operator: crawler.operator,
213
+ purpose: crawler.purpose,
214
+ robots: crawler.robots,
215
+ catalog_version: CATALOG_VERSION,
216
+ };
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+
222
+ function parseRobots(source) {
223
+ const groups = [];
224
+ let current = null;
225
+ let lastWasAgent = false;
226
+ for (const raw of source.replace(/^/, "").split(/\r\n|\n|\r/)) {
227
+ const line = strip(raw.split("#")[0], WS + "\v");
228
+ if (!line || !line.includes(":")) continue;
229
+ const index = line.indexOf(":");
230
+ const name = asciiLower(strip(line.slice(0, index), WS + "\v"));
231
+ const value = strip(line.slice(index + 1), WS + "\v");
232
+ if (name === "user-agent") {
233
+ if (current === null || !lastWasAgent) {
234
+ current = { agents: [], rules: [] };
235
+ groups.push(current);
236
+ }
237
+ current.agents.push(asciiLower(value));
238
+ lastWasAgent = true;
239
+ continue;
240
+ }
241
+ lastWasAgent = false;
242
+ if (current !== null && (name === "allow" || name === "disallow")) current.rules.push([name, value]);
243
+ }
244
+ return groups;
245
+ }
246
+
247
+ function selectGroup(groups, token) {
248
+ const lowered = asciiLower(token);
249
+ const named = groups.filter(group => group.agents.includes(lowered));
250
+ const chosen = named.length ? named : groups.filter(group => group.agents.includes("*"));
251
+ const matched = named.length ? "named" : chosen.length ? "star" : "none";
252
+ return [matched, chosen.flatMap(group => group.rules)];
253
+ }
254
+
255
+ function patternMatches(pattern, path) {
256
+ const anchored = pattern.endsWith("$");
257
+ const parts = (anchored ? pattern.slice(0, -1) : pattern).split("*");
258
+ if (!path.startsWith(parts[0])) return false;
259
+ let position = parts[0].length;
260
+ if (parts.length === 1) return anchored ? position === path.length : true;
261
+ for (const part of parts.slice(1, -1)) {
262
+ const index = path.indexOf(part, position);
263
+ if (index < 0) return false;
264
+ position = index + part.length;
265
+ }
266
+ const last = parts[parts.length - 1];
267
+ if (anchored) return path.length - last.length >= position && path.endsWith(last);
268
+ return path.indexOf(last, position) >= 0;
269
+ }
270
+
271
+ function allowedBy(rules, path) {
272
+ let best = -1;
273
+ let allowed = true;
274
+ for (const [name, value] of rules) {
275
+ if (name === "disallow" && value === "") continue;
276
+ if (patternMatches(value, path) && (value.length > best || (value.length === best && name === "allow"))) {
277
+ best = value.length;
278
+ allowed = name === "allow";
279
+ }
280
+ }
281
+ return allowed;
282
+ }
283
+
284
+ function attributes(tag) {
285
+ const inner = tag.replace(/^<[A-Za-z0-9]+/, "").replace(/[>/]+$/, "");
286
+ const found = {};
287
+ for (const match of inner.matchAll(ATTR)) {
288
+ const name = asciiLower(match[1]);
289
+ if (!Object.hasOwn(found, name)) {
290
+ const value = [match[2], match[3], match[4]].find(group => group !== undefined);
291
+ found[name] = value === undefined ? "" : value;
292
+ }
293
+ }
294
+ return found;
295
+ }
296
+
297
+ function tags(html, name) {
298
+ return [...html.matchAll(new RegExp("<" + name + "(?=[\\t\\n\\f\\r />])[^>]*>", "gi"))].map(match => attributes(match[0]));
299
+ }
300
+
301
+ function attr(record, key) {
302
+ return Object.hasOwn(record, key) ? record[key] : "";
303
+ }
304
+
305
+ function jsonLd(blocks) {
306
+ const types = new Set();
307
+ let dated = false;
308
+ let invalid = 0;
309
+ for (const block of blocks.slice(0, 32)) {
310
+ let value;
311
+ try {
312
+ value = JSON.parse(block);
313
+ } catch {
314
+ invalid += 1;
315
+ continue;
316
+ }
317
+ const stack = [[value, 0]];
318
+ while (stack.length) {
319
+ const [node, depth] = stack.pop();
320
+ if (depth > 64) continue;
321
+ if (node !== null && typeof node === "object" && !Array.isArray(node)) {
322
+ const kind = Object.hasOwn(node, "@type") ? node["@type"] : null;
323
+ for (const name of Array.isArray(kind) ? kind : [kind]) {
324
+ if (typeof name === "string" && TYPE_NAME.test(name)) types.add(name);
325
+ }
326
+ for (const key of ["dateModified", "datePublished"]) {
327
+ if (Object.hasOwn(node, key) && typeof node[key] === "string" && strip(node[key])) dated = true;
328
+ }
329
+ for (const child of Object.values(node)) stack.push([child, depth + 1]);
330
+ } else if (Array.isArray(node)) {
331
+ for (const child of node) stack.push([child, depth + 1]);
332
+ }
333
+ }
334
+ }
335
+ return [[...types].sort(compare), dated, invalid];
336
+ }
337
+
338
+ function shortType(name) {
339
+ const parts = name.split(/[/:#]/);
340
+ return parts[parts.length - 1];
341
+ }
342
+
343
+ function check(id, priority, status, evidence, action = null) {
344
+ return { id, priority, status, evidence, action };
345
+ }
346
+
347
+ function crawlerChecks(robotsTxt, path) {
348
+ const groups = robotsTxt !== null ? parseRobots(robotsTxt) : [];
349
+ const present = robotsTxt !== null;
350
+ const checks = [check("robots_txt", "P2", present ? "pass" : "warn", { present }, present ? null : "publish_robots_txt")];
351
+ const access = {};
352
+ for (const crawler of CATALOG.crawlers) {
353
+ if (crawler.robots === "not_applicable") continue;
354
+ const [matched, rules] = selectGroup(groups, crawler.token);
355
+ const allowed = allowedBy(rules, path);
356
+ access[crawler.token] = { operator: crawler.operator, purpose: crawler.purpose, allowed, matched };
357
+ const evidence = { allowed, matched, purpose: crawler.purpose, claim: crawler.claim === undefined ? null : crawler.claim };
358
+ let result;
359
+ if (crawler.purpose === "search") result = allowed ? ["pass", "P0", null] : ["fail", "P0", "allow_ai_search_crawler"];
360
+ else if (crawler.purpose === "user") result = allowed ? ["pass", "P1", null] : ["warn", "P1", "allow_user_triggered_fetcher"];
361
+ else result = ["info", "P3", null];
362
+ checks.push(check("crawler:" + crawler.token, result[1], result[0], evidence, result[2]));
363
+ }
364
+ return [checks, access];
365
+ }
366
+
367
+ function htmlChecks(source, host) {
368
+ if (source === null) return [check("html", "P0", "na", { present: false }, "supply_server_rendered_html")];
369
+ const html = source.replace(/<!--[\s\S]*?-->/g, " ");
370
+ const ldBlocks = [];
371
+ for (const match of html.matchAll(RAW_TEXT)) {
372
+ if (asciiLower(match[1]) !== "script") continue;
373
+ const kind = asciiLower(strip(attr(attributes("<script" + match[2] + ">"), "type")));
374
+ if (kind === "application/ld+json") ldBlocks.push(match[3]);
375
+ }
376
+ const body = html.replace(RAW_TEXT, " ");
377
+ let visible = 0;
378
+ for (const char of body.replace(/<[^>]*>/g, " ")) if (!(WS + "\v ").includes(char)) visible += 1;
379
+ const enough = visible >= MIN_VISIBLE_CHARS;
380
+ const checks = [check("ssr_text", "P0", enough ? "pass" : "fail", { visible_chars: visible, minimum: MIN_VISIBLE_CHARS },
381
+ enough ? null : "server_render_card_content")];
382
+ const title = /<title(?=[\t\n\f\r />])[^>]*>([\s\S]*?)<\/title[\t\n\f\r ]*>/i.exec(body);
383
+ const hasTitle = title !== null && Boolean(strip(title[1].replace(/<[^>]*>/g, "")));
384
+ checks.push(check("title", "P1", hasTitle ? "pass" : "fail", { present: hasTitle }, hasTitle ? null : "add_title"));
385
+ const metas = tags(body, "meta");
386
+ const content = (key, value) => metas.some(meta => asciiLower(attr(meta, key)) === value && Boolean(strip(attr(meta, "content"))));
387
+ const hasDescription = content("name", "description");
388
+ checks.push(check("meta_description", "P2", hasDescription ? "pass" : "warn", { present: hasDescription },
389
+ hasDescription ? null : "add_meta_description"));
390
+ const canonicalLink = tags(body, "link").find(link => asciiLower(attr(link, "rel")).split(/[ \t\n\r\f\v]+/).includes("canonical"));
391
+ const canonical = canonicalLink === undefined ? null : strip(attr(canonicalLink, "href"));
392
+ if (!canonical) {
393
+ checks.push(check("canonical", "P2", "warn", { present: false, same_host: null }, "add_canonical"));
394
+ } else {
395
+ const parts = canonical.includes("://") ? urlParts(canonical) : null;
396
+ const same = (canonical.startsWith("/") && !canonical.startsWith("//")) || (parts !== null && parts[0] === host);
397
+ checks.push(check("canonical", same ? "P2" : "P1", same ? "pass" : "warn", { present: true, same_host: same },
398
+ same ? null : "point_canonical_to_card_host"));
399
+ }
400
+ const directives = new Set();
401
+ for (const meta of metas) {
402
+ if (["robots", "googlebot"].includes(asciiLower(attr(meta, "name")))) {
403
+ for (const piece of asciiLower(attr(meta, "content")).split(",")) {
404
+ const part = strip(piece);
405
+ if (DIRECTIVE.test(part)) directives.add(part);
406
+ }
407
+ }
408
+ }
409
+ const sortedDirectives = [...directives].sort(compare);
410
+ if (directives.has("noindex") || directives.has("none")) {
411
+ checks.push(check("meta_robots", "P0", "fail", { directives: sortedDirectives }, "remove_noindex"));
412
+ } else if (directives.has("nosnippet")) {
413
+ checks.push(check("meta_robots", "P1", "warn", { directives: sortedDirectives }, "remove_nosnippet"));
414
+ } else {
415
+ checks.push(check("meta_robots", "P0", "pass", { directives: sortedDirectives }));
416
+ }
417
+ const [types, ldDated, invalid] = jsonLd(ldBlocks);
418
+ const entity = types.some(name => ENTITY_TYPES.has(shortType(name)));
419
+ const evidence = { blocks: ldBlocks.length, invalid, types, entity };
420
+ if (invalid) checks.push(check("json_ld", "P1", "fail", evidence, "fix_json_ld_syntax"));
421
+ else if (!ldBlocks.length) checks.push(check("json_ld", "P1", "warn", evidence, "add_entity_json_ld"));
422
+ else if (!entity) checks.push(check("json_ld", "P2", "warn", evidence, "add_entity_json_ld"));
423
+ else checks.push(check("json_ld", "P1", "pass", evidence));
424
+ const dated = ldDated || content("property", "article:modified_time") || content("property", "article:published_time")
425
+ || tags(body, "time").some(time => Boolean(strip(attr(time, "datetime"))));
426
+ checks.push(check("freshness", "P2", dated ? "pass" : "warn", { dated }, dated ? null : "publish_dates"));
427
+ const headings = [...body.matchAll(/<h1(?=[\t\n\f\r />])/gi)].length;
428
+ checks.push(check("h1", headings === 0 ? "P2" : "P3", headings === 1 ? "pass" : "warn", { count: headings },
429
+ headings === 1 ? null : "use_one_h1"));
430
+ return checks;
431
+ }
432
+
433
+ function operatorEntry() {
434
+ return {
435
+ crawl: Object.fromEntries(PURPOSES.map(purpose => [purpose, 0])),
436
+ refused: 0,
437
+ engines: {},
438
+ clicks: 0,
439
+ click_sources: {},
440
+ click_surfaces: {},
441
+ stages: [],
442
+ };
443
+ }
444
+
445
+ function signalOf(readingSignal) {
446
+ requireValue(isRecord(readingSignal), "trajectory_reading_invalid");
447
+ const state = get(readingSignal, "state");
448
+ const rate = get(readingSignal, "rate_bp");
449
+ requireValue(["measured", "withheld", "unknown"].includes(state), "trajectory_reading_invalid");
450
+ if (state === "measured") requireValue(Number.isInteger(rate) && rate >= 0 && rate <= 10000, "trajectory_reading_invalid");
451
+ else requireValue(rate === null, "trajectory_reading_invalid");
452
+ return { state, rate_bp: rate };
453
+ }
454
+
455
+ function createAiVisibilityApi(canonicalJsonBuffer) {
456
+ const digest = value => crypto.createHash("sha256").update(canonicalJsonBuffer(value)).digest("hex");
457
+
458
+ function verified(row, schema, host, code) {
459
+ requireValue(isRecord(row) && row.schema === schema && row.host === host, code);
460
+ const supplied = row.digest;
461
+ requireValue(typeof supplied === "string" && SHA256.test(supplied), code);
462
+ const { digest: _, ...body } = row;
463
+ let expected;
464
+ try {
465
+ expected = digest(body);
466
+ } catch {
467
+ throw new AiVisibilityError(code);
468
+ }
469
+ requireValue(crypto.timingSafeEqual(Buffer.from(supplied, "ascii"), Buffer.from(expected, "ascii")), code);
470
+ }
471
+
472
+ function auditAiReadiness({ host, path = "/", robotsTxt = null, html = null, llmsTxt = null } = {}) {
473
+ const owner = hostValue(host, "readiness_host_invalid");
474
+ requireValue(typeof path === "string" && path.startsWith("/") && path.length <= 2048
475
+ && !/[^\x00-\x7f]/.test(path) && !/[\x00-\x20\x7f?#]/.test(path), "readiness_path_invalid");
476
+ robotsTxt = optionalText(robotsTxt, 512_000, "readiness_robots_invalid");
477
+ html = optionalText(html, 2_000_000, "readiness_html_invalid");
478
+ llmsTxt = optionalText(llmsTxt, 512_000, "readiness_llms_invalid");
479
+ const [checks, access] = crawlerChecks(robotsTxt, path);
480
+ checks.push(...htmlChecks(html, owner));
481
+ checks.push(check("llms_txt", "P3", "info", { present: Boolean(llmsTxt && strip(llmsTxt)) }));
482
+ const blocking = checks.filter(item => ["fail", "na"].includes(item.status) && item.priority === "P0").map(item => item.id).sort(compare);
483
+ const actions = checks
484
+ .filter(item => item.action && ["fail", "warn", "na"].includes(item.status))
485
+ .map(item => ({ check: item.id, priority: item.priority, action: item.action }))
486
+ .sort((a, b) => PRIORITY[a.priority] - PRIORITY[b.priority] || compare(a.check, b.check));
487
+ const body = {
488
+ schema: AI_READINESS_SCHEMA,
489
+ host: owner,
490
+ path,
491
+ catalog_version: CATALOG_VERSION,
492
+ checks,
493
+ crawler_access: access,
494
+ gate: { passed: !blocking.length, blocking },
495
+ actions,
496
+ non_claims: [...READINESS_NON_CLAIMS],
497
+ };
498
+ return { ...body, digest: digest(body) };
499
+ }
500
+
501
+ function summarizeAiTrajectory({ host, visits = [], reading = null, referrals = [], engineOperators = null } = {}) {
502
+ const owner = hostValue(host, "trajectory_host_invalid");
503
+ requireValue(Array.isArray(visits) && visits.length <= 100_000, "trajectory_visits_invalid");
504
+ requireValue(Array.isArray(referrals) && referrals.length <= 100_000, "trajectory_referrals_invalid");
505
+ const mapping = engineOperators === null ? {} : engineOperators;
506
+ requireValue(isRecord(mapping) && Object.entries(mapping).every(([engine, operator]) =>
507
+ ENGINE.test(engine) && typeof operator === "string" && OPERATOR.test(operator)), "trajectory_engine_operators_invalid");
508
+ const operators = {};
509
+ const ensure = name => (operators[name] = Object.hasOwn(operators, name) ? operators[name] : operatorEntry());
510
+ const totals = { fetched_requests: 0, refused_requests: 0, unclassified_requests: 0, ai_sessions: 0, non_ai_sessions: 0 };
511
+ for (const visit of visits) {
512
+ verified(visit, VISIT_EVIDENCE_SCHEMA, owner, "trajectory_visit_invalid");
513
+ const requests = count(get(visit, "request_count"), "trajectory_visit_invalid", 1);
514
+ const status = count(get(visit, "status"), "trajectory_visit_invalid");
515
+ const bot = get(visit, "bot");
516
+ if (bot === null) {
517
+ totals.unclassified_requests += requests;
518
+ continue;
519
+ }
520
+ requireValue(typeof bot === "string" && typeof get(visit, "provider") === "string", "trajectory_visit_invalid");
521
+ const crawler = CRAWLERS_BY_TOKEN.get(bot);
522
+ const entry = ensure(crawler ? crawler.operator : visit.provider);
523
+ const purpose = crawler && PURPOSES.includes(crawler.purpose) ? crawler.purpose : "other";
524
+ if (status >= 400) {
525
+ entry.refused += requests;
526
+ totals.refused_requests += requests;
527
+ } else {
528
+ entry.crawl[purpose] += requests;
529
+ totals.fetched_requests += requests;
530
+ }
531
+ }
532
+ const unattributed = [];
533
+ let readingDigest = null;
534
+ let readingValid = null;
535
+ if (reading !== null) {
536
+ verified(reading, ENGINE_READING_SCHEMA, owner, "trajectory_reading_invalid");
537
+ requireValue(isRecord(get(reading, "by_engine")) && typeof get(reading, "valid") === "boolean", "trajectory_reading_invalid");
538
+ readingDigest = reading.digest;
539
+ readingValid = reading.valid;
540
+ for (const engine of Object.keys(reading.by_engine).sort(compare)) {
541
+ const entryIn = reading.by_engine[engine];
542
+ requireValue(ENGINE.test(engine), "trajectory_reading_invalid");
543
+ requireValue(isRecord(entryIn) && isRecord(get(entryIn, "signals")), "trajectory_reading_invalid");
544
+ const signals = Object.fromEntries(SIGNALS.map(name => [name, signalOf(get(entryIn.signals, name))]));
545
+ const { mentioned, cited } = signals;
546
+ const score = mentioned.state === "measured" && cited.state === "measured"
547
+ ? Math.floor((SCORE_WEIGHTS.mentioned * mentioned.rate_bp + SCORE_WEIGHTS.cited * cited.rate_bp) / SCORE_WEIGHTS.divisor)
548
+ : null;
549
+ if (!Object.hasOwn(mapping, engine)) {
550
+ unattributed.push(engine);
551
+ continue;
552
+ }
553
+ ensure(mapping[engine]).engines[engine] = { ...signals, answer_visibility_bp: score };
554
+ }
555
+ }
556
+ const referralKeys = ["referrer", "landing_url", "utm_source", "sessions"];
557
+ for (const row of referrals) {
558
+ requireValue(isRecord(row) && Object.keys(row).every(key => referralKeys.includes(key)), "trajectory_referral_invalid");
559
+ const sessions = count(Object.hasOwn(row, "sessions") ? row.sessions : 1, "trajectory_referral_invalid", 1);
560
+ let referral;
561
+ try {
562
+ referral = classifyAiReferral(get(row, "referrer"), { landingUrl: get(row, "landing_url"), utmSource: get(row, "utm_source") });
563
+ } catch {
564
+ throw new AiVisibilityError("trajectory_referral_invalid");
565
+ }
566
+ if (referral === null) {
567
+ totals.non_ai_sessions += sessions;
568
+ continue;
569
+ }
570
+ const entry = ensure(referral.operator);
571
+ entry.clicks += sessions;
572
+ entry.click_sources[referral.source] = (entry.click_sources[referral.source] || 0) + sessions;
573
+ if (referral.surface !== null) entry.click_surfaces[referral.surface] = (entry.click_surfaces[referral.surface] || 0) + sessions;
574
+ totals.ai_sessions += sessions;
575
+ }
576
+ for (const entry of Object.values(operators)) {
577
+ const reached = {
578
+ crawled: Object.values(entry.crawl).some(value => value > 0),
579
+ search_indexed: entry.crawl.search > 0,
580
+ user_fetched: entry.crawl.user > 0,
581
+ clicked: entry.clicks > 0,
582
+ };
583
+ for (const name of SIGNALS) {
584
+ reached[name] = Object.values(entry.engines).some(engine => engine[name].state === "measured" && engine[name].rate_bp > 0);
585
+ }
586
+ entry.stages = STAGES.filter(stage => reached[stage]);
587
+ }
588
+ const body = {
589
+ schema: AI_TRAJECTORY_SCHEMA,
590
+ host: owner,
591
+ catalog_version: CATALOG_VERSION,
592
+ operators,
593
+ totals,
594
+ reading_digest: readingDigest,
595
+ reading_valid: readingValid,
596
+ unattributed_engines: unattributed,
597
+ score_weights: { ...SCORE_WEIGHTS },
598
+ non_claims: [...TRAJECTORY_NON_CLAIMS],
599
+ };
600
+ return { ...body, digest: digest(body) };
601
+ }
602
+
603
+ return { auditAiReadiness, summarizeAiTrajectory };
604
+ }
605
+
606
+ module.exports = {
607
+ AI_VISIBILITY_CATALOG: CATALOG,
608
+ AI_REFERRAL_SCHEMA,
609
+ AI_CRAWLER_SCHEMA,
610
+ AI_READINESS_SCHEMA,
611
+ AI_TRAJECTORY_SCHEMA,
612
+ AiVisibilityError,
613
+ classifyAiReferral,
614
+ classifyAiCrawler,
615
+ createAiVisibilityApi,
616
+ };
@@ -20,8 +20,9 @@ const BOTS = [
20
20
  ["GPTBot", "OpenAI"],
21
21
  ["OAI-SearchBot", "OpenAI"],
22
22
  ["ChatGPT-User", "OpenAI"],
23
- ["ClaudeBot", "Anthropic"],
23
+ ["Claude-SearchBot", "Anthropic"],
24
24
  ["Claude-User", "Anthropic"],
25
+ ["ClaudeBot", "Anthropic"],
25
26
  ["PerplexityBot", "Perplexity"],
26
27
  ["Perplexity-User", "Perplexity"],
27
28
  ["Googlebot", "Google"],
package/js/discovery.d.ts CHANGED
@@ -1,4 +1,25 @@
1
1
  export {
2
+ AI_CRAWLER_SCHEMA,
3
+ AI_READINESS_SCHEMA,
4
+ AI_REFERRAL_SCHEMA,
5
+ AI_TRAJECTORY_SCHEMA,
6
+ AI_VISIBILITY_CATALOG,
7
+ AiCrawler,
8
+ AiCrawlerPurpose,
9
+ AiReadinessCheck,
10
+ AiReadinessReport,
11
+ AiReferral,
12
+ AiReferralRow,
13
+ AiTrajectory,
14
+ AiTrajectoryOperator,
15
+ AiTrajectorySignal,
16
+ AiTrajectoryStage,
17
+ AiVisibilityCatalog,
18
+ AiVisibilityError,
19
+ auditAiReadiness,
20
+ classifyAiCrawler,
21
+ classifyAiReferral,
22
+ summarizeAiTrajectory,
2
23
  A2AAgentCardSignature,
3
24
  A2AInterface,
4
25
  A2AJwks,
package/js/discovery.js CHANGED
@@ -17,6 +17,16 @@ module.exports = {
17
17
  normalizeEngineObservation: impl.engineObservationApi.normalizeEngineObservation,
18
18
  summarizeEngineObservations: impl.engineObservationApi.summarizeEngineObservations,
19
19
  summarizeCohortVisibility: impl.engineObservationApi.summarizeCohortVisibility,
20
+ AI_VISIBILITY_CATALOG: impl.aiVisibility.AI_VISIBILITY_CATALOG,
21
+ AI_REFERRAL_SCHEMA: impl.aiVisibility.AI_REFERRAL_SCHEMA,
22
+ AI_CRAWLER_SCHEMA: impl.aiVisibility.AI_CRAWLER_SCHEMA,
23
+ AI_READINESS_SCHEMA: impl.aiVisibility.AI_READINESS_SCHEMA,
24
+ AI_TRAJECTORY_SCHEMA: impl.aiVisibility.AI_TRAJECTORY_SCHEMA,
25
+ AiVisibilityError: impl.aiVisibility.AiVisibilityError,
26
+ classifyAiReferral: impl.aiVisibility.classifyAiReferral,
27
+ classifyAiCrawler: impl.aiVisibility.classifyAiCrawler,
28
+ auditAiReadiness: impl.aiVisibilityApi.auditAiReadiness,
29
+ summarizeAiTrajectory: impl.aiVisibilityApi.summarizeAiTrajectory,
20
30
  createSovereignCard: impl.createSovereignCard,
21
31
  createTwin3AgentCard: impl.createTwin3AgentCard,
22
32
  createAgentCard: impl.createAgentCard,
package/js/discovery.mjs CHANGED
@@ -14,6 +14,16 @@ export const EngineObservationError = api.EngineObservationError;
14
14
  export const normalizeEngineObservation = api.normalizeEngineObservation;
15
15
  export const summarizeEngineObservations = api.summarizeEngineObservations;
16
16
  export const summarizeCohortVisibility = api.summarizeCohortVisibility;
17
+ export const AI_VISIBILITY_CATALOG = api.AI_VISIBILITY_CATALOG;
18
+ export const AI_REFERRAL_SCHEMA = api.AI_REFERRAL_SCHEMA;
19
+ export const AI_CRAWLER_SCHEMA = api.AI_CRAWLER_SCHEMA;
20
+ export const AI_READINESS_SCHEMA = api.AI_READINESS_SCHEMA;
21
+ export const AI_TRAJECTORY_SCHEMA = api.AI_TRAJECTORY_SCHEMA;
22
+ export const AiVisibilityError = api.AiVisibilityError;
23
+ export const classifyAiReferral = api.classifyAiReferral;
24
+ export const classifyAiCrawler = api.classifyAiCrawler;
25
+ export const auditAiReadiness = api.auditAiReadiness;
26
+ export const summarizeAiTrajectory = api.summarizeAiTrajectory;
17
27
  export const createSovereignCard = api.createSovereignCard;
18
28
  export const createTwin3AgentCard = api.createTwin3AgentCard;
19
29
  export const createAgentCard = api.createAgentCard;
package/js/index.js CHANGED
@@ -45,6 +45,9 @@ module.exports = {
45
45
  normalizeEngineObservation: discovery.normalizeEngineObservation,
46
46
  summarizeEngineObservations: discovery.summarizeEngineObservations,
47
47
  summarizeCohortVisibility: discovery.summarizeCohortVisibility,
48
+ AI_VISIBILITY_CATALOG: discovery.AI_VISIBILITY_CATALOG, AiVisibilityError: discovery.AiVisibilityError,
49
+ AI_REFERRAL_SCHEMA: discovery.AI_REFERRAL_SCHEMA, AI_CRAWLER_SCHEMA: discovery.AI_CRAWLER_SCHEMA, AI_READINESS_SCHEMA: discovery.AI_READINESS_SCHEMA, AI_TRAJECTORY_SCHEMA: discovery.AI_TRAJECTORY_SCHEMA,
50
+ classifyAiReferral: discovery.classifyAiReferral, classifyAiCrawler: discovery.classifyAiCrawler, auditAiReadiness: discovery.auditAiReadiness, summarizeAiTrajectory: discovery.summarizeAiTrajectory,
48
51
  AuthorizationLeaseError: shield.AuthorizationLeaseError,
49
52
  verifyAuthorizationLease: shield.verifyAuthorizationLease,
50
53
  leaseClearanceContext: shield.leaseClearanceContext,
package/js/index.mjs CHANGED
@@ -246,6 +246,16 @@ export const signEnvelopeWithSigner = api.signEnvelopeWithSigner;
246
246
  export const summarizeCohortVisibility = api.summarizeCohortVisibility;
247
247
  export const summarizeDiscoveryEvidence = api.summarizeDiscoveryEvidence;
248
248
  export const summarizeEngineObservations = api.summarizeEngineObservations;
249
+ export const AI_VISIBILITY_CATALOG = api.AI_VISIBILITY_CATALOG;
250
+ export const AI_REFERRAL_SCHEMA = api.AI_REFERRAL_SCHEMA;
251
+ export const AI_CRAWLER_SCHEMA = api.AI_CRAWLER_SCHEMA;
252
+ export const AI_READINESS_SCHEMA = api.AI_READINESS_SCHEMA;
253
+ export const AI_TRAJECTORY_SCHEMA = api.AI_TRAJECTORY_SCHEMA;
254
+ export const AiVisibilityError = api.AiVisibilityError;
255
+ export const classifyAiReferral = api.classifyAiReferral;
256
+ export const classifyAiCrawler = api.classifyAiCrawler;
257
+ export const auditAiReadiness = api.auditAiReadiness;
258
+ export const summarizeAiTrajectory = api.summarizeAiTrajectory;
249
259
  export const taskTrustLine = api.taskTrustLine;
250
260
  export const toolAuthorityMatches = api.toolAuthorityMatches;
251
261
  export const unresolvedPropositions = api.unresolvedPropositions;
@@ -5,6 +5,7 @@ const trust = require("./trust.js");
5
5
 
6
6
  const discoveryEvidence = require("../discovery-evidence.js");
7
7
  const engineObservation = require("../engine-observation.js");
8
+ const aiVisibility = require("../ai-visibility.js");
8
9
  const { validId } = require("../id-format.js");
9
10
  const SOVEREIGN_CARD_SCHEMA = "twin3-discovery-sovereign-card-v1";
10
11
  const CARD_CANONICALIZATION = "rfc8785";
@@ -1039,28 +1040,60 @@ function parseX402DnsTxt(records, host) {
1039
1040
  scheme: (tags.scheme || "").split(",").filter(Boolean),
1040
1041
  };
1041
1042
  }
1042
- function x402AddressIsUnsafe(address) {
1043
- const text = String(address).trim();
1044
- if (require("node:net").isIP(text) === 0 || text.includes("%"))
1045
- core.fail("x402_fetch_address_invalid");
1043
+ function x402V4IsUnsafe(a, b, c) {
1044
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
1045
+ || (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2) || (a === 192 && b === 168)
1046
+ || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)
1047
+ || a >= 224;
1048
+ }
1049
+ function x402ParseV4(text) {
1046
1050
  const v4 = text.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
1047
- if (v4) {
1048
- const [a, b] = [Number(v4[1]), Number(v4[2])];
1049
- if ([a, b, Number(v4[3]), Number(v4[4])].some((n) => n > 255))
1051
+ if (!v4)
1052
+ return null;
1053
+ const octets = v4.slice(1).map(Number);
1054
+ if (octets.some((n) => n > 255))
1055
+ core.fail("x402_fetch_address_invalid");
1056
+ return octets;
1057
+ }
1058
+ function x402ParseV6(text) {
1059
+ if (!/^[0-9a-f:.]+$/.test(text) || (text.match(/::/g) || []).length > 1)
1060
+ core.fail("x402_fetch_address_invalid");
1061
+ let groups = text.split(":");
1062
+ const tail = groups[groups.length - 1];
1063
+ if (tail.includes(".")) {
1064
+ const octets = x402ParseV4(tail);
1065
+ if (!octets)
1050
1066
  core.fail("x402_fetch_address_invalid");
1051
- return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
1052
- || (a === 192 && b === 0 && Number(v4[3]) === 0) || (a === 192 && b === 0 && Number(v4[3]) === 2) || (a === 192 && b === 168)
1053
- || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && Number(v4[3]) === 100) || (a === 203 && b === 0 && Number(v4[3]) === 113)
1054
- || a >= 224;
1067
+ groups = [...groups.slice(0, -1), ((octets[0] << 8) | octets[1]).toString(16), ((octets[2] << 8) | octets[3]).toString(16)];
1055
1068
  }
1056
- if (text.includes(":")) {
1057
- const lower = new URL(`https://[${text}]/`).hostname.slice(1, -1);
1058
- const [first, second] = lower.split(":").map((part) => parseInt(part || "0", 16));
1059
- return first < 0x2000 || first > 0x3fff || first === 0x2002
1060
- || (first === 0x2001 && (second < 0x200 || second === 0xdb8))
1061
- || (first === 0x3fff && second < 0x1000);
1069
+ const gap = groups.indexOf("");
1070
+ if (text.includes("::")) {
1071
+ const head = text.startsWith("::") ? [] : groups.slice(0, gap);
1072
+ const rest = groups.slice(groups.lastIndexOf("") + 1);
1073
+ if (head.length + rest.length > 7)
1074
+ core.fail("x402_fetch_address_invalid");
1075
+ groups = [...head, ...Array(8 - head.length - rest.length).fill("0"), ...rest];
1062
1076
  }
1063
- core.fail("x402_fetch_address_invalid");
1077
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group)))
1078
+ core.fail("x402_fetch_address_invalid");
1079
+ return groups.map((group) => parseInt(group, 16));
1080
+ }
1081
+ function x402AddressIsUnsafe(address) {
1082
+ const text = String(address).trim().toLowerCase();
1083
+ const v4 = x402ParseV4(text);
1084
+ if (v4)
1085
+ return x402V4IsUnsafe(v4[0], v4[1], v4[2]);
1086
+ if (!text.includes(":"))
1087
+ core.fail("x402_fetch_address_invalid");
1088
+ const h = x402ParseV6(text);
1089
+ if (h.slice(0, 6).every((n) => n === 0))
1090
+ return true;
1091
+ if (h.slice(0, 5).every((n) => n === 0) && h[5] === 0xffff)
1092
+ return true;
1093
+ if ((h[0] === 0x64 && h[1] === 0xff9b) || h[0] === 0x2002)
1094
+ return true;
1095
+ return h[0] < 0x2000 || h[0] > 0x3fff || (h[0] === 0x2001 && (h[1] < 0x0200 || h[1] === 0x0db8))
1096
+ || (h[0] === 0x3fff && h[1] < 0x1000);
1064
1097
  }
1065
1098
  function checkX402FetchTarget(url, host, { resolvedAddresses = [], hop = 0, maxRedirects = X402_FETCH_BOUNDS.max_redirects } = {}) {
1066
1099
  const safeHost = x402Host(host);
@@ -1244,10 +1277,12 @@ function canStrangerDecide(declaration) {
1244
1277
  }
1245
1278
  const discoveryEvidenceApi = discoveryEvidence.createDiscoveryEvidenceApi(core.canonicalJsonBuffer);
1246
1279
  const engineObservationApi = engineObservation.createEngineObservationApi(core.canonicalJsonBuffer);
1280
+ const aiVisibilityApi = aiVisibility.createAiVisibilityApi(core.canonicalJsonBuffer);
1247
1281
 
1248
1282
  module.exports = {
1249
1283
  discoveryEvidence,
1250
1284
  engineObservation,
1285
+ aiVisibility,
1251
1286
  validId,
1252
1287
  SOVEREIGN_CARD_SCHEMA,
1253
1288
  CARD_CANONICALIZATION,
@@ -1367,4 +1402,5 @@ module.exports = {
1367
1402
  canStrangerDecide,
1368
1403
  discoveryEvidenceApi,
1369
1404
  engineObservationApi,
1405
+ aiVisibilityApi,
1370
1406
  };
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  const contracts = require("./runtime.js");
4
+ const { occurrenceContext, occurrenceId } = require("./shield.js");
4
5
 
5
6
  const copy = value => JSON.parse(JSON.stringify(value));
6
7
  const fail = code => { throw new contracts.RuntimeContractError(code); };
@@ -23,15 +24,17 @@ function taskSnapshot(state, replayed = false) {
23
24
  return copy({ task_id: state.task_id, run_id: state.run_id, request_id: state.request_id, revision: state.revision,
24
25
  execution_status: state.execution_status, approval_status: state.approval_status, effect_outcome: state.effect_outcome,
25
26
  reply: state.reply, error: state.error, operation_id: state.operation_id, result: state.result,
26
- preview: state.preview, available_actions: availableActions, events: state.events, cursor: state.revision, replayed });
27
+ preview: state.preview, available_actions: availableActions, events: state.events, cursor: state.revision, replayed,
28
+ ...(state.reconciliation ? { reconciliation: state.reconciliation } : {}) });
27
29
  }
28
30
 
29
31
  class AgentRuntime {
30
- constructor({ store, resolvePrincipal, resolveContext, engine, resolveSkill, resolveExecution, evaluateAction = async () => "ask", clock = () => Math.floor(Date.now() / 1000), timeoutMs = 30000 }) {
32
+ constructor({ store, resolvePrincipal, resolveContext, engine, resolveSkill, resolveExecution, resolveOutcome = null, evaluateAction = async () => "ask", clock = () => Math.floor(Date.now() / 1000), timeoutMs = 30000 }) {
31
33
  if (!store || typeof store.get !== "function" || typeof store.transact !== "function"
32
34
  || ![resolvePrincipal, resolveContext, engine, resolveSkill, resolveExecution, evaluateAction, clock].every(value => typeof value === "function")) fail("runtime_ports_required");
33
35
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300000) fail("runtime_timeout_invalid");
34
- Object.assign(this, { store, resolvePrincipal, resolveContext, engine, resolveSkill, resolveExecution, evaluateAction, clock, timeoutMs });
36
+ if (resolveOutcome !== null && typeof resolveOutcome !== "function") fail("runtime_ports_required");
37
+ Object.assign(this, { store, resolvePrincipal, resolveContext, engine, resolveSkill, resolveExecution, resolveOutcome, evaluateAction, clock, timeoutMs });
35
38
  this.controllers = new Map();
36
39
  }
37
40
 
@@ -253,6 +256,75 @@ class AgentRuntime {
253
256
  });
254
257
  }
255
258
 
259
+ async reconcile(caller, { task_id: taskId, expected_revision: expectedRevision, request_id: requestId }) {
260
+ boundedText(requestId, 128);
261
+ if (!Number.isSafeInteger(expectedRevision)) fail("runtime_request_invalid");
262
+ if (!this.resolveOutcome) fail("runtime_reconciliation_unavailable");
263
+ const principal = await this.principal(caller), key = this.key(principal, taskId);
264
+ const command = contracts.runtimeDigest({ operation: "reconcile", task_id: taskId, expected_revision: expectedRevision, request_id: requestId });
265
+ const state = await this.store.get(key);
266
+ if (!state) fail("runtime_task_not_found");
267
+ if (Object.hasOwn(state.commands, requestId)) {
268
+ if (state.commands[requestId] !== command) fail("runtime_request_conflict");
269
+ if (contracts.runtimeDigest(await this.principal(caller)) !== contracts.runtimeDigest(principal)) fail("runtime_authority_changed");
270
+ return taskSnapshot(state, true);
271
+ }
272
+ if (state.revision !== expectedRevision) fail("runtime_revision_conflict");
273
+ if (state.execution_status !== "outcome_unknown" || state.effect_outcome !== "unknown" || !state.preview || !state.context) fail("runtime_reconciliation_not_available");
274
+ const preview = contracts.createExecutionPreview({ principal: state.principal, context: state.context, actions: state.preview.actions,
275
+ task_id: taskId, request_id: state.preview.request_id, revision: state.preview.revision, expires_at: state.preview.expires_at });
276
+ if (contracts.runtimeDigest(preview) !== contracts.runtimeDigest(state.preview) || preview.actions.length !== 1) fail("runtime_preview_changed");
277
+ const original = state.principal;
278
+ if (["kind", "tenant", "subject", "owner", "claim_generation"].some(field => original[field] !== principal[field])) fail("runtime_authority_changed");
279
+ const operationId = occurrenceId(occurrenceContext({ tenant: original.tenant, taskId, plan: preview.actions, index: 0, epoch: original.epoch }));
280
+ if (state.operation_id && state.operation_id !== operationId) fail("runtime_operation_changed");
281
+ const identity = { operation_id: operationId, action_digest: contracts.runtimeDigest(preview.actions[0]),
282
+ context_digest: contracts.runtimeDigest(state.context), principal_digest: contracts.runtimeDigest(original) };
283
+ const controller = new AbortController();
284
+ let timer;
285
+ const returned = await Promise.race([
286
+ Promise.resolve().then(() => this.resolveOutcome(caller, copy(identity), controller.signal)),
287
+ new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new contracts.RuntimeContractError("runtime_reconciliation_timeout")); }, this.timeoutMs); })
288
+ ]).finally(() => clearTimeout(timer));
289
+ contracts.runtimeDigest(returned);
290
+ const outcome = copy(returned);
291
+ if (!outcome || !["confirmed", "not_submitted", "unknown"].includes(outcome.effect_outcome)
292
+ || Object.keys(outcome).some(field => ![...Object.keys(identity), "effect_outcome", "evidence_ref", "result"].includes(field))
293
+ || Object.entries(identity).some(([field, value]) => outcome[field] !== value)) fail("runtime_reconciliation_invalid");
294
+ if (contracts.runtimeDigest(await this.principal(caller)) !== contracts.runtimeDigest(principal)) fail("runtime_authority_changed");
295
+ if (outcome.effect_outcome === "unknown") {
296
+ const latest = await this.store.get(key);
297
+ if (contracts.runtimeDigest(await this.principal(caller)) !== contracts.runtimeDigest(principal)) fail("runtime_authority_changed");
298
+ return taskSnapshot(latest);
299
+ }
300
+ const evidence = boundedText(outcome.evidence_ref, 512);
301
+ if (/[\u0000-\u001f\u007f]/u.test(evidence) || outcome.effect_outcome === "confirmed" && !Object.hasOwn(outcome, "result")) fail("runtime_reconciliation_invalid");
302
+ const result = outcome.effect_outcome === "confirmed" ? outcome.result : null;
303
+ contracts.runtimeDigest(result);
304
+ if (Buffer.byteLength(JSON.stringify(result), "utf8") > 65536) fail("runtime_payload_too_large");
305
+ return this.store.transact(key, async current => {
306
+ if (!current) fail("runtime_task_not_found");
307
+ if (Object.hasOwn(current.commands, requestId)) {
308
+ if (current.commands[requestId] !== command) fail("runtime_request_conflict");
309
+ return { state: current, value: taskSnapshot(current, true) };
310
+ }
311
+ if (current.revision !== expectedRevision || current.execution_status !== "outcome_unknown"
312
+ || contracts.runtimeDigest(current.preview) !== contracts.runtimeDigest(preview)
313
+ || contracts.runtimeDigest(current.context) !== identity.context_digest
314
+ || contracts.runtimeDigest(current.principal) !== identity.principal_digest) fail("runtime_revision_conflict");
315
+ if (contracts.runtimeDigest(await this.principal(caller)) !== contracts.runtimeDigest(principal)) fail("runtime_authority_changed");
316
+ current.commands = { ...current.commands, [requestId]: command };
317
+ current.operation_id = operationId;
318
+ current.effect_outcome = outcome.effect_outcome;
319
+ current.execution_status = outcome.effect_outcome === "confirmed" ? "completed" : "blocked";
320
+ current.result = result;
321
+ current.error = outcome.effect_outcome === "confirmed" ? null : "runtime_action_not_submitted";
322
+ current.reconciliation = { evidence_ref: evidence, ...identity };
323
+ appendEvent(current, "reconciled", this.clock());
324
+ return { state: current, value: taskSnapshot(current) };
325
+ });
326
+ }
327
+
256
328
  async cancel(caller, { task_id: taskId, expected_revision: expectedRevision, request_id: requestId }) {
257
329
  boundedText(requestId, 128);
258
330
  const principal = await this.principal(caller);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "twin3-sdk",
3
- "version": "0.2.20-pre-alpha",
3
+ "version": "0.3.0",
4
4
  "description": "Universal Capability Motherboard for Autonomous AI Agents and Self-Sovereign Humans (JS/Node runtime)",
5
5
  "type": "commonjs",
6
6
  "main": "./js/index.js",
@@ -83,9 +83,11 @@
83
83
  "js/index.js",
84
84
  "js/discovery-evidence.js",
85
85
  "js/engine-observation.js",
86
+ "js/ai-visibility.js",
86
87
  "js/id-format.js",
87
88
  "twin3/discovery/id-format.v1.json",
88
89
  "twin3/discovery/evidence-i18n.json",
90
+ "twin3/discovery/ai-visibility-catalog.json",
89
91
  "js/comm/counterparty.js",
90
92
  "js/comm/completion.js",
91
93
  "twin3/comm/task-completion-command.schema.json",
@@ -138,7 +140,7 @@
138
140
  "publishConfig": {
139
141
  "access": "public",
140
142
  "registry": "https://registry.npmjs.org/",
141
- "tag": "next"
143
+ "tag": "latest"
142
144
  },
143
145
  "scripts": {
144
146
  "test": "node --test tests/*.test.js"
package/runtime.d.ts CHANGED
@@ -115,7 +115,19 @@ export interface RuntimeTaskSnapshot {
115
115
  events: { event_id: string; task_id: string; run_id: string; cursor: number; type: string; at: number }[];
116
116
  cursor: number;
117
117
  replayed: boolean;
118
+ reconciliation?: RuntimeReconciliationIdentity & { evidence_ref: string };
118
119
  }
120
+ export interface RuntimeReconciliationIdentity {
121
+ operation_id: string;
122
+ action_digest: string;
123
+ context_digest: string;
124
+ principal_digest: string;
125
+ }
126
+ export type RuntimeReconciliationOutcome = RuntimeReconciliationIdentity & (
127
+ | { effect_outcome: "unknown" }
128
+ | { effect_outcome: "not_submitted"; evidence_ref: string }
129
+ | { effect_outcome: "confirmed"; evidence_ref: string; result: JsonValue }
130
+ );
119
131
  export interface RuntimeTaskStore {
120
132
  get(key: string): Promise<unknown>;
121
133
  transact<T>(key: string, update: (state: unknown) => Promise<{ state: unknown; value: T }> | { state: unknown; value: T }): Promise<T>;
@@ -129,6 +141,7 @@ export declare class AgentRuntime<Caller = unknown> {
129
141
  engine(input: { intent: string; context: ContextEnvelope; principal: PrincipalContext; request_id: string; signal: AbortSignal }): Promise<{ reply: string; actions: unknown[] }>;
130
142
  resolveSkill(caller: Caller, skillId: string): Promise<{ ready: boolean; manifest: SkillManifest } | null>;
131
143
  resolveExecution(caller: Caller, state: unknown): Promise<{ gate: RuntimeGate; broker: RuntimeBroker } | null>;
144
+ resolveOutcome?(caller: Caller, identity: RuntimeReconciliationIdentity, signal: AbortSignal): Promise<RuntimeReconciliationOutcome>;
132
145
  evaluateAction?(caller: Caller, action: SkillAction, context: ContextEnvelope): Promise<"allow" | "ask" | "deny">;
133
146
  clock?: () => number;
134
147
  timeoutMs?: number;
@@ -137,5 +150,6 @@ export declare class AgentRuntime<Caller = unknown> {
137
150
  get(caller: Caller, taskId: string): Promise<RuntimeTaskSnapshot>;
138
151
  decide(caller: Caller, input: { task_id: string; preview_id: string; expected_revision: number; decision: "approve" | "deny"; request_id: string }): Promise<RuntimeTaskSnapshot>;
139
152
  execute(caller: Caller, input: { task_id: string; expected_revision: number; request_id: string }): Promise<RuntimeTaskSnapshot>;
153
+ reconcile(caller: Caller, input: { task_id: string; expected_revision: number; request_id: string }): Promise<RuntimeTaskSnapshot>;
140
154
  cancel(caller: Caller, input: { task_id: string; expected_revision: number; request_id: string }): Promise<RuntimeTaskSnapshot>;
141
155
  }
@@ -0,0 +1,68 @@
1
+ {
2
+ "version": 1,
3
+ "checked_on": "2026-10-27",
4
+ "provenance": [
5
+ {
6
+ "name": "ansvisor",
7
+ "url": "https://github.com/ansvisor/ansvisor",
8
+ "license": "MIT",
9
+ "used": ["server/src/lib/ai-sources.json v3", "server/src/lib/ai-bots.json v1", "server/src/config/visibility-score.js weights"]
10
+ },
11
+ {
12
+ "name": "claude-seo",
13
+ "url": "https://github.com/AgriciDaniel/claude-seo/tree/6be93f45064ffbadced572b5d5c95f55fd263af0",
14
+ "license": "MIT",
15
+ "used": ["scripts/agentic_check.py AI_AGENTS and RFC 9309 group selection", "skills/seo-geo crawler claim table"]
16
+ },
17
+ {
18
+ "name": "GEOFlow",
19
+ "url": "https://github.com/yaojingang/GEOFlow",
20
+ "license": "AGPL-3.0",
21
+ "used": ["publish quality-gate concept only; no code or data copied"]
22
+ }
23
+ ],
24
+ "referral_sources": [
25
+ { "key": "chatgpt.com", "operator": "OpenAI", "hosts": ["chatgpt.com", "chat.openai.com"], "aliases": ["chatgpt", "openai", "openai.com"], "surfaces": [{ "key": "shopping", "medium": "feed" }] },
26
+ { "key": "perplexity.ai", "operator": "Perplexity", "hosts": ["perplexity.ai"], "aliases": ["perplexity"] },
27
+ { "key": "claude.ai", "operator": "Anthropic", "hosts": ["claude.ai"], "aliases": ["claude"] },
28
+ { "key": "gemini.google.com", "operator": "Google", "hosts": ["gemini.google.com", "bard.google.com"], "aliases": ["gemini", "bard"] },
29
+ { "key": "copilot.microsoft.com", "operator": "Microsoft", "hosts": ["copilot.microsoft.com", "copilot.com"], "aliases": ["copilot"] },
30
+ { "key": "grok.com", "operator": "xAI", "hosts": ["grok.com"], "aliases": ["grok"] },
31
+ { "key": "deepseek.com", "operator": "DeepSeek", "hosts": ["deepseek.com"], "aliases": ["deepseek"] },
32
+ { "key": "meta.ai", "operator": "Meta", "hosts": ["meta.ai"], "aliases": [] },
33
+ { "key": "you.com", "operator": "You.com", "hosts": ["you.com"], "aliases": [] },
34
+ { "key": "phind.com", "operator": "Phind", "hosts": ["phind.com"], "aliases": [] },
35
+ { "key": "poe.com", "operator": "Quora", "hosts": ["poe.com"], "aliases": [] },
36
+ { "key": "labs.google", "operator": "Google", "hosts": ["labs.google"], "aliases": [] },
37
+ { "key": "bing.com", "operator": "Microsoft", "hosts": ["bing.com"], "aliases": [], "referrer_path": "/ck/a", "utm": false }
38
+ ],
39
+ "crawlers": [
40
+ { "token": "GPTBot", "operator": "OpenAI", "purpose": "training", "robots": "honours", "user_agent": true },
41
+ { "token": "OAI-SearchBot", "operator": "OpenAI", "purpose": "search", "robots": "honours", "user_agent": true, "claim": "citable_in_chatgpt_search" },
42
+ { "token": "ChatGPT-User", "operator": "OpenAI", "purpose": "user", "robots": "may_not_apply", "user_agent": true },
43
+ { "token": "ClaudeBot", "operator": "Anthropic", "purpose": "training", "robots": "honours", "user_agent": true },
44
+ { "token": "Claude-SearchBot", "operator": "Anthropic", "purpose": "search", "robots": "honours", "user_agent": true, "claim": "citable_in_claude_search" },
45
+ { "token": "Claude-User", "operator": "Anthropic", "purpose": "user", "robots": "honours", "user_agent": true },
46
+ { "token": "PerplexityBot", "operator": "Perplexity", "purpose": "search", "robots": "honours", "user_agent": true, "claim": "citable_in_perplexity" },
47
+ { "token": "Perplexity-User", "operator": "Perplexity", "purpose": "user", "robots": "generally_ignores", "user_agent": true },
48
+ { "token": "Googlebot", "operator": "Google", "purpose": "search", "robots": "honours", "user_agent": true, "claim": "eligible_for_google_ai_overviews" },
49
+ { "token": "Google-Extended", "operator": "Google", "purpose": "control", "robots": "honours", "user_agent": false },
50
+ { "token": "Google-Agent", "operator": "Google", "purpose": "user", "robots": "generally_ignores", "user_agent": true },
51
+ { "token": "Google-CloudVertexBot", "operator": "Google", "purpose": "user", "robots": "honours", "user_agent": true },
52
+ { "token": "Bingbot", "operator": "Microsoft", "purpose": "search", "robots": "honours", "user_agent": true, "claim": "eligible_for_copilot_and_bing" },
53
+ { "token": "Applebot", "operator": "Apple", "purpose": "search", "robots": "honours", "user_agent": true },
54
+ { "token": "Applebot-Extended", "operator": "Apple", "purpose": "control", "robots": "honours", "user_agent": false },
55
+ { "token": "CCBot", "operator": "Common Crawl", "purpose": "training", "robots": "honours", "user_agent": true },
56
+ { "token": "A2A-Client", "operator": "A2A", "purpose": "agent", "robots": "not_applicable", "user_agent": true },
57
+ { "token": "WebMCP-Client", "operator": "WebMCP", "purpose": "agent", "robots": "not_applicable", "user_agent": true }
58
+ ],
59
+ "stages": [
60
+ { "key": "crawled", "en": "Fetched by an AI crawler", "zh-TW": "被 AI 爬蟲抓取" },
61
+ { "key": "search_indexed", "en": "Fetched by an AI search crawler", "zh-TW": "被 AI 搜尋爬蟲收錄" },
62
+ { "key": "user_fetched", "en": "Fetched for a person's question", "zh-TW": "因使用者提問被即時讀取" },
63
+ { "key": "retrieved", "en": "Retrieved in an AI answer run", "zh-TW": "在 AI 回答中被檢索" },
64
+ { "key": "mentioned", "en": "Mentioned in an AI answer", "zh-TW": "在 AI 回答中被提及" },
65
+ { "key": "cited", "en": "Cited as a source", "zh-TW": "被列為引用來源" },
66
+ { "key": "clicked", "en": "Visited from an AI answer", "zh-TW": "從 AI 回答點擊進站" }
67
+ ]
68
+ }