@pentatonic-ai/ai-agent-sdk 0.3.0-beta.3
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/LICENSE +21 -0
- package/README.md +401 -0
- package/bin/cli.js +371 -0
- package/build.js +23 -0
- package/dist/index.cjs +699 -0
- package/dist/index.js +677 -0
- package/dist/pentatonic_agent_events-0.2.0b1-py3-none-any.whl +0 -0
- package/dist/pentatonic_agent_events-0.2.0b1.tar.gz +0 -0
- package/dist/pentatonic_agent_events-0.3.0b1-py3-none-any.whl +0 -0
- package/dist/pentatonic_agent_events-0.3.0b1.tar.gz +0 -0
- package/dist/pentatonic_agent_events-0.3.0b2-py3-none-any.whl +0 -0
- package/dist/pentatonic_agent_events-0.3.0b2.tar.gz +0 -0
- package/dist/pentatonic_agent_events-0.3.0b3-py3-none-any.whl +0 -0
- package/dist/pentatonic_agent_events-0.3.0b3.tar.gz +0 -0
- package/package.json +64 -0
- package/src/client.js +60 -0
- package/src/index.js +4 -0
- package/src/normalizer.js +111 -0
- package/src/session.js +181 -0
- package/src/tracking.js +119 -0
- package/src/transport.js +48 -0
- package/src/wrapper.js +329 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pentatonic-ai/ai-agent-sdk",
|
|
3
|
+
"version": "0.3.0-beta.3",
|
|
4
|
+
"description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"ai-agent-sdk": "./bin/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"bin",
|
|
21
|
+
"build.js",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepare": "node build.js",
|
|
27
|
+
"build": "node build.js",
|
|
28
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.config.cjs",
|
|
29
|
+
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --config jest.config.cjs --watch",
|
|
30
|
+
"prepublishOnly": "npm run build && npm test"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"tes",
|
|
34
|
+
"thing-event-system",
|
|
35
|
+
"pentatonic",
|
|
36
|
+
"llm",
|
|
37
|
+
"observability",
|
|
38
|
+
"ai",
|
|
39
|
+
"openai",
|
|
40
|
+
"anthropic",
|
|
41
|
+
"product-lifecycle",
|
|
42
|
+
"event-sourcing",
|
|
43
|
+
"circular-commerce",
|
|
44
|
+
"agentic-commerce",
|
|
45
|
+
"vector-search",
|
|
46
|
+
"graphql",
|
|
47
|
+
"mcp",
|
|
48
|
+
"model-context-protocol"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"homepage": "https://thingeventsystem.ai",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/Pentatonic-Ltd/ai-agent-sdk.git"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
58
|
+
"esbuild": "^0.20.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@jest/globals": "^29.7.0",
|
|
62
|
+
"jest": "^29.7.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Session } from "./session.js";
|
|
2
|
+
import { wrapClient } from "./wrapper.js";
|
|
3
|
+
|
|
4
|
+
export class TESClient {
|
|
5
|
+
constructor({ clientId, apiKey, endpoint, headers, userId, captureContent = true, maxContentLength = 4096 }) {
|
|
6
|
+
if (!clientId) throw new Error("clientId is required");
|
|
7
|
+
if (!apiKey) throw new Error("apiKey is required");
|
|
8
|
+
if (!endpoint) throw new Error("endpoint is required");
|
|
9
|
+
|
|
10
|
+
const cleanEndpoint = endpoint.replace(/\/$/, "");
|
|
11
|
+
const isLocalDev =
|
|
12
|
+
/^http:\/\/localhost(:\d+)?(\/|$)/.test(cleanEndpoint) ||
|
|
13
|
+
/^http:\/\/127\.0\.0\.1(:\d+)?(\/|$)/.test(cleanEndpoint);
|
|
14
|
+
if (!cleanEndpoint.startsWith("https://") && !isLocalDev) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"endpoint must use https:// (http:// is only allowed for localhost)"
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this.clientId = clientId;
|
|
21
|
+
this.endpoint = cleanEndpoint;
|
|
22
|
+
this.userId = userId || null;
|
|
23
|
+
this.captureContent = captureContent;
|
|
24
|
+
this.maxContentLength = maxContentLength;
|
|
25
|
+
|
|
26
|
+
// Store apiKey and headers as non-enumerable so they won't appear in
|
|
27
|
+
// JSON.stringify, console.log, or error reporter serialization.
|
|
28
|
+
Object.defineProperty(this, "_apiKey", {
|
|
29
|
+
value: apiKey,
|
|
30
|
+
enumerable: false,
|
|
31
|
+
writable: false,
|
|
32
|
+
});
|
|
33
|
+
Object.defineProperty(this, "_headers", {
|
|
34
|
+
value: headers || {},
|
|
35
|
+
enumerable: false,
|
|
36
|
+
writable: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get _config() {
|
|
41
|
+
return {
|
|
42
|
+
clientId: this.clientId,
|
|
43
|
+
apiKey: this._apiKey,
|
|
44
|
+
endpoint: this.endpoint,
|
|
45
|
+
headers: this._headers,
|
|
46
|
+
userId: this.userId,
|
|
47
|
+
captureContent: this.captureContent,
|
|
48
|
+
maxContentLength: this.maxContentLength,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
session(opts) {
|
|
53
|
+
return new Session(this._config, opts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
wrap(client, { sessionId, userId, metadata, autoEmit = true, waitUntil } = {}) {
|
|
57
|
+
const config = userId ? { ...this._config, userId } : this._config;
|
|
58
|
+
return wrapClient(config, client, { sessionId, metadata, autoEmit, waitUntil });
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an LLM response from any supported provider into a common shape.
|
|
3
|
+
* Detects format by duck-typing: OpenAI (choices), Anthropic (content array), Workers AI (response string).
|
|
4
|
+
*/
|
|
5
|
+
export function normalizeResponse(raw) {
|
|
6
|
+
if (!raw || typeof raw !== "object") {
|
|
7
|
+
return empty();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// OpenAI SDK format: { choices, usage, model }
|
|
11
|
+
if (Array.isArray(raw.choices)) {
|
|
12
|
+
return normalizeOpenAI(raw);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Anthropic SDK format: { content: [{ type: "text"|"tool_use", ... }], usage: { input_tokens, output_tokens } }
|
|
16
|
+
if (Array.isArray(raw.content) && raw.content[0]?.type) {
|
|
17
|
+
return normalizeAnthropic(raw);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Workers AI format: { response: "...", tool_calls: [...] }
|
|
21
|
+
if (typeof raw.response === "string" || (raw.tool_calls && !raw.choices)) {
|
|
22
|
+
return normalizeWorkersAI(raw);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return empty();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function empty() {
|
|
29
|
+
return {
|
|
30
|
+
content: "",
|
|
31
|
+
model: null,
|
|
32
|
+
usage: { prompt_tokens: 0, completion_tokens: 0 },
|
|
33
|
+
toolCalls: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeOpenAI(raw) {
|
|
38
|
+
const message = raw.choices?.[0]?.message || {};
|
|
39
|
+
const usage = raw.usage || {};
|
|
40
|
+
// Workers AI sometimes puts tool_calls at top level instead of inside message
|
|
41
|
+
const rawToolCalls = message.tool_calls?.length
|
|
42
|
+
? message.tool_calls
|
|
43
|
+
: raw.tool_calls || [];
|
|
44
|
+
const toolCalls = rawToolCalls.map((tc) => ({
|
|
45
|
+
tool: tc.function?.name || tc.name,
|
|
46
|
+
args: parseArgs(tc.function?.arguments || tc.arguments),
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
content: message.content || "",
|
|
51
|
+
model: raw.model || null,
|
|
52
|
+
usage: {
|
|
53
|
+
prompt_tokens: usage.prompt_tokens || 0,
|
|
54
|
+
completion_tokens: usage.completion_tokens || 0,
|
|
55
|
+
},
|
|
56
|
+
toolCalls,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeAnthropic(raw) {
|
|
61
|
+
const usage = raw.usage || {};
|
|
62
|
+
let content = "";
|
|
63
|
+
const toolCalls = [];
|
|
64
|
+
|
|
65
|
+
for (const block of raw.content) {
|
|
66
|
+
if (block.type === "text") {
|
|
67
|
+
content += block.text;
|
|
68
|
+
} else if (block.type === "tool_use") {
|
|
69
|
+
toolCalls.push({ tool: block.name, args: block.input || {} });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
content,
|
|
75
|
+
model: raw.model || null,
|
|
76
|
+
usage: {
|
|
77
|
+
prompt_tokens: usage.input_tokens || 0,
|
|
78
|
+
completion_tokens: usage.output_tokens || 0,
|
|
79
|
+
},
|
|
80
|
+
toolCalls,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeWorkersAI(raw) {
|
|
85
|
+
const usage = raw.usage || {};
|
|
86
|
+
const toolCalls = (raw.tool_calls || []).map((tc) => ({
|
|
87
|
+
tool: tc.function?.name || tc.name,
|
|
88
|
+
args: parseArgs(tc.function?.arguments || tc.arguments || {}),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
content: raw.response || "",
|
|
93
|
+
model: raw.model || null,
|
|
94
|
+
usage: {
|
|
95
|
+
prompt_tokens: usage.prompt_tokens || 0,
|
|
96
|
+
completion_tokens: usage.completion_tokens || 0,
|
|
97
|
+
},
|
|
98
|
+
toolCalls,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseArgs(args) {
|
|
103
|
+
if (typeof args === "string") {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(args);
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return args || {};
|
|
111
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { normalizeResponse } from "./normalizer.js";
|
|
2
|
+
import { sendEvent } from "./transport.js";
|
|
3
|
+
import { buildTrackUrl } from "./tracking.js";
|
|
4
|
+
|
|
5
|
+
function truncate(value, maxLen) {
|
|
6
|
+
if (!value || !maxLen || typeof value !== "string") return value;
|
|
7
|
+
if (value.length <= maxLen) return value;
|
|
8
|
+
return value.slice(0, maxLen) + "...[truncated]";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class Session {
|
|
12
|
+
constructor(clientConfig, { sessionId, metadata } = {}) {
|
|
13
|
+
Object.defineProperty(this, '_config', {
|
|
14
|
+
value: clientConfig,
|
|
15
|
+
enumerable: false,
|
|
16
|
+
});
|
|
17
|
+
this.sessionId = sessionId || crypto.randomUUID();
|
|
18
|
+
this._metadata = metadata || {};
|
|
19
|
+
this._reset();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_reset() {
|
|
23
|
+
this._promptTokens = 0;
|
|
24
|
+
this._completionTokens = 0;
|
|
25
|
+
this._rounds = 0;
|
|
26
|
+
this._toolCalls = [];
|
|
27
|
+
this._model = null;
|
|
28
|
+
this._systemPrompt = null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get totalUsage() {
|
|
32
|
+
return {
|
|
33
|
+
prompt_tokens: this._promptTokens,
|
|
34
|
+
completion_tokens: this._completionTokens,
|
|
35
|
+
total_tokens: this._promptTokens + this._completionTokens,
|
|
36
|
+
ai_rounds: this._rounds,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get toolCalls() {
|
|
41
|
+
return this._toolCalls;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
record(rawResponse) {
|
|
45
|
+
const normalized = normalizeResponse(rawResponse);
|
|
46
|
+
const round = this._rounds;
|
|
47
|
+
|
|
48
|
+
this._promptTokens += normalized.usage.prompt_tokens;
|
|
49
|
+
this._completionTokens += normalized.usage.completion_tokens;
|
|
50
|
+
this._rounds += 1;
|
|
51
|
+
|
|
52
|
+
if (normalized.model) {
|
|
53
|
+
this._model = normalized.model;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const tc of normalized.toolCalls) {
|
|
57
|
+
this._toolCalls.push({ ...tc, round });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Attach a result summary to the most recent tool call matching `toolName`.
|
|
65
|
+
* Call this after executing a tool to include results in the emitted event.
|
|
66
|
+
*/
|
|
67
|
+
recordToolResult(toolName, result) {
|
|
68
|
+
for (let i = this._toolCalls.length - 1; i >= 0; i--) {
|
|
69
|
+
if (this._toolCalls[i].tool === toolName && !this._toolCalls[i].result) {
|
|
70
|
+
this._toolCalls[i].result = result;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async emitChatTurn({ userMessage, assistantResponse, turnNumber, messages }) {
|
|
77
|
+
const capture = this._config.captureContent !== false;
|
|
78
|
+
const maxLen = this._config.maxContentLength;
|
|
79
|
+
|
|
80
|
+
// Spread metadata first so SDK-controlled fields always win
|
|
81
|
+
const attributes = {
|
|
82
|
+
...this._metadata,
|
|
83
|
+
source: "pentatonic-ai-sdk",
|
|
84
|
+
model: this._model,
|
|
85
|
+
usage: this.totalUsage,
|
|
86
|
+
tool_calls: this._toolCalls.length
|
|
87
|
+
? (capture ? this._toolCalls : this._toolCalls.map(({ args, ...rest }) => rest))
|
|
88
|
+
: undefined,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
if (capture) {
|
|
92
|
+
attributes.user_message = truncate(userMessage, maxLen);
|
|
93
|
+
attributes.assistant_response = truncate(assistantResponse, maxLen);
|
|
94
|
+
if (this._systemPrompt) {
|
|
95
|
+
attributes.system_prompt = truncate(this._systemPrompt, maxLen);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (messages) {
|
|
99
|
+
attributes.messages = messages.map((m) => {
|
|
100
|
+
if (typeof m.content === "string") {
|
|
101
|
+
return { ...m, content: truncate(m.content, maxLen) };
|
|
102
|
+
}
|
|
103
|
+
return m;
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (turnNumber !== undefined) {
|
|
109
|
+
attributes.turn_number = turnNumber;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = await sendEvent(this._config, {
|
|
113
|
+
eventType: "CHAT_TURN",
|
|
114
|
+
entityType: "conversation",
|
|
115
|
+
data: {
|
|
116
|
+
entity_id: this.sessionId,
|
|
117
|
+
attributes,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this._reset();
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async emitToolUse({ tool, args, resultSummary, durationMs, turnNumber }) {
|
|
126
|
+
const capture = this._config.captureContent !== false;
|
|
127
|
+
const maxLen = this._config.maxContentLength;
|
|
128
|
+
|
|
129
|
+
const attributes = {
|
|
130
|
+
...this._metadata,
|
|
131
|
+
source: "pentatonic-ai-sdk",
|
|
132
|
+
tool,
|
|
133
|
+
duration_ms: durationMs,
|
|
134
|
+
turn_number: turnNumber,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
if (capture) {
|
|
138
|
+
attributes.args = args;
|
|
139
|
+
attributes.result_summary = truncate(resultSummary, maxLen);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Spread metadata first so SDK-controlled fields always win
|
|
143
|
+
return sendEvent(this._config, {
|
|
144
|
+
eventType: "TOOL_USE",
|
|
145
|
+
entityType: "conversation",
|
|
146
|
+
data: {
|
|
147
|
+
entity_id: this.sessionId,
|
|
148
|
+
attributes,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async trackUrl(url, { eventType, attributes } = {}) {
|
|
154
|
+
const payload = {
|
|
155
|
+
u: url,
|
|
156
|
+
s: this.sessionId,
|
|
157
|
+
c: this._config.clientId,
|
|
158
|
+
t: Math.floor(Date.now() / 1000),
|
|
159
|
+
e: eventType || "LINK_CLICK",
|
|
160
|
+
};
|
|
161
|
+
const meta = { ...this._metadata, ...attributes };
|
|
162
|
+
if (Object.keys(meta).length) {
|
|
163
|
+
payload.a = meta;
|
|
164
|
+
}
|
|
165
|
+
return buildTrackUrl(this._config.endpoint, this._config.apiKey, payload);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async emitSessionStart() {
|
|
169
|
+
return sendEvent(this._config, {
|
|
170
|
+
eventType: "SESSION_START",
|
|
171
|
+
entityType: "conversation",
|
|
172
|
+
data: {
|
|
173
|
+
entity_id: this.sessionId,
|
|
174
|
+
attributes: {
|
|
175
|
+
source: "pentatonic-ai-sdk",
|
|
176
|
+
metadata: this._metadata,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/tracking.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMAC signing and URL rewriting utilities for click tracking.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Web Crypto API (works in browsers, Node 18+, Cloudflare Workers).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encode bytes to base64url (RFC 4648 §5) — no padding.
|
|
11
|
+
*/
|
|
12
|
+
function toBase64Url(buffer) {
|
|
13
|
+
const bytes = new Uint8Array(buffer);
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
16
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* HMAC-SHA256 sign a JSON payload, returning a base64url signature.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} secret Shared secret (TES_INTERNAL_SERVICE_KEY)
|
|
23
|
+
* @param {object} payload Object to sign (serialised to canonical JSON)
|
|
24
|
+
* @returns {Promise<string>} base64url HMAC signature
|
|
25
|
+
*/
|
|
26
|
+
export async function signPayload(secret, payload) {
|
|
27
|
+
const key = await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
encoder.encode(secret),
|
|
30
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
31
|
+
false,
|
|
32
|
+
["sign"],
|
|
33
|
+
);
|
|
34
|
+
const data = encoder.encode(JSON.stringify(payload));
|
|
35
|
+
const sig = await crypto.subtle.sign("HMAC", key, data);
|
|
36
|
+
return toBase64Url(sig);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Verify a payload against a base64url HMAC-SHA256 signature.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} secret Shared secret
|
|
43
|
+
* @param {object} payload Payload object
|
|
44
|
+
* @param {string} signature base64url signature to verify
|
|
45
|
+
* @returns {Promise<boolean>}
|
|
46
|
+
*/
|
|
47
|
+
export async function verifyPayload(secret, payload, signature) {
|
|
48
|
+
const expected = await signPayload(secret, payload);
|
|
49
|
+
return expected === signature;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a TES redirect/tracking URL.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} endpoint TES API base URL (no trailing slash)
|
|
56
|
+
* @param {string} apiKey Shared secret for signing
|
|
57
|
+
* @param {object} payload Tracking payload with short keys (u, s, c, t, e, a)
|
|
58
|
+
* @returns {Promise<string>} Full redirect URL
|
|
59
|
+
*/
|
|
60
|
+
export async function buildTrackUrl(endpoint, apiKey, payload) {
|
|
61
|
+
const p = { ...payload };
|
|
62
|
+
if (!p.e) p.e = "LINK_CLICK";
|
|
63
|
+
|
|
64
|
+
const encoded = toBase64Url(encoder.encode(JSON.stringify(p)));
|
|
65
|
+
const sig = await signPayload(apiKey, p);
|
|
66
|
+
return `${endpoint}/r/${encoded}?sig=${sig}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Scan text for URLs and rewrite each as a tracked redirect URL.
|
|
73
|
+
*
|
|
74
|
+
* URLs already pointing at the TES redirect endpoint are left untouched.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text Text (typically an LLM response) to scan
|
|
77
|
+
* @param {object} config { endpoint, apiKey, clientId }
|
|
78
|
+
* @param {string} sessionId Current session ID
|
|
79
|
+
* @param {object} [metadata] Optional attributes merged into payload.a
|
|
80
|
+
* @returns {Promise<string>} Text with URLs replaced
|
|
81
|
+
*/
|
|
82
|
+
export async function rewriteUrls(text, config, sessionId, metadata) {
|
|
83
|
+
if (!text) return text;
|
|
84
|
+
|
|
85
|
+
const redirectPrefix = `${config.endpoint}/r/`;
|
|
86
|
+
const matches = [...text.matchAll(URL_RE)];
|
|
87
|
+
|
|
88
|
+
if (matches.length === 0) return text;
|
|
89
|
+
|
|
90
|
+
// Build tracked URLs for each unique original URL (preserving order)
|
|
91
|
+
const replacements = new Map();
|
|
92
|
+
for (const m of matches) {
|
|
93
|
+
const originalUrl = m[0];
|
|
94
|
+
if (originalUrl.startsWith(redirectPrefix)) continue;
|
|
95
|
+
if (replacements.has(originalUrl)) continue;
|
|
96
|
+
|
|
97
|
+
const payload = {
|
|
98
|
+
u: originalUrl,
|
|
99
|
+
s: sessionId,
|
|
100
|
+
c: config.clientId,
|
|
101
|
+
t: Math.floor(Date.now() / 1000),
|
|
102
|
+
};
|
|
103
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
104
|
+
payload.a = metadata;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const trackUrl = await buildTrackUrl(config.endpoint, config.apiKey, payload);
|
|
108
|
+
replacements.set(originalUrl, trackUrl);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Replace URLs in text (longest-first to avoid partial matches)
|
|
112
|
+
let result = text;
|
|
113
|
+
const sorted = [...replacements.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
114
|
+
for (const [original, tracked] of sorted) {
|
|
115
|
+
result = result.split(original).join(tracked);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return result;
|
|
119
|
+
}
|
package/src/transport.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const EMIT_EVENT_MUTATION = `
|
|
2
|
+
mutation EmitEvent($input: EventInput!) {
|
|
3
|
+
emitEvent(input: $input) {
|
|
4
|
+
success
|
|
5
|
+
eventId
|
|
6
|
+
message
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
export async function sendEvent({ endpoint, apiKey, clientId, userId, headers }, input, fetchFn) {
|
|
12
|
+
const f = fetchFn || globalThis.fetch;
|
|
13
|
+
|
|
14
|
+
// tes_ prefixed tokens are API tokens — send as Authorization: Bearer
|
|
15
|
+
// Other tokens (internal service keys) go as x-service-key
|
|
16
|
+
const authHeaders = apiKey.startsWith("tes_")
|
|
17
|
+
? { Authorization: `Bearer ${apiKey}` }
|
|
18
|
+
: { "x-service-key": apiKey };
|
|
19
|
+
|
|
20
|
+
const response = await f(`${endpoint}/api/graphql`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"x-client-id": clientId,
|
|
25
|
+
...headers,
|
|
26
|
+
...authHeaders,
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({
|
|
29
|
+
query: EMIT_EVENT_MUTATION,
|
|
30
|
+
variables: {
|
|
31
|
+
input: userId
|
|
32
|
+
? { ...input, data: { ...input.data, attributes: { ...input.data?.attributes, userId } } }
|
|
33
|
+
: input,
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error(`TES API error: ${response.status}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const json = await response.json();
|
|
43
|
+
if (json.errors?.length) {
|
|
44
|
+
throw new Error(`TES GraphQL error: ${json.errors[0].message}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return json.data.emitEvent;
|
|
48
|
+
}
|