@twin3-ai/agent-id 0.2.0 → 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/b1-sync.js ADDED
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const crypto = require("node:crypto");
6
+
7
+ const GA4_ENDPOINT = "https://analyticsdata.googleapis.com/v1beta/properties";
8
+ const AI_HOSTS = Object.freeze(["chat.openai.com", "chatgpt.com", "claude.ai", "copilot.microsoft.com", "gemini.google.com", "perplexity.ai"]);
9
+
10
+ function hash(value) { return crypto.createHash("sha256").update(String(value)).digest("hex"); }
11
+ function emptyState() { return { schema: "agentx-b1-sync-state-v1", cursor: "", pending: null, seen: [], retry_at: 0, attempts: 0, next_run_at: 0, last_sync_at: 0, last_error: null }; }
12
+ function loadState(statePath) {
13
+ try { return { ...emptyState(), ...JSON.parse(fs.readFileSync(statePath, "utf8")) }; }
14
+ catch (error) { if (error.code === "ENOENT") return emptyState(); throw new Error("b1_sync_state_invalid"); }
15
+ }
16
+ function saveState(statePath, state) {
17
+ const target = path.resolve(statePath); fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
18
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
19
+ try { fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode:0o600, flag:"wx" }); fs.renameSync(temporary, target); fs.chmodSync(target, 0o600); }
20
+ finally { try { fs.rmSync(temporary, { force:true }); } catch (_error) {} }
21
+ }
22
+ function aiHost(value) {
23
+ const raw = String(value || "").trim().toLowerCase();
24
+ if (!raw || ["(direct)", "(none)", "(not set)"].includes(raw)) return "";
25
+ try {
26
+ const host = new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.replace(/\.$/, "");
27
+ return AI_HOSTS.find((known) => host === known || host.endsWith(`.${known}`)) ? host : "";
28
+ } catch (_error) { return ""; }
29
+ }
30
+ function safeLabel(value) { return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(String(value || "")) ? String(value) : ""; }
31
+ function reportMap(row, dimensionHeaders, metricHeaders) {
32
+ const dimensions = Object.fromEntries(dimensionHeaders.map((name, index) => [name, String(row.dimensionValues && row.dimensionValues[index] && row.dimensionValues[index].value || "")]));
33
+ const metrics = Object.fromEntries(metricHeaders.map((name, index) => [name, String(row.metricValues && row.metricValues[index] && row.metricValues[index].value || "0")]));
34
+ return { dimensions, metrics };
35
+ }
36
+ function ga4Conversions(response, siteUrl, windowKey) {
37
+ const dimensions = (response.dimensionHeaders || []).map((item) => item && item.name).filter(Boolean);
38
+ const metrics = (response.metricHeaders || []).map((item) => item && item.name).filter(Boolean);
39
+ return (response.rows || []).flatMap((row) => {
40
+ const mapped = reportMap(row || {}, dimensions, metrics);
41
+ const source = aiHost(mapped.dimensions.sessionSource);
42
+ const count = Math.max(0, Math.floor(Number(mapped.metrics.keyEvents || 0)));
43
+ const revenue = Math.max(0, Number(mapped.metrics.totalRevenue || 0));
44
+ if (!source || count < 1 || !Number.isFinite(revenue)) return [];
45
+ const campaign = safeLabel(mapped.dimensions.sessionCampaignName);
46
+ const currency = String(response.currencyCode || response.metadata && response.metadata.currencyCode || "USD").toUpperCase();
47
+ return [{ conversion_id:`ga4_${hash(JSON.stringify([windowKey, source, campaign])).slice(0,32)}`, event:"ga4_key_event", url:siteUrl, referrer:`https://${source}/`, utm_source:source, utm_campaign:campaign, conversion_count:count, value:Number(revenue.toFixed(2)), currency:/^[A-Z]{3}$/.test(currency)?currency:"USD", timestamp:Date.parse(`${windowKey}T23:59:59Z`) }];
48
+ });
49
+ }
50
+ async function defaultFetchGa4({ token, propertyId, startDate, endDate, fetchImpl = globalThis.fetch }) {
51
+ if (typeof fetchImpl !== "function") throw new Error("ga4_fetch_unavailable");
52
+ const response = await fetchImpl(`${GA4_ENDPOINT}/${encodeURIComponent(propertyId)}:runReport`, { method:"POST", headers:{ authorization:`Bearer ${token}`, "content-type":"application/json" }, body:JSON.stringify({ dateRanges:[{startDate,endDate}], dimensions:[{name:"sessionSource"},{name:"sessionMedium"},{name:"sessionCampaignName"}], metrics:[{name:"sessions"},{name:"keyEvents"},{name:"totalRevenue"}] }), redirect:"error" });
53
+ if (!response.ok) throw new Error(`ga4_http_${response.status}`);
54
+ const body = await response.json(); if (body.error) throw new Error("ga4_provider_error"); return body;
55
+ }
56
+ function createBaseSync({ client, statePath, siteUrl, intervalMs, collect, now, random }) {
57
+ if (!client || typeof client.conversions !== "function" || !statePath || !siteUrl || typeof collect !== "function") throw new Error("b1_sync_configuration_invalid");
58
+ const url = new URL(siteUrl).origin; const state = loadState(statePath); const persist = () => saveState(statePath, state);
59
+ async function upload(batch, cursor) {
60
+ try {
61
+ const result = batch.length ? await client.conversions({ url, conversions:batch, source:"b1_automatic_sync" }) : { stored:0 };
62
+ state.pending=null; state.cursor=cursor; state.seen=[...new Set([...state.seen,...batch.map(item=>item.conversion_id)])].slice(-10000); state.retry_at=0; state.attempts=0; state.last_sync_at=now(); state.next_run_at=state.last_sync_at+Math.max(60000,intervalMs); state.last_error=null; persist();
63
+ return {ok:true,status:"synced",rows:batch.length,conversions:batch.reduce((sum,item)=>sum+Math.max(1,Number(item.conversion_count||1)),0),stored:Number(result.stored||0),cursor};
64
+ } catch (error) {
65
+ state.pending={batch:batch.slice(0,5000),cursor}; state.attempts+=1; const backoff=Math.min(300000,1000*(2**Math.min(8,state.attempts-1))); state.retry_at=now()+Math.round(backoff*(0.5+random())); state.last_error={code:String(error.code||"b1_delivery_failed").slice(0,80)}; persist();
66
+ return {ok:false,status:"queued_for_retry",pending:state.pending.batch.length,retry_at:state.retry_at};
67
+ }
68
+ }
69
+ async function runOnce({force=false}={}) {
70
+ const timestamp=now(); if(!force&&state.retry_at>timestamp)return {ok:false,status:"retry_wait",retry_at:state.retry_at}; if(state.pending)return upload(state.pending.batch,state.pending.cursor); if(!force&&state.next_run_at>timestamp)return {ok:true,status:"not_due",next_run_at:state.next_run_at};
71
+ const collected=await collect({timestamp,cursor:state.cursor,seen:new Set(state.seen)}); return upload(collected.batch||[],collected.cursor||state.cursor);
72
+ }
73
+ async function status(){return {schema:state.schema,host:new URL(url).hostname,cursor:state.cursor,pending:state.pending?state.pending.batch.length:0,retry_at:state.retry_at,next_run_at:state.next_run_at,last_sync_at:state.last_sync_at,last_error:state.last_error};}
74
+ return Object.freeze({runOnce,status});
75
+ }
76
+ function createGa4B1Sync({client,statePath,siteUrl,propertyId,accessToken,fetchGa4=defaultFetchGa4,intervalMs=86400000,now=Date.now,random=Math.random}={}) {
77
+ if(!propertyId||!accessToken)throw new Error("ga4_b1_configuration_invalid");
78
+ return createBaseSync({client,statePath,siteUrl,intervalMs,now,random,collect:async({timestamp,cursor})=>{const date=new Date(timestamp-86400000).toISOString().slice(0,10);if(cursor===date)return {batch:[],cursor};const response=await fetchGa4({token:accessToken,propertyId,startDate:date,endDate:date});return {batch:ga4Conversions(response,new URL(siteUrl).origin,date),cursor:date};}});
79
+ }
80
+ function createOrderB1Sync({client,statePath,siteUrl,sourceId,loadOrders,intervalMs=3600000,now=Date.now,random=Math.random}={}) {
81
+ if(!safeLabel(sourceId)||typeof loadOrders!=="function")throw new Error("order_b1_configuration_invalid");
82
+ return createBaseSync({client,statePath,siteUrl,intervalMs,now,random,collect:async({timestamp,seen})=>{const rows=await loadOrders();const batch=(Array.isArray(rows)?rows:[]).flatMap((row)=>{const source=aiHost(row&&row.source);const rawId=String(row&&row.order_id||"");const receipt=rawId?`order_${hash(`${sourceId}:${rawId}`).slice(0,32)}`:"";const amount=Number(row&&row.amount||0);if(!source||!receipt||seen.has(receipt)||!Number.isFinite(amount)||amount<0)return [];return [{conversion_id:receipt,event:safeLabel(row.event)||"order",url:new URL(siteUrl).origin,referrer:`https://${source}/`,value:Number(amount.toFixed(2)),currency:/^[A-Z]{3}$/.test(String(row.currency||"").toUpperCase())?String(row.currency).toUpperCase():"USD",timestamp:Number(row.timestamp||timestamp)}];});return {batch,cursor:String(timestamp)};}});
83
+ }
84
+
85
+ module.exports={createGa4B1Sync,createOrderB1Sync,ga4Conversions,defaultFetchGa4};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { createSiteAgentClient } = require("../site-agent.js");
7
+ const { createGa4B1Sync, createOrderB1Sync } = require("../b1-sync.js");
8
+
9
+ function valueAfter(args, name, fallback = "") { const index=args.indexOf(name); return index>=0&&args[index+1]?args[index+1]:fallback; }
10
+ function help() {
11
+ console.log(`agent-id-b1-sync
12
+
13
+ Usage:
14
+ agent-id-b1-sync --ga4 --url https://example.com
15
+ agent-id-b1-sync --orders ./crm-orders.json --source-id crm-main --url https://example.com
16
+
17
+ Shared environment:
18
+ SITE_AGENT_KEY Agent ID Site Agent Key
19
+ AGENT_ID_ENDPOINT Parent Agent ID origin
20
+ AGENT_ID_B1_STATE Local cursor and retry state path
21
+
22
+ GA4 environment:
23
+ GA4_PROPERTY_ID GA4 numeric property ID
24
+ GA4_ACCESS_TOKEN Read-only Analytics Data API OAuth token
25
+ `);
26
+ }
27
+ async function main() {
28
+ const args=process.argv.slice(2); if(args.includes("--help")||args.includes("-h"))return help();
29
+ const siteUrl=valueAfter(args,"--url",process.env.SITE_URL||""); if(!siteUrl)throw new Error("--url or SITE_URL is required");
30
+ const endpoint=valueAfter(args,"--endpoint",process.env.AGENT_ID_ENDPOINT||"");
31
+ const statePath=valueAfter(args,"--state",process.env.AGENT_ID_B1_STATE||path.join(process.cwd(),".agent-id","b1-sync-state.json"));
32
+ const client=createSiteAgentClient({endpoint,agentKey:process.env.SITE_AGENT_KEY});
33
+ let sync;
34
+ if(args.includes("--ga4")) {
35
+ sync=createGa4B1Sync({client,statePath,siteUrl,propertyId:process.env.GA4_PROPERTY_ID,accessToken:process.env.GA4_ACCESS_TOKEN});
36
+ } else {
37
+ const orderPath=valueAfter(args,"--orders",""); const sourceId=valueAfter(args,"--source-id","");
38
+ if(!orderPath||!sourceId)throw new Error("--ga4 or both --orders and --source-id are required");
39
+ sync=createOrderB1Sync({client,statePath,siteUrl,sourceId,loadOrders:async()=>JSON.parse(fs.readFileSync(path.resolve(orderPath),"utf8"))});
40
+ }
41
+ const result=args.includes("--status")?await sync.status():await sync.runOnce({force:args.includes("--force")});
42
+ console.log(JSON.stringify(result,null,2));
43
+ }
44
+ main().catch((error)=>{console.error(error&&error.message?error.message:"b1_sync_failed");process.exit(1);});
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const { createSiteAgentClient } = require("../site-agent.js");
6
+ const { createCloudflareA1Sync } = require("../cloudflare-a1-sync.js");
7
+
8
+ function valueAfter(args, name, fallback = "") {
9
+ const index = args.indexOf(name);
10
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
11
+ }
12
+ function help() {
13
+ console.log(`agent-id-cloudflare-a1
14
+
15
+ Usage:
16
+ agent-id-cloudflare-a1 --url https://example.com --once
17
+ agent-id-cloudflare-a1 --url https://example.com --status
18
+
19
+ Required environment:
20
+ SITE_AGENT_KEY Agent ID Site Agent Key
21
+ CLOUDFLARE_API_TOKEN Read-only Cloudflare Analytics token
22
+ CLOUDFLARE_ZONE_ID Website zone ID
23
+
24
+ Optional environment:
25
+ AGENT_ID_ENDPOINT Parent Agent ID origin
26
+ AGENT_ID_A1_STATE Local cursor and retry state path
27
+ AGENT_ID_A1_INTERVAL_MS Minimum interval between successful syncs
28
+ `);
29
+ }
30
+ async function main() {
31
+ const args = process.argv.slice(2);
32
+ if (args.includes("--help") || args.includes("-h")) return help();
33
+ const siteUrl = valueAfter(args, "--url", process.env.SITE_URL || "");
34
+ const endpoint = valueAfter(args, "--endpoint", process.env.AGENT_ID_ENDPOINT || "");
35
+ const statePath = valueAfter(args, "--state", process.env.AGENT_ID_A1_STATE || path.join(process.cwd(), ".agent-id", "cloudflare-a1-state.json"));
36
+ if (!siteUrl) throw new Error("--url or SITE_URL is required");
37
+ const client = createSiteAgentClient({ endpoint, agentKey: process.env.SITE_AGENT_KEY });
38
+ const sync = createCloudflareA1Sync({
39
+ client,
40
+ statePath,
41
+ siteUrl,
42
+ zoneId: process.env.CLOUDFLARE_ZONE_ID,
43
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
44
+ intervalMs: Number(process.env.AGENT_ID_A1_INTERVAL_MS || 900000)
45
+ });
46
+ const result = args.includes("--status") ? await sync.status() : await sync.runOnce({ force: args.includes("--force") });
47
+ console.log(JSON.stringify(result, null, 2));
48
+ }
49
+
50
+ main().catch((error) => {
51
+ console.error(error && error.message ? error.message : "cloudflare_a1_sync_failed");
52
+ process.exit(1);
53
+ });