totemlayer 0.1.2 → 0.1.4

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.
@@ -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
+ }
@@ -0,0 +1 @@
1
+ export {};
package/bin/totem.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { stripVTControlCharacters } from "node:util";
6
6
 
7
- const CLI_VERSION = "0.1.2";
7
+ const CLI_VERSION = "0.1.4";
8
8
  const DEFAULT_BASE_URL = "https://totemlayer.com";
9
9
  const API_KEY_PATTERN = /^at_(?:live|test)_[A-Za-z0-9_-]{20,}$/;
10
10
  const ACTION_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
@@ -241,6 +241,13 @@ function help() {
241
241
  Confirm installation totem --version
242
242
  Run one-off npx --yes totemlayer --help
243
243
 
244
+ Agent messaging and local runtime
245
+ totem network --help
246
+ totem network run --provider ollama --model qwen3.5:9b
247
+
248
+ Connect an AI app through MCP
249
+ totem mcp
250
+
244
251
  Authorization handshake
245
252
  totem challenge \\
246
253
  --presenter-agent <agent-id> \\
@@ -1734,6 +1741,35 @@ async function runRfq(options, baseUrl) {
1734
1741
  }
1735
1742
 
1736
1743
  async function main() {
1744
+ if (process.argv[2] === "mcp") {
1745
+ if (process.argv[3] === "install" || process.argv[3] === "--help" || process.argv[3] === "-h") {
1746
+ const { installTotemMcp } = await import("./mcp-install.mjs");
1747
+ try {
1748
+ process.stdout.write(
1749
+ await installTotemMcp(process.argv[3] === "install" ? process.argv.slice(4) : ["--help"]),
1750
+ );
1751
+ } catch (error) {
1752
+ process.stderr.write(
1753
+ `${error instanceof Error ? error.message : "Could not install the Totem MCP server."}\n`,
1754
+ );
1755
+ process.exitCode = 1;
1756
+ }
1757
+ return;
1758
+ }
1759
+ if (process.argv.length > 3) {
1760
+ process.stderr.write("Unknown Totem MCP option. Run `totem mcp install --help`.\n");
1761
+ process.exitCode = 1;
1762
+ return;
1763
+ }
1764
+ const { mcpMain } = await import("./local-mcp.mjs");
1765
+ await mcpMain();
1766
+ return;
1767
+ }
1768
+ if (process.argv[2] === "network") {
1769
+ const { networkMain } = await import("./network.mjs");
1770
+ await networkMain(process.argv.slice(3));
1771
+ return;
1772
+ }
1737
1773
  let options;
1738
1774
  const jsonRequested = process.argv.slice(2).includes("--json");
1739
1775
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "totemlayer",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Cross-platform CLI for TotemLayer agent identity and authorization",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,8 @@
11
11
  "files": [
12
12
  "bin",
13
13
  "LICENSE.md",
14
- "README.md"
14
+ "README.md",
15
+ "scripts/prepare.mjs"
15
16
  ],
16
17
  "engines": {
17
18
  "node": ">=22.14.0"
@@ -29,5 +30,12 @@
29
30
  "publishConfig": {
30
31
  "access": "public",
31
32
  "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/server": "2.0.0",
36
+ "zod": "4.4.3"
37
+ },
38
+ "scripts": {
39
+ "prepack": "node scripts/prepare.mjs"
32
40
  }
33
41
  }
@@ -0,0 +1,15 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdir, readdir, copyFile } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Build from the sibling source every time a distributable is packed.
6
+ const sdk = fileURLToPath(new URL("../../sdk/", import.meta.url));
7
+ const destination = new URL("../bin/sdk/", import.meta.url);
8
+ execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "build", "--prefix", sdk], {
9
+ stdio: "inherit",
10
+ });
11
+ await mkdir(destination, { recursive: true });
12
+ for (const name of await readdir(new URL("../../sdk/dist/", import.meta.url))) {
13
+ if (name.endsWith(".js"))
14
+ await copyFile(new URL(`../../sdk/dist/${name}`, import.meta.url), new URL(name, destination));
15
+ }