totemlayer 0.1.1 → 0.1.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/README.md +113 -3
- package/bin/collaboration-companion.mjs +328 -0
- package/bin/collaboration-listener.mjs +425 -0
- package/bin/local-mcp.mjs +97 -0
- package/bin/managed-collaboration.mjs +94 -0
- package/bin/mcp-install.mjs +155 -0
- package/bin/mcp.mjs +777 -0
- package/bin/network-runtime.mjs +685 -0
- package/bin/network.mjs +323 -0
- package/bin/presenter-mcp.mjs +238 -0
- package/bin/repository.d.mts +27 -0
- package/bin/repository.mjs +135 -0
- package/bin/sdk/errors.js +54 -0
- package/bin/sdk/index.js +307 -0
- package/bin/sdk/transport.js +197 -0
- package/bin/sdk/types.js +1 -0
- package/bin/totem.mjs +579 -152
- package/package.json +10 -2
- package/scripts/prepare.mjs +15 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { apiError, TotemCanceledError, TotemError, TotemNetworkError, TotemTimeoutError, } from "./errors.js";
|
|
2
|
+
export function idempotencyKey() {
|
|
3
|
+
return globalThis.crypto.randomUUID();
|
|
4
|
+
}
|
|
5
|
+
export function sleep(ms, signal) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const cancel = () => {
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
signal?.removeEventListener("abort", cancel);
|
|
10
|
+
reject(new TotemCanceledError("Totem request canceled.", { code: "CANCELED" }));
|
|
11
|
+
};
|
|
12
|
+
const timer = setTimeout(() => {
|
|
13
|
+
signal?.removeEventListener("abort", cancel);
|
|
14
|
+
resolve();
|
|
15
|
+
}, ms);
|
|
16
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
17
|
+
if (signal?.aborted)
|
|
18
|
+
cancel();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export class Transport {
|
|
22
|
+
#apiKey;
|
|
23
|
+
#baseUrl;
|
|
24
|
+
#fetch;
|
|
25
|
+
#timeoutMs;
|
|
26
|
+
#maxRetries;
|
|
27
|
+
#retryDelayMs;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
if (!options.apiKey?.trim())
|
|
30
|
+
throw new TotemError("An agent API key is required.", { code: "INVALID_CONFIG" });
|
|
31
|
+
const url = new URL(options.baseUrl ?? "https://totemlayer.com");
|
|
32
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
33
|
+
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
|
|
34
|
+
url.username ||
|
|
35
|
+
url.password ||
|
|
36
|
+
url.search ||
|
|
37
|
+
url.hash ||
|
|
38
|
+
url.pathname !== "/") {
|
|
39
|
+
throw new TotemError("baseUrl must be an HTTPS origin or a local HTTP origin.", {
|
|
40
|
+
code: "INVALID_CONFIG",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
this.#apiKey = options.apiKey;
|
|
44
|
+
this.#baseUrl = `${url.origin}/api/v1/network`;
|
|
45
|
+
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
46
|
+
this.#timeoutMs = options.timeoutMs ?? 15_000;
|
|
47
|
+
this.#maxRetries = options.maxRetries ?? 2;
|
|
48
|
+
this.#retryDelayMs = options.retryDelayMs ?? 250;
|
|
49
|
+
if (!Number.isFinite(this.#timeoutMs) ||
|
|
50
|
+
this.#timeoutMs < 1 ||
|
|
51
|
+
!Number.isInteger(this.#maxRetries) ||
|
|
52
|
+
this.#maxRetries < 0 ||
|
|
53
|
+
this.#maxRetries > 10 ||
|
|
54
|
+
!Number.isFinite(this.#retryDelayMs) ||
|
|
55
|
+
this.#retryDelayMs < 0) {
|
|
56
|
+
throw new TotemError("Invalid timeout or retry configuration.", { code: "INVALID_CONFIG" });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async request(method, path, options = {}) {
|
|
60
|
+
const url = new URL(`${this.#baseUrl}${path}`);
|
|
61
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
62
|
+
if (value !== undefined && value !== null)
|
|
63
|
+
url.searchParams.set(key, String(value));
|
|
64
|
+
}
|
|
65
|
+
const retrySafe = method === "GET" || options.retrySafe === true || Boolean(options.idempotencyKey);
|
|
66
|
+
const body = options.binaryBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body));
|
|
67
|
+
const headers = {
|
|
68
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
69
|
+
Accept: "application/json",
|
|
70
|
+
...(body === undefined || options.binaryBody ? {} : { "Content-Type": "application/json" }),
|
|
71
|
+
...(options.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : {}),
|
|
72
|
+
...(options.headers ?? {}),
|
|
73
|
+
};
|
|
74
|
+
for (let attempt = 0;; attempt++) {
|
|
75
|
+
if (options.signal?.aborted)
|
|
76
|
+
throw new TotemCanceledError("Totem request canceled.", { code: "CANCELED" });
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
let timer;
|
|
79
|
+
let cancel;
|
|
80
|
+
let retryAfter = null;
|
|
81
|
+
try {
|
|
82
|
+
const aborted = new Promise((_, reject) => {
|
|
83
|
+
cancel = () => {
|
|
84
|
+
controller.abort();
|
|
85
|
+
reject(new TotemCanceledError("Totem request canceled.", { code: "CANCELED" }));
|
|
86
|
+
};
|
|
87
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
88
|
+
if (options.signal?.aborted)
|
|
89
|
+
cancel();
|
|
90
|
+
timer = setTimeout(() => {
|
|
91
|
+
controller.abort();
|
|
92
|
+
reject(new TotemTimeoutError("Totem request timed out.", { code: "TIMEOUT" }));
|
|
93
|
+
}, this.#timeoutMs);
|
|
94
|
+
});
|
|
95
|
+
const response = (async () => {
|
|
96
|
+
const result = await this.#fetch(url.toString(), {
|
|
97
|
+
method,
|
|
98
|
+
headers,
|
|
99
|
+
body: body instanceof Uint8Array ? new Uint8Array(body) : body,
|
|
100
|
+
signal: controller.signal,
|
|
101
|
+
redirect: "error",
|
|
102
|
+
cache: "no-store",
|
|
103
|
+
});
|
|
104
|
+
retryAfter = result.headers.get("Retry-After");
|
|
105
|
+
const requestId = result.headers.get("X-Request-Id") ?? undefined;
|
|
106
|
+
let payload;
|
|
107
|
+
try {
|
|
108
|
+
payload = await result.json();
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
if (!result.ok)
|
|
112
|
+
throw apiError(result.status, undefined, requestId);
|
|
113
|
+
throw new TotemError("Totem returned invalid JSON.", {
|
|
114
|
+
code: "INVALID_RESPONSE",
|
|
115
|
+
requestId,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (!result.ok)
|
|
119
|
+
throw apiError(result.status, payload, requestId);
|
|
120
|
+
if (!payload || typeof payload !== "object" || !("data" in payload)) {
|
|
121
|
+
throw new TotemError("Totem returned an invalid response envelope.", {
|
|
122
|
+
code: "INVALID_RESPONSE",
|
|
123
|
+
requestId,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return payload;
|
|
127
|
+
})();
|
|
128
|
+
return await Promise.race([response, aborted]);
|
|
129
|
+
}
|
|
130
|
+
catch (cause) {
|
|
131
|
+
const error = cause instanceof TotemError
|
|
132
|
+
? cause
|
|
133
|
+
: new TotemNetworkError("Unable to reach Totem.", { code: "NETWORK_ERROR", cause });
|
|
134
|
+
const transient = error instanceof TotemNetworkError ||
|
|
135
|
+
error instanceof TotemTimeoutError ||
|
|
136
|
+
(error.status !== undefined && [408, 429, 500, 502, 503, 504].includes(error.status));
|
|
137
|
+
if (!retrySafe || !transient || attempt >= this.#maxRetries)
|
|
138
|
+
throw error;
|
|
139
|
+
// The request timer and caller listener must not survive into backoff.
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
if (cancel)
|
|
142
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
143
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
144
|
+
const parsedDate = retryAfter === null ? NaN : Date.parse(retryAfter);
|
|
145
|
+
const requestedDelay = Number.isFinite(seconds) ? seconds * 1000 : parsedDate - Date.now();
|
|
146
|
+
const fallback = this.#retryDelayMs * 2 ** attempt * (0.75 + Math.random() * 0.5);
|
|
147
|
+
await sleep(Math.min(30_000, Math.max(0, Number.isFinite(requestedDelay) ? requestedDelay : fallback)), options.signal);
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
if (cancel)
|
|
152
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async download(path, options = {}) {
|
|
157
|
+
const signal = AbortSignal.any([options.signal, AbortSignal.timeout(this.#timeoutMs)].filter(Boolean));
|
|
158
|
+
let response;
|
|
159
|
+
try {
|
|
160
|
+
response = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
161
|
+
method: "GET",
|
|
162
|
+
headers: { Authorization: `Bearer ${this.#apiKey}` },
|
|
163
|
+
signal,
|
|
164
|
+
redirect: "error",
|
|
165
|
+
cache: "no-store",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
catch (cause) {
|
|
169
|
+
throw new TotemNetworkError("Unable to download the attachment.", {
|
|
170
|
+
code: "NETWORK_ERROR",
|
|
171
|
+
cause,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
const payload = await response.json().catch(() => undefined);
|
|
176
|
+
throw apiError(response.status, payload, response.headers.get("x-request-id") ?? undefined);
|
|
177
|
+
}
|
|
178
|
+
const reported = Number(response.headers.get("content-length"));
|
|
179
|
+
if (reported > 2 * 1024 * 1024)
|
|
180
|
+
throw new TotemError("The attachment exceeds the 2 MiB limit.", {
|
|
181
|
+
code: "ATTACHMENT_TOO_LARGE",
|
|
182
|
+
});
|
|
183
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
184
|
+
if (bytes.byteLength > 2 * 1024 * 1024)
|
|
185
|
+
throw new TotemError("The attachment exceeds the 2 MiB limit.", {
|
|
186
|
+
code: "ATTACHMENT_TOO_LARGE",
|
|
187
|
+
});
|
|
188
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
189
|
+
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
190
|
+
return {
|
|
191
|
+
bytes,
|
|
192
|
+
mediaType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
193
|
+
name: encoded ? decodeURIComponent(encoded) : "attachment",
|
|
194
|
+
sha256: response.headers.get("x-content-sha256"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
package/bin/sdk/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|