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.
- package/README.md +114 -2
- package/bin/collaboration-companion.mjs +309 -0
- package/bin/collaboration-listener.mjs +411 -0
- package/bin/device-approval.mjs +93 -0
- package/bin/local-mcp.mjs +97 -0
- package/bin/managed-collaboration.mjs +94 -0
- package/bin/mcp-install.mjs +159 -0
- package/bin/mcp.mjs +777 -0
- package/bin/network-runtime.mjs +685 -0
- package/bin/network.mjs +323 -0
- package/bin/platform-client.mjs +83 -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 +37 -1
- package/package.json +10 -2
- package/scripts/prepare.mjs +15 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const runFile = promisify(execFile);
|
|
5
|
+
const COMMIT = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
|
6
|
+
|
|
7
|
+
export function repositoryIdFromRemote(remote) {
|
|
8
|
+
const value = remote.trim();
|
|
9
|
+
let host;
|
|
10
|
+
let path;
|
|
11
|
+
const scp = /^git@([^:]+):(.+)$/.exec(value);
|
|
12
|
+
if (scp) {
|
|
13
|
+
[, host, path] = scp;
|
|
14
|
+
} else {
|
|
15
|
+
let url;
|
|
16
|
+
try {
|
|
17
|
+
url = new URL(value);
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error("The Git origin must be a credential-free HTTPS or SSH remote.");
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
!["https:", "ssh:"].includes(url.protocol) ||
|
|
23
|
+
url.password ||
|
|
24
|
+
(url.username && !(url.protocol === "ssh:" && url.username === "git")) ||
|
|
25
|
+
url.search ||
|
|
26
|
+
url.hash
|
|
27
|
+
)
|
|
28
|
+
throw new Error("The Git origin must be a credential-free HTTPS or SSH remote.");
|
|
29
|
+
host = url.host;
|
|
30
|
+
path = url.pathname.replace(/^\//, "");
|
|
31
|
+
}
|
|
32
|
+
path = path.replace(/\.git$/, "").replace(/\/$/, "");
|
|
33
|
+
if (
|
|
34
|
+
!host ||
|
|
35
|
+
!path ||
|
|
36
|
+
(!host.split(":")[0].includes(".") && host.split(":")[0] !== "localhost") ||
|
|
37
|
+
!path
|
|
38
|
+
.split("/")
|
|
39
|
+
.every((part) => part && part !== "." && part !== ".." && /^[a-zA-Z0-9._-]+$/.test(part))
|
|
40
|
+
)
|
|
41
|
+
throw new Error("The Git origin cannot be represented as a safe Totem repository ID.");
|
|
42
|
+
return `${host.toLowerCase()}/${path}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function git(root, ...args) {
|
|
46
|
+
try {
|
|
47
|
+
const { stdout } = await runFile(
|
|
48
|
+
"git",
|
|
49
|
+
["-c", "core.fsmonitor=false", "--no-optional-locks", "-C", root, ...args],
|
|
50
|
+
{
|
|
51
|
+
timeout: 10_000,
|
|
52
|
+
maxBuffer: 256 * 1024,
|
|
53
|
+
encoding: "utf8",
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
return args.includes("-z") ? stdout : stdout.trim();
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("The configured local Git repository could not be inspected.");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function configuredRoot(environment = process.env) {
|
|
63
|
+
return environment.TOTEM_REPOSITORY_ROOT?.trim() || process.cwd();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function inspectLocalRepository(environment = process.env) {
|
|
67
|
+
const root = configuredRoot(environment);
|
|
68
|
+
const topLevel = await git(root, "rev-parse", "--show-toplevel");
|
|
69
|
+
const [remote, headCommit, currentBranch, status] = await Promise.all([
|
|
70
|
+
git(topLevel, "remote", "get-url", "origin"),
|
|
71
|
+
git(topLevel, "rev-parse", "HEAD"),
|
|
72
|
+
git(topLevel, "branch", "--show-current"),
|
|
73
|
+
git(topLevel, "status", "--porcelain=v1", "--untracked-files=normal"),
|
|
74
|
+
]);
|
|
75
|
+
return {
|
|
76
|
+
repository: repositoryIdFromRemote(remote),
|
|
77
|
+
headCommit,
|
|
78
|
+
currentBranch: currentBranch || null,
|
|
79
|
+
clean: status.length === 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function withinScope(path, claimed) {
|
|
84
|
+
const normalized = path.normalize("NFC").toLowerCase();
|
|
85
|
+
return claimed.some((scope) => {
|
|
86
|
+
const base = scope.normalize("NFC").toLowerCase();
|
|
87
|
+
return normalized === base || normalized.startsWith(`${base}/`);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function verifyLocalRepoClaim(task, claim, environment = process.env) {
|
|
92
|
+
if (!COMMIT.test(task.baseCommit) || !COMMIT.test(claim.baseCommit))
|
|
93
|
+
throw new Error("The task needs full Git commit hashes.");
|
|
94
|
+
const local = await inspectLocalRepository(environment);
|
|
95
|
+
const root = await git(configuredRoot(environment), "rev-parse", "--show-toplevel");
|
|
96
|
+
const repositoryMatches =
|
|
97
|
+
local.repository === task.repository && claim.repository === task.repository;
|
|
98
|
+
const baseMatches = claim.baseCommit.toLowerCase() === task.baseCommit.toLowerCase();
|
|
99
|
+
const branchMatches = local.currentBranch === claim.branch;
|
|
100
|
+
const mergeBase = await git(root, "merge-base", task.baseCommit, "HEAD").catch(() => null);
|
|
101
|
+
const ancestryMatches = mergeBase?.toLowerCase() === task.baseCommit.toLowerCase();
|
|
102
|
+
const changed = ancestryMatches
|
|
103
|
+
? (
|
|
104
|
+
await git(
|
|
105
|
+
root,
|
|
106
|
+
"diff",
|
|
107
|
+
"--no-ext-diff",
|
|
108
|
+
"--no-renames",
|
|
109
|
+
"--name-only",
|
|
110
|
+
"-z",
|
|
111
|
+
`${task.baseCommit}..HEAD`,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
.split("\0")
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
: [];
|
|
117
|
+
const outOfScopePaths = changed.filter((path) => !withinScope(path, claim.paths));
|
|
118
|
+
return {
|
|
119
|
+
...local,
|
|
120
|
+
repositoryMatches,
|
|
121
|
+
baseMatches,
|
|
122
|
+
branchMatches,
|
|
123
|
+
baseAncestor: ancestryMatches,
|
|
124
|
+
changedPaths: changed,
|
|
125
|
+
outOfScopePaths,
|
|
126
|
+
locallyVerified:
|
|
127
|
+
repositoryMatches &&
|
|
128
|
+
baseMatches &&
|
|
129
|
+
branchMatches &&
|
|
130
|
+
ancestryMatches &&
|
|
131
|
+
local.clean &&
|
|
132
|
+
outOfScopePaths.length === 0,
|
|
133
|
+
remotelyVerified: false,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class TotemError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
requestId;
|
|
5
|
+
constructor(message, options) {
|
|
6
|
+
super(message, { cause: options.cause });
|
|
7
|
+
this.name = new.target.name;
|
|
8
|
+
this.code = options.code;
|
|
9
|
+
this.status = options.status;
|
|
10
|
+
this.requestId = options.requestId;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class TotemAuthenticationError extends TotemError {
|
|
14
|
+
}
|
|
15
|
+
export class TotemPermissionError extends TotemError {
|
|
16
|
+
}
|
|
17
|
+
export class TotemNotFoundError extends TotemError {
|
|
18
|
+
}
|
|
19
|
+
export class TotemConflictError extends TotemError {
|
|
20
|
+
}
|
|
21
|
+
export class TotemRateLimitError extends TotemError {
|
|
22
|
+
}
|
|
23
|
+
export class TotemTimeoutError extends TotemError {
|
|
24
|
+
}
|
|
25
|
+
export class TotemNetworkError extends TotemError {
|
|
26
|
+
}
|
|
27
|
+
export class TotemCanceledError extends TotemError {
|
|
28
|
+
}
|
|
29
|
+
export function apiError(status, body, requestId) {
|
|
30
|
+
const error = body &&
|
|
31
|
+
typeof body === "object" &&
|
|
32
|
+
"error" in body &&
|
|
33
|
+
body.error &&
|
|
34
|
+
typeof body.error === "object"
|
|
35
|
+
? body.error
|
|
36
|
+
: {};
|
|
37
|
+
const message = "message" in error && typeof error.message === "string"
|
|
38
|
+
? error.message
|
|
39
|
+
: `Totem request failed (${status}).`;
|
|
40
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : "HTTP_ERROR";
|
|
41
|
+
const id = "requestId" in error && typeof error.requestId === "string" ? error.requestId : requestId;
|
|
42
|
+
const Constructor = status === 401
|
|
43
|
+
? TotemAuthenticationError
|
|
44
|
+
: status === 403
|
|
45
|
+
? TotemPermissionError
|
|
46
|
+
: status === 404
|
|
47
|
+
? TotemNotFoundError
|
|
48
|
+
: status === 409
|
|
49
|
+
? TotemConflictError
|
|
50
|
+
: status === 429
|
|
51
|
+
? TotemRateLimitError
|
|
52
|
+
: TotemError;
|
|
53
|
+
return new Constructor(message, { code, status, requestId: id });
|
|
54
|
+
}
|
package/bin/sdk/index.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { TotemError } from "./errors.js";
|
|
2
|
+
import { idempotencyKey, sleep, Transport } from "./transport.js";
|
|
3
|
+
export * from "./types.js";
|
|
4
|
+
export * from "./errors.js";
|
|
5
|
+
export { idempotencyKey } from "./transport.js";
|
|
6
|
+
function content(input) {
|
|
7
|
+
// Runtime callers (including JavaScript) cannot accidentally provide a sender.
|
|
8
|
+
if ("from" in input)
|
|
9
|
+
throw new TotemError("Sender identity comes from the API key.", { code: "INVALID_MESSAGE" });
|
|
10
|
+
return input.content ?? { type: "text", text: input.text ?? input.message };
|
|
11
|
+
}
|
|
12
|
+
async function* paginate(fetchPage, cursor) {
|
|
13
|
+
const seen = new Set();
|
|
14
|
+
if (cursor)
|
|
15
|
+
seen.add(cursor);
|
|
16
|
+
do {
|
|
17
|
+
const page = await fetchPage(cursor);
|
|
18
|
+
for (const item of page.data)
|
|
19
|
+
yield item;
|
|
20
|
+
cursor = page.nextCursor ?? undefined;
|
|
21
|
+
if (cursor && seen.has(cursor))
|
|
22
|
+
throw new TotemError("Totem returned a repeated pagination cursor.", {
|
|
23
|
+
code: "INVALID_RESPONSE",
|
|
24
|
+
});
|
|
25
|
+
if (cursor)
|
|
26
|
+
seen.add(cursor);
|
|
27
|
+
} while (cursor);
|
|
28
|
+
}
|
|
29
|
+
export class Totem {
|
|
30
|
+
#transport;
|
|
31
|
+
#address;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.#transport = new Transport(options);
|
|
34
|
+
this.#address = options.address?.trim().toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
static async connect(options) {
|
|
37
|
+
return new Totem(options).connect();
|
|
38
|
+
}
|
|
39
|
+
async connect() {
|
|
40
|
+
const identity = await this.agent();
|
|
41
|
+
if (this.#address && this.#address !== identity.address.toLowerCase()) {
|
|
42
|
+
throw new TotemError("The configured address does not match the authenticated agent.", {
|
|
43
|
+
code: "IDENTITY_MISMATCH",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return new ConnectedAgent(this, identity);
|
|
47
|
+
}
|
|
48
|
+
async agent(options) {
|
|
49
|
+
return (await this.#transport.request("GET", "/agents/me", options)).data;
|
|
50
|
+
}
|
|
51
|
+
async resolve(address, options) {
|
|
52
|
+
return (await this.#transport.request("GET", `/resolve/${encodeURIComponent(address)}`, options)).data;
|
|
53
|
+
}
|
|
54
|
+
agents = {
|
|
55
|
+
search: (query = {}, options) => this.#transport.request("GET", "/agents/search", { ...options, query }),
|
|
56
|
+
iterate: (query = {}, options) => paginate((cursor) => this.agents.search({ ...query, cursor }, options), query.cursor),
|
|
57
|
+
};
|
|
58
|
+
attachments = {
|
|
59
|
+
upload: async (name, mediaType, bytes, options = {}) => {
|
|
60
|
+
if (!bytes.byteLength || bytes.byteLength > 2 * 1024 * 1024)
|
|
61
|
+
throw new TotemError("Attachments must be nonempty and no larger than 2 MiB.", {
|
|
62
|
+
code: "ATTACHMENT_SIZE_INVALID",
|
|
63
|
+
});
|
|
64
|
+
return (await this.#transport.request("POST", "/attachments", {
|
|
65
|
+
...options,
|
|
66
|
+
binaryBody: bytes,
|
|
67
|
+
headers: { "Content-Type": mediaType, "X-Totem-File-Name": encodeURIComponent(name) },
|
|
68
|
+
})).data;
|
|
69
|
+
},
|
|
70
|
+
download: (id, options) => this.#transport.download(`/attachments/${encodeURIComponent(id)}`, options),
|
|
71
|
+
};
|
|
72
|
+
messages = {
|
|
73
|
+
send: async (input, options = {}) => {
|
|
74
|
+
const body = { ...input, content: content(input) };
|
|
75
|
+
delete body.text;
|
|
76
|
+
delete body.message;
|
|
77
|
+
return (await this.#transport.request("POST", "/messages", {
|
|
78
|
+
...options,
|
|
79
|
+
body,
|
|
80
|
+
idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
|
|
81
|
+
})).data;
|
|
82
|
+
},
|
|
83
|
+
list: (query = {}, options) => this.#transport.request("GET", "/messages", { ...options, query }),
|
|
84
|
+
iterate: (query = {}, options) => paginate((cursor) => this.messages.list({ ...query, cursor }, options), query.cursor),
|
|
85
|
+
get: async (id, options) => (await this.#transport.request("GET", `/messages/${encodeURIComponent(id)}`, options)).data,
|
|
86
|
+
reply: async (id, text, options = {}) => (await this.#transport.request("POST", `/messages/${encodeURIComponent(id)}/reply`, {
|
|
87
|
+
...options,
|
|
88
|
+
body: {
|
|
89
|
+
content: {
|
|
90
|
+
type: "text",
|
|
91
|
+
text,
|
|
92
|
+
...(options.attachments ? { attachments: options.attachments } : {}),
|
|
93
|
+
},
|
|
94
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
95
|
+
},
|
|
96
|
+
idempotencyKey: options.idempotencyKey ?? `sdk:reply:${id}`,
|
|
97
|
+
})).data,
|
|
98
|
+
ack: async (id, options) => (await this.#transport.request("POST", `/messages/${encodeURIComponent(id)}/ack`, {
|
|
99
|
+
...options,
|
|
100
|
+
body: {},
|
|
101
|
+
retrySafe: true,
|
|
102
|
+
})).data,
|
|
103
|
+
};
|
|
104
|
+
send(input, textOrOptions, options) {
|
|
105
|
+
if (typeof input === "string")
|
|
106
|
+
return this.messages.send({ to: input, text: textOrOptions }, options);
|
|
107
|
+
return this.messages.send(input, textOrOptions);
|
|
108
|
+
}
|
|
109
|
+
conversations = {
|
|
110
|
+
create: async (input, options) => {
|
|
111
|
+
// Creation has no automatic retry until the server supports an idempotent contract.
|
|
112
|
+
const result = await this.#transport.request("POST", "/conversations", { ...options, body: input });
|
|
113
|
+
return new ConversationHandle(this, result.data, input.with);
|
|
114
|
+
},
|
|
115
|
+
list: (query = {}, options) => this.#transport.request("GET", "/conversations", { ...options, query }),
|
|
116
|
+
get: async (id, options) => (await this.#transport.request("GET", `/conversations/${encodeURIComponent(id)}`, options)).data,
|
|
117
|
+
messages: (id, query = {}, options) => this.#transport.request("GET", `/conversations/${encodeURIComponent(id)}/messages`, {
|
|
118
|
+
...options,
|
|
119
|
+
query,
|
|
120
|
+
}),
|
|
121
|
+
};
|
|
122
|
+
startConversation(input, options) {
|
|
123
|
+
return this.conversations.create(input, options);
|
|
124
|
+
}
|
|
125
|
+
chats = {
|
|
126
|
+
list: (query = {}, options) => this.#transport.request("GET", "/chats", { ...options, query }),
|
|
127
|
+
get: async (id, options = {}) => (await this.#transport.request("GET", `/chats/${encodeURIComponent(id)}`, {
|
|
128
|
+
signal: options.signal,
|
|
129
|
+
query: { cursor: options.cursor, before: options.before, limit: options.limit },
|
|
130
|
+
})).data,
|
|
131
|
+
/** Publish as the authenticated agent; this never creates a human command. */
|
|
132
|
+
updates: async (id, text, options = {}) => (await this.#transport.request("POST", `/chats/${encodeURIComponent(id)}/updates`, {
|
|
133
|
+
...options,
|
|
134
|
+
body: { text },
|
|
135
|
+
idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
|
|
136
|
+
})).data,
|
|
137
|
+
};
|
|
138
|
+
chatCommands = {
|
|
139
|
+
list: (query = {}, options) => this.#transport.request("GET", "/chat-commands", { ...options, query }),
|
|
140
|
+
claim: async (id, options) => (await this.#transport.request("POST", `/chat-commands/${encodeURIComponent(id)}/claim`, { ...options, body: {} })).data,
|
|
141
|
+
renew: async (id, leaseToken, options) => (await this.#transport.request("POST", `/chat-commands/${encodeURIComponent(id)}/renew`, { ...options, body: { leaseToken }, retrySafe: true })).data,
|
|
142
|
+
complete: async (id, input, options = {}) => (await this.#transport.request("POST", `/chat-commands/${encodeURIComponent(id)}/complete`, { ...options, body: input, idempotencyKey: options.idempotencyKey ?? idempotencyKey() })).data,
|
|
143
|
+
fail: async (id, input, options) => (await this.#transport.request("POST", `/chat-commands/${encodeURIComponent(id)}/fail`, { ...options, body: input, retrySafe: true })).data,
|
|
144
|
+
};
|
|
145
|
+
runtime = {
|
|
146
|
+
heartbeat: async (input = {}, options) => (await this.#transport.request("POST", "/runtime/heartbeat", {
|
|
147
|
+
...options,
|
|
148
|
+
body: input,
|
|
149
|
+
retrySafe: true,
|
|
150
|
+
})).data,
|
|
151
|
+
};
|
|
152
|
+
tasks = {
|
|
153
|
+
create: async (input, options = {}) => (await this.#transport.request("POST", "/tasks", {
|
|
154
|
+
...options,
|
|
155
|
+
body: {
|
|
156
|
+
to: input.to,
|
|
157
|
+
content: content(input),
|
|
158
|
+
conversationId: input.conversationId,
|
|
159
|
+
metadata: input.metadata,
|
|
160
|
+
ttlSeconds: input.ttlSeconds,
|
|
161
|
+
authorization: input.authorization,
|
|
162
|
+
},
|
|
163
|
+
idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
|
|
164
|
+
})).data,
|
|
165
|
+
get: async (id, options) => (await this.#transport.request("GET", `/tasks/${encodeURIComponent(id)}`, options)).data,
|
|
166
|
+
list: (query = {}, options) => this.#transport.request("GET", "/tasks", { ...options, query }),
|
|
167
|
+
iterate: (query = {}, options) => paginate((cursor) => this.tasks.list({ ...query, cursor }, options), query.cursor),
|
|
168
|
+
postUpdate: async (id, text, options = {}) => (await this.#transport.request("POST", `/tasks/${encodeURIComponent(id)}/messages`, {
|
|
169
|
+
...options,
|
|
170
|
+
body: { content: { type: "text", text } },
|
|
171
|
+
idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
|
|
172
|
+
})).data,
|
|
173
|
+
update: async (id, input, options) => (await this.#transport.request("PATCH", `/tasks/${encodeURIComponent(id)}`, {
|
|
174
|
+
...options,
|
|
175
|
+
body: input,
|
|
176
|
+
})).data,
|
|
177
|
+
cancel: async (id, options) => (await this.#transport.request("POST", `/tasks/${encodeURIComponent(id)}/cancel`, { ...options, body: {} })).data,
|
|
178
|
+
};
|
|
179
|
+
repositories = {
|
|
180
|
+
getTask: async (taskId, options) => (await this.#transport.request("GET", `/tasks/${encodeURIComponent(taskId)}/repository`, options)).data,
|
|
181
|
+
claim: async (taskId, input, options) => (await this.#transport.request("POST", `/tasks/${encodeURIComponent(taskId)}/repository`, { ...options, body: input })).data,
|
|
182
|
+
update: async (taskId, input, options) => (await this.#transport.request("PATCH", `/tasks/${encodeURIComponent(taskId)}/repository`, { ...options, body: input })).data,
|
|
183
|
+
};
|
|
184
|
+
github = {
|
|
185
|
+
repositories: async (options) => (await this.#transport.request("GET", "/github/repositories", options)).data,
|
|
186
|
+
openPullRequest: async (taskId, input, options) => (await this.#transport.request("POST", `/github/pull-requests?taskId=${encodeURIComponent(taskId)}`, { ...options, body: input })).data,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
export class ConversationHandle {
|
|
190
|
+
id;
|
|
191
|
+
conversation;
|
|
192
|
+
#totem;
|
|
193
|
+
#recipient;
|
|
194
|
+
constructor(totem, conversation, recipient) {
|
|
195
|
+
this.#totem = totem;
|
|
196
|
+
this.conversation = conversation;
|
|
197
|
+
this.id = conversation.id;
|
|
198
|
+
this.#recipient = recipient;
|
|
199
|
+
}
|
|
200
|
+
send(text, options) {
|
|
201
|
+
return this.#totem.messages.send({ to: this.#recipient, text, conversationId: this.id }, options);
|
|
202
|
+
}
|
|
203
|
+
messages(query = {}, options) {
|
|
204
|
+
return this.#totem.conversations.messages(this.id, query, options);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export class ConnectedAgent {
|
|
208
|
+
identity;
|
|
209
|
+
address;
|
|
210
|
+
client;
|
|
211
|
+
#subscription;
|
|
212
|
+
#lastError;
|
|
213
|
+
constructor(client, identity) {
|
|
214
|
+
this.client = client;
|
|
215
|
+
this.identity = identity;
|
|
216
|
+
this.address = identity.address;
|
|
217
|
+
}
|
|
218
|
+
get lastError() {
|
|
219
|
+
return this.#lastError;
|
|
220
|
+
}
|
|
221
|
+
send(input, textOrOptions, options) {
|
|
222
|
+
return typeof input === "string"
|
|
223
|
+
? this.client.send(input, textOrOptions, options)
|
|
224
|
+
: this.client.send(input, textOrOptions);
|
|
225
|
+
}
|
|
226
|
+
startConversation(input) {
|
|
227
|
+
return this.client.startConversation(input);
|
|
228
|
+
}
|
|
229
|
+
on(event, handler, options = {}) {
|
|
230
|
+
if (event !== "message")
|
|
231
|
+
throw new TotemError("Only message subscriptions are supported.", { code: "INVALID_EVENT" });
|
|
232
|
+
if (this.#subscription)
|
|
233
|
+
throw new TotemError("This agent already has a message listener.", {
|
|
234
|
+
code: "ALREADY_LISTENING",
|
|
235
|
+
});
|
|
236
|
+
const interval = options.pollIntervalMs ?? 1000;
|
|
237
|
+
if (!Number.isFinite(interval) || interval < 10)
|
|
238
|
+
throw new TotemError("pollIntervalMs must be at least 10.", { code: "INVALID_CONFIG" });
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
const report = (cause) => {
|
|
241
|
+
this.#lastError = cause instanceof Error ? cause : new Error(String(cause));
|
|
242
|
+
try {
|
|
243
|
+
options.onError?.(this.#lastError);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* Error reporting cannot acknowledge an unprocessed message. */
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
const done = (async () => {
|
|
250
|
+
while (!controller.signal.aborted) {
|
|
251
|
+
try {
|
|
252
|
+
for await (const message of this.client.messages.iterate({ direction: "inbound", unread: true, limit: 100 }, { signal: controller.signal })) {
|
|
253
|
+
if (controller.signal.aborted)
|
|
254
|
+
break;
|
|
255
|
+
const incoming = {
|
|
256
|
+
...message,
|
|
257
|
+
text: message.content.type === "text"
|
|
258
|
+
? message.content.text
|
|
259
|
+
: JSON.stringify(message.content.data),
|
|
260
|
+
reply: (text, writeOptions = {}) => this.client.messages.reply(message.id, text, {
|
|
261
|
+
...writeOptions,
|
|
262
|
+
idempotencyKey: writeOptions.idempotencyKey ?? `reply:${message.id}:0`,
|
|
263
|
+
}),
|
|
264
|
+
ack: () => this.client.messages.ack(message.id),
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
await handler(incoming);
|
|
268
|
+
await incoming.ack();
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
report(error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (!controller.signal.aborted)
|
|
277
|
+
report(error);
|
|
278
|
+
}
|
|
279
|
+
if (!controller.signal.aborted) {
|
|
280
|
+
try {
|
|
281
|
+
await sleep(interval, controller.signal);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
/* stop wakes the poller. */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
const subscription = {
|
|
290
|
+
done,
|
|
291
|
+
stop: async () => {
|
|
292
|
+
controller.abort();
|
|
293
|
+
await done;
|
|
294
|
+
if (this.#subscription === subscription)
|
|
295
|
+
this.#subscription = undefined;
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
this.#subscription = subscription;
|
|
299
|
+
return subscription;
|
|
300
|
+
}
|
|
301
|
+
async stop() {
|
|
302
|
+
await this.#subscription?.stop();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
/** Compatibility name for callers who prefer `new TotemAgent({ address, apiKey })`. */
|
|
306
|
+
export class TotemAgent extends Totem {
|
|
307
|
+
}
|