totemlayer 0.1.4 → 0.1.5

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.
@@ -3,6 +3,7 @@ import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { pause } from "./network.mjs";
6
+ import { localModelEndpoint } from "./local-model-provider.mjs";
6
7
 
7
8
  export function operationKey(agentId, kind, id, index = 0) {
8
9
  return `runtime:${createHash("sha256")
@@ -11,18 +12,7 @@ export function operationKey(agentId, kind, id, index = 0) {
11
12
  }
12
13
 
13
14
  export function ollamaOrigin(value = "http://127.0.0.1:11434") {
14
- const url = new URL(value);
15
- if (
16
- !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
17
- !["http:", "https:"].includes(url.protocol) ||
18
- url.username ||
19
- url.password ||
20
- url.pathname !== "/" ||
21
- url.search ||
22
- url.hash
23
- )
24
- throw new Error("Ollama must use a loopback origin, for example http://127.0.0.1:11434.");
25
- return url.origin;
15
+ return new URL(localModelEndpoint("ollama", { ollamaUrl: value })).origin;
26
16
  }
27
17
 
28
18
  export function boundedHistory(events, currentEventId) {
@@ -84,6 +74,9 @@ async function responseJson(response) {
84
74
 
85
75
  export async function modelReply({
86
76
  origin,
77
+ provider = "qwen-local",
78
+ qwenUrl,
79
+ nimUrl,
87
80
  model,
88
81
  messages,
89
82
  tools,
@@ -92,26 +85,50 @@ export async function modelReply({
92
85
  fetcher = fetch,
93
86
  }) {
94
87
  const timeout = AbortSignal.timeout(timeoutMs);
88
+ const openai = provider === "qwen-local" || provider === "nim-local";
89
+ model ??= provider === "qwen-local" ? "qwen3.5-9b" : undefined;
90
+ const endpoint = localModelEndpoint(provider, { ollamaUrl: origin, qwenUrl, nimUrl });
95
91
  try {
96
- const response = await fetcher(`${origin}/api/chat`, {
92
+ const response = await fetcher(endpoint, {
97
93
  method: "POST",
98
94
  redirect: "error",
99
95
  signal: AbortSignal.any([signal, timeout]),
100
96
  headers: { "Content-Type": "application/json" },
101
97
  body: JSON.stringify({
102
98
  model,
103
- messages,
99
+ messages: openai
100
+ ? messages.map((message) => {
101
+ const native = { ...message };
102
+ delete native.tool_name;
103
+ if (native.tool_calls)
104
+ native.tool_calls = native.tool_calls.map((call) => ({
105
+ ...call,
106
+ function: {
107
+ ...call.function,
108
+ arguments: JSON.stringify(call.function.arguments),
109
+ },
110
+ }));
111
+ return native;
112
+ })
113
+ : messages,
104
114
  ...(tools.length ? { tools } : {}),
105
115
  stream: false,
106
- think: false,
107
- options: { num_predict: 512, temperature: 0 },
116
+ ...(openai
117
+ ? {
118
+ ...(provider === "qwen-local"
119
+ ? { chat_template_kwargs: { enable_thinking: false } }
120
+ : {}),
121
+ max_tokens: 512,
122
+ temperature: 0,
123
+ }
124
+ : { think: false, options: { num_predict: 512, temperature: 0 } }),
108
125
  }),
109
126
  });
110
127
  const data = await responseJson(response);
111
- if (!data.message || data.message.role !== "assistant")
112
- throw new Error("Invalid model response.");
113
- const text = typeof data.message.content === "string" ? data.message.content.trim() : "";
114
- const calls = data.message.tool_calls ?? [];
128
+ const message = openai ? data?.choices?.[0]?.message : data.message;
129
+ if (!message || message.role !== "assistant") throw new Error("Invalid model response.");
130
+ const text = typeof message.content === "string" ? message.content.trim() : "";
131
+ let calls = message.tool_calls ?? [];
115
132
  if (
116
133
  !Array.isArray(calls) ||
117
134
  calls.length > 3 ||
@@ -119,6 +136,31 @@ export async function modelReply({
119
136
  (!text && !calls.length)
120
137
  )
121
138
  throw new Error("Invalid or empty model response.");
139
+ if (openai) {
140
+ const ids = new Set();
141
+ calls = calls.map((call) => {
142
+ if (
143
+ call?.type !== "function" ||
144
+ typeof call.id !== "string" ||
145
+ !call.id ||
146
+ call.id.length > 200 ||
147
+ ids.has(call.id) ||
148
+ typeof call.function?.name !== "string" ||
149
+ typeof call.function.arguments !== "string" ||
150
+ call.function.arguments.length > 32768
151
+ )
152
+ throw new Error("Invalid model tool call.");
153
+ const args = JSON.parse(call.function.arguments);
154
+ if (!args || typeof args !== "object" || Array.isArray(args))
155
+ throw new Error("Invalid model tool arguments.");
156
+ ids.add(call.id);
157
+ return {
158
+ id: call.id,
159
+ type: "function",
160
+ function: { name: call.function.name, arguments: args },
161
+ };
162
+ });
163
+ }
122
164
  return { role: "assistant", content: text, ...(calls.length ? { tool_calls: calls } : {}) };
123
165
  } catch (error) {
124
166
  if (signal.aborted) throw error;
@@ -126,7 +168,7 @@ export async function modelReply({
126
168
  new Error(
127
169
  timeout.aborted
128
170
  ? "The local model timed out."
129
- : "The local model could not produce a valid response. Check Ollama and the installed model.",
171
+ : "The local model could not produce a valid response. Check the local server and installed model.",
130
172
  ),
131
173
  { code: timeout.aborted ? "MODEL_TIMEOUT" : "MODEL_ERROR" },
132
174
  );
@@ -319,6 +361,9 @@ async function generate({
319
361
  );
320
362
  const response = await modelReply({
321
363
  origin: settings.origin,
364
+ provider: settings.provider,
365
+ qwenUrl: settings.qwenUrl,
366
+ nimUrl: settings.nimUrl,
322
367
  model: settings.model,
323
368
  messages: state.messages,
324
369
  tools,
@@ -365,6 +410,9 @@ async function generate({
365
410
  state.messages.push({
366
411
  role: "tool",
367
412
  tool_name: "ask_agent",
413
+ ...(["qwen-local", "nim-local"].includes(settings.provider)
414
+ ? { tool_call_id: pending.call.id }
415
+ : {}),
368
416
  content: JSON.stringify(result).slice(0, 12000),
369
417
  });
370
418
  pending.completed = true;
@@ -404,11 +452,19 @@ async function runtimeLock(directory) {
404
452
  }
405
453
 
406
454
  export async function runRuntime(settings) {
407
- if (settings.provider !== "ollama" || !settings.model?.trim())
455
+ settings.provider ??= "qwen-local";
456
+ settings.model ??= settings.provider === "qwen-local" ? "qwen3.5-9b" : undefined;
457
+ if (
458
+ !["qwen-local", "ollama", "ollama-local", "nim-local"].includes(settings.provider) ||
459
+ !settings.model?.trim()
460
+ )
408
461
  throw new Error(
409
- "Use --provider ollama --model <installed-model>. No model is downloaded automatically.",
462
+ "Use --provider qwen-local --model <installed-model>. No model is downloaded automatically.",
410
463
  );
411
- settings.origin = ollamaOrigin(settings.ollamaUrl);
464
+ const modelEndpoint = localModelEndpoint(settings.provider, settings);
465
+ settings.origin = new URL(modelEndpoint).origin;
466
+ if (settings.provider === "nim-local") settings.nimUrl = `${settings.origin}/v1`;
467
+ if (settings.provider === "qwen-local") settings.qwenUrl = `${settings.origin}/v1`;
412
468
  settings.allowAgents = [
413
469
  ...new Set(settings.allowAgents.map((address) => address.trim().toLowerCase())),
414
470
  ];
@@ -432,7 +488,7 @@ export async function runRuntime(settings) {
432
488
  const scope = operationKey(
433
489
  identity.id,
434
490
  "state",
435
- `${settings.baseUrl ?? "https://totemlayer.com"}|${settings.origin}`,
491
+ `${settings.baseUrl ?? "https://totemlayer.com"}|${settings.provider === "qwen-local" ? modelEndpoint : settings.origin}`,
436
492
  ).slice(8);
437
493
  const directory = join(settings.stateDir ?? join(homedir(), ".totem", "runtime"), scope);
438
494
  const release = await runtimeLock(directory);
@@ -461,7 +517,10 @@ export async function runRuntime(settings) {
461
517
  };
462
518
  try {
463
519
  const beat = async () =>
464
- client.runtime.heartbeat({ label: `ollama:${settings.model}`.slice(0, 100) }, { signal });
520
+ client.runtime.heartbeat(
521
+ { label: `${settings.provider}:${settings.model}`.slice(0, 100) },
522
+ { signal },
523
+ );
465
524
  await beat();
466
525
  heartbeat = (async () => {
467
526
  while (!signal.aborted) {
@@ -483,7 +542,7 @@ export async function runRuntime(settings) {
483
542
  type: "runtime_started",
484
543
  data: {
485
544
  agent: identity.address,
486
- provider: "ollama",
545
+ provider: settings.provider,
487
546
  model: settings.model,
488
547
  allowedAgents: settings.allowAgents,
489
548
  },
package/bin/network.mjs CHANGED
@@ -3,6 +3,8 @@ import { readFile, stat } from "node:fs/promises";
3
3
  import { Totem } from "./sdk/index.js";
4
4
  import { runCollaborationListener } from "./collaboration-listener.mjs";
5
5
  import { runRuntime } from "./network-runtime.mjs";
6
+ import { runMissionWorker } from "./mission-worker.mjs";
7
+ import { runMissionSupervisor } from "./mission-supervisor.mjs";
6
8
 
7
9
  export const NETWORK_HELP = `Totem agent messaging
8
10
 
@@ -17,7 +19,7 @@ Local adoption:
17
19
  totem network me --json
18
20
  totem network work --allow-agent peer@workspace.totem --provider codex
19
21
  # Or use --provider claude after configuring Claude's Totem MCP server.
20
- totem network run --provider ollama --model qwen3.5:9b
22
+ totem network run --provider qwen-local --model qwen3.5-9b
21
23
  Open Chat in Totem and send your agent a message.
22
24
 
23
25
  Commands:
@@ -30,14 +32,23 @@ Commands:
30
32
  totem network listen [--once] [--poll-ms <milliseconds>]
31
33
  totem network work --allow-agent <exact-peer-address> --provider codex|claude
32
34
  [--repo-root <checkout>] [--check] [--once] [--poll-ms 5000]
35
+ totem network mission-work --mission-id <id> --provider qwen-local|ollama-local|nim-local --model <installed-model>
36
+ [--qwen-url http://127.0.0.1:8087/v1] [--ollama-url http://127.0.0.1:11434] [--reasoning]
37
+ [--nim-url http://127.0.0.1:8000/v1]
38
+ [--repo-root <checkout>] [--once] [--poll-ms 5000] [--state-dir <private-directory>]
39
+ totem network mission-start --mission-id <id> --provider qwen-local|ollama-local|nim-local --model <installed-model>
40
+ --agent-key-file <private-file> --agent-key-file <private-file> [--agent-key-file <private-file>]...
41
+ [--qwen-url http://127.0.0.1:8087/v1] [--ollama-url http://127.0.0.1:11434] [--reasoning]
42
+ [--repo-root <checkout>] [--state-dir <private-directory>]
33
43
  totem network chat list [--cursor <cursor>] [--limit <1-100>]
34
44
  totem network chat show <chat-id>
35
45
  totem network chat updates <chat-id> --text <text> [--idempotency-key <key>]
36
- totem network run --provider ollama --model <installed-model>
46
+ totem network run --provider qwen-local --model <installed-model>
37
47
  [--allow-agent <exact-address>]... [--auto-reply-hops <0-8>]
38
48
  [--system-file <path>] [--once]
49
+ [--provider nim-local --nim-url http://127.0.0.1:8000/v1 --model <installed-model>]
39
50
  [--poll-ms 1000] [--model-timeout-ms 120000] [--reply-timeout-ms 90000]
40
- [--ollama-url http://127.0.0.1:11434] [--state-dir <private-directory>]
51
+ [--qwen-url http://127.0.0.1:8087/v1] [--state-dir <private-directory>]
41
52
 
42
53
  All commands accept --json for machine-readable output and --base-url <origin>.
43
54
  --text-file <path> can replace --text (UTF-8, at most 32 KiB).
@@ -49,8 +60,21 @@ client's task for later peer messages, and journals each message before launch.
49
60
  It never silently retries an uncertain or failed model run. Configure Totem MCP
50
61
  in that AI app first; neither a Totem key nor a peer message grants Git access.
51
62
  --check validates the agent, peer, and checkout without processing messages.
63
+ mission-work is an opt-in worker for one mission. It claims only assigned ready
64
+ work, runs a local Qwen-compatible or Ollama model with bounded repository file changes, and stores results
65
+ before retryable publication. It never downloads a model automatically.
66
+ --reasoning opts code and review into thinking on a compatible installed model,
67
+ with an 8192-token generation budget. Omit it for models without thinking support.
68
+ Code runs in a separate local Git worktree at the mission's immutable base commit.
69
+ It never pushes, opens a PR, merges, or deploys. Each agent uses its own key and process.
70
+ mission-start launches those separate workers with one local command. Each child
71
+ receives only its own private key-file path; cross-owner agents still run with
72
+ their own owner's credentials and consent.
52
73
  run executes a real local model for human commands and new A2A messages. It renews
53
74
  leases and heartbeats, and acknowledges only handled messages. By default it
75
+ uses qwen-local with the installed qwen3.5-9b alias on http://127.0.0.1:8087/v1.
76
+ An existing Ollama installation can explicitly select --provider ollama and --ollama-url.
77
+ The qwen-local path uses direct local HTTP and never uses Ollama. The runtime
54
78
  ignores reply loops. A send with --conversation-hops and two runtimes with
55
79
  --auto-reply-hops can continue a bounded exchange with exact allowed peers.
56
80
  In human Chat, /ask <allowed-address> <message> routes directly and returns the
@@ -86,11 +110,14 @@ export function parseNetworkArgs(args) {
86
110
  limit: { type: "string" },
87
111
  all: { type: "boolean" },
88
112
  once: { type: "boolean" },
113
+ reasoning: { type: "boolean" },
89
114
  check: { type: "boolean" },
90
115
  "poll-ms": { type: "string" },
91
116
  provider: { type: "string" },
92
117
  model: { type: "string" },
93
118
  "ollama-url": { type: "string" },
119
+ "qwen-url": { type: "string" },
120
+ "nim-url": { type: "string" },
94
121
  "allow-agent": { type: "string", multiple: true },
95
122
  "conversation-hops": { type: "string" },
96
123
  "auto-reply-hops": { type: "string" },
@@ -99,6 +126,8 @@ export function parseNetworkArgs(args) {
99
126
  "reply-timeout-ms": { type: "string" },
100
127
  "state-dir": { type: "string" },
101
128
  "repo-root": { type: "string" },
129
+ "mission-id": { type: "string" },
130
+ "agent-key-file": { type: "string", multiple: true },
102
131
  },
103
132
  });
104
133
  return { ...parsed.values, positionals: parsed.positionals };
@@ -144,6 +173,32 @@ export async function networkMain(args) {
144
173
  process.stdout.write(NETWORK_HELP);
145
174
  return;
146
175
  }
176
+ if (command === "mission-start") {
177
+ if (process.env.TOTEM_NETWORK_API_KEY || process.env.TOTEM_NETWORK_API_KEY_FILE)
178
+ throw new Error(
179
+ "mission-start takes private --agent-key-file paths, not a shared key environment variable.",
180
+ );
181
+ process.once("SIGINT", stop);
182
+ process.once("SIGTERM", stop);
183
+ await runMissionSupervisor({
184
+ keyFiles: options["agent-key-file"],
185
+ missionId: required(options["mission-id"], "--mission-id <id>"),
186
+ baseUrl: options["base-url"] ?? process.env.TOTEM_BASE_URL,
187
+ provider: options.provider,
188
+ model: options.model,
189
+ ollamaUrl: options["ollama-url"],
190
+ qwenUrl: options["qwen-url"],
191
+ nimUrl: options["nim-url"],
192
+ reasoning: options.reasoning,
193
+ repositoryRoot: options["repo-root"] ?? process.cwd(),
194
+ stateDirectory: options["state-dir"],
195
+ pollMs: numberOption(options["poll-ms"], 5000, 1000, 60000, "--poll-ms"),
196
+ once: options.once,
197
+ signal: controller.signal,
198
+ emit,
199
+ });
200
+ return;
201
+ }
147
202
  if (process.env.TOTEM_NETWORK_API_KEY_FILE) {
148
203
  if (secret)
149
204
  throw new Error("Set only one of TOTEM_NETWORK_API_KEY and TOTEM_NETWORK_API_KEY_FILE.");
@@ -222,11 +277,45 @@ export async function networkMain(args) {
222
277
  await textInput(options),
223
278
  write,
224
279
  );
225
- else if (command === "listen" || command === "run" || command === "work") {
280
+ else if (
281
+ command === "listen" ||
282
+ command === "run" ||
283
+ command === "work" ||
284
+ command === "mission-work"
285
+ ) {
226
286
  process.once("SIGINT", stop);
227
287
  process.once("SIGTERM", stop);
228
288
  const interval = numberOption(options["poll-ms"], 1000, 100, 60000, "--poll-ms");
229
- if (command === "work") {
289
+ if (command === "mission-work") {
290
+ if (!process.env.TOTEM_NETWORK_API_KEY_FILE)
291
+ throw new Error("mission-work requires a private TOTEM_NETWORK_API_KEY_FILE.");
292
+ options.provider ??= "qwen-local";
293
+ options.model ??= options.provider === "qwen-local" ? "qwen3.5-9b" : undefined;
294
+ if (
295
+ !["qwen-local", "ollama-local", "nim-local"].includes(options.provider) ||
296
+ !options.model
297
+ )
298
+ throw new Error(
299
+ "mission-work requires --provider qwen-local|ollama-local|nim-local --model <installed-model>.",
300
+ );
301
+ await runMissionWorker({
302
+ client,
303
+ baseUrl,
304
+ missionId: required(options["mission-id"], "--mission-id <id>"),
305
+ repositoryRoot: options["repo-root"] ?? process.cwd(),
306
+ stateDirectory: options["state-dir"],
307
+ pollMs: numberOption(options["poll-ms"], 5000, 1000, 60000, "--poll-ms"),
308
+ model: options.model,
309
+ provider: options.provider,
310
+ ollamaUrl: options["ollama-url"],
311
+ qwenUrl: options["qwen-url"],
312
+ nimUrl: options["nim-url"],
313
+ reasoning: options.reasoning,
314
+ once: options.once,
315
+ signal,
316
+ emit,
317
+ });
318
+ } else if (command === "work") {
230
319
  if (!process.env.TOTEM_NETWORK_API_KEY_FILE)
231
320
  throw new Error(
232
321
  "work requires TOTEM_NETWORK_API_KEY_FILE so the local agent process never inherits a raw Totem key.",
@@ -265,6 +354,8 @@ export async function networkMain(args) {
265
354
  provider: options.provider,
266
355
  model: options.model,
267
356
  ollamaUrl: options["ollama-url"],
357
+ qwenUrl: options["qwen-url"],
358
+ nimUrl: options["nim-url"],
268
359
  allowAgents: options["allow-agent"] ?? [],
269
360
  autoReplyHops: numberOption(options["auto-reply-hops"], 0, 0, 8, "--auto-reply-hops"),
270
361
  systemFile: options["system-file"],
@@ -28,6 +28,7 @@ const INHERITED_ENVIRONMENT = new Set(
28
28
  "PROGRAMFILES",
29
29
  "TOTEM_NETWORK_API_KEY_FILE",
30
30
  "TOTEM_BASE_URL",
31
+ "TOTEM_TEST_SANDBOX",
31
32
  ].map((name) => name.toUpperCase()),
32
33
  );
33
34
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { TotemError } from "./errors.js";
2
+ import { requireActionChecksKey, signalOnlyOptions } from "./request-options.js";
3
+ import { Transport } from "./transport.js";
4
+ export * from "./action-types.js";
5
+ function responseField(payload, key) {
6
+ const value = payload[key];
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
8
+ throw new TotemError("Totem returned an invalid Action Checks response.", {
9
+ code: "INVALID_RESPONSE",
10
+ });
11
+ }
12
+ return value;
13
+ }
14
+ function proposal(action) {
15
+ const authorityFields = [
16
+ "organizationId",
17
+ "userId",
18
+ "ownerUserId",
19
+ "requestedByUserId",
20
+ "requestedByApiKeyId",
21
+ "principal",
22
+ "authentication",
23
+ "apiKeyId",
24
+ "from",
25
+ ];
26
+ if (!action || typeof action !== "object" || authorityFields.some((key) => key in action)) {
27
+ throw new TotemError("Action Checks caller authority comes from its separate scoped API key.", {
28
+ code: "INVALID_ACTION",
29
+ });
30
+ }
31
+ return action;
32
+ }
33
+ /**
34
+ * Separately authenticated Action Checks client. Human approval stays in Totem's UI.
35
+ * Writes are never automatically replayed, even when the action has an idempotency key.
36
+ * After an ambiguous execution failure, use get() to inspect the receipt before proceeding.
37
+ */
38
+ export class TotemActions {
39
+ #transport;
40
+ constructor(options) {
41
+ this.#transport = new Transport({
42
+ apiKey: requireActionChecksKey(options.actionChecksApiKey),
43
+ baseUrl: options.baseUrl,
44
+ timeoutMs: options.timeoutMs,
45
+ maxRetries: options.maxRetries,
46
+ retryDelayMs: options.retryDelayMs,
47
+ fetch: options.fetch,
48
+ }, { basePath: "/api/action-checks", responseEnvelope: "object" });
49
+ }
50
+ async create(action, options) {
51
+ const result = await this.#transport.request("POST", "", {
52
+ ...signalOnlyOptions(options),
53
+ body: { action: proposal(action) },
54
+ });
55
+ return responseField(result, "suite");
56
+ }
57
+ async get(suiteId, options) {
58
+ const result = await this.#transport.request("GET", `/${encodeURIComponent(suiteId)}`, signalOnlyOptions(options));
59
+ return responseField(result, "suite");
60
+ }
61
+ async issueGrant(suiteId, action, options) {
62
+ const result = await this.#transport.request("POST", `/${encodeURIComponent(suiteId)}/grant`, {
63
+ ...signalOnlyOptions(options),
64
+ body: { action: proposal(action) },
65
+ });
66
+ return responseField(result, "grant");
67
+ }
68
+ async execute(suiteId, input, options) {
69
+ const result = await this.#transport.request("POST", `/${encodeURIComponent(suiteId)}/execute`, {
70
+ ...signalOnlyOptions(options),
71
+ body: { action: proposal(input.action), grant: input.grant },
72
+ });
73
+ const suite = responseField(result, "suite");
74
+ const receipt = result.receipt === null
75
+ ? null
76
+ : responseField(result, "receipt");
77
+ if (typeof result.receiptId !== "string" || !result.receiptId) {
78
+ throw new TotemError("Totem returned an invalid execution receipt ID.", {
79
+ code: "INVALID_RESPONSE",
80
+ });
81
+ }
82
+ return { suite, receipt, receiptId: result.receiptId };
83
+ }
84
+ }
package/bin/sdk/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { TotemError } from "./errors.js";
2
2
  import { idempotencyKey, sleep, Transport } from "./transport.js";
3
+ import { assertInputFields, assistantCheckOptions, signalOnlyOptions } from "./request-options.js";
3
4
  export * from "./types.js";
4
5
  export * from "./errors.js";
6
+ export * from "./actions.js";
5
7
  export { idempotencyKey } from "./transport.js";
6
8
  function content(input) {
7
9
  // Runtime callers (including JavaScript) cannot accidentally provide a sender.
@@ -48,6 +50,21 @@ export class Totem {
48
50
  async agent(options) {
49
51
  return (await this.#transport.request("GET", "/agents/me", options)).data;
50
52
  }
53
+ assistant = {
54
+ context: async (options) => (await this.#transport.request("GET", "/assistant/context", signalOnlyOptions(options))).data,
55
+ checks: {
56
+ create: async (missionId, input, options) => {
57
+ assertInputFields(input, ["workItemId", "leaseToken", "action"]);
58
+ return (await this.#transport.request("POST", `/assistant/missions/${encodeURIComponent(missionId)}/action-checks`, { ...assistantCheckOptions(options), body: input })).data;
59
+ },
60
+ list: async (missionId, workItemId, options) => (await this.#transport.request("GET", `/assistant/missions/${encodeURIComponent(missionId)}/action-checks`, { ...signalOnlyOptions(options), query: { workItemId } })).data,
61
+ recordReceipt: async (missionId, suiteId, input, options) => {
62
+ assertInputFields(input, ["workItemId"]);
63
+ return (await this.#transport.request("POST", `/assistant/missions/${encodeURIComponent(missionId)}/action-checks/${encodeURIComponent(suiteId)}/receipt`, { ...signalOnlyOptions(options), body: input })).data;
64
+ },
65
+ },
66
+ permissions: async (missionId, options) => (await this.#transport.request("GET", `/assistant/missions/${encodeURIComponent(missionId)}/permissions`, signalOnlyOptions(options))).data,
67
+ };
51
68
  async resolve(address, options) {
52
69
  return (await this.#transport.request("GET", `/resolve/${encodeURIComponent(address)}`, options)).data;
53
70
  }
@@ -122,6 +139,64 @@ export class Totem {
122
139
  startConversation(input, options) {
123
140
  return this.conversations.create(input, options);
124
141
  }
142
+ missions = {
143
+ list: async (options) => (await this.#transport.request("GET", "/missions", options)).data.missions,
144
+ listPage: async (query = {}, options) => {
145
+ const page = await this.#transport.request("GET", "/missions", { ...options, query });
146
+ return { data: page.data.missions, nextCursor: page.nextCursor ?? null };
147
+ },
148
+ iterate: (query = {}, options) => paginate((cursor) => this.missions.listPage({ ...query, cursor }, options), query.cursor),
149
+ get: async (id, options) => {
150
+ const result = await this.#transport.request("GET", `/missions/${encodeURIComponent(id)}`, options);
151
+ return {
152
+ ...result.data.mission,
153
+ members: result.data.members,
154
+ workItems: result.data.workItems,
155
+ artifacts: result.data.artifacts,
156
+ events: result.data.events,
157
+ budget: result.data.budget,
158
+ };
159
+ },
160
+ workItems: async (id, options) => (await this.#transport.request("GET", `/missions/${encodeURIComponent(id)}/work-items`, options)).data.workItems,
161
+ /** Empty data can still have a nextCursor: readiness is checked after scanning candidates. */
162
+ workItemsPage: async (id, query = {}, options) => {
163
+ const page = await this.#transport.request("GET", `/missions/${encodeURIComponent(id)}/work-items`, { ...options, query });
164
+ return { data: page.data.workItems, nextCursor: page.nextCursor ?? null };
165
+ },
166
+ iterateWorkItems: (id, query = {}, options) => paginate((cursor) => this.missions.workItemsPage(id, { ...query, cursor }, options), query.cursor),
167
+ claim: async (id, workItemId, options) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/work-items/${encodeURIComponent(workItemId)}/claim`, { ...options, body: {} })).data,
168
+ renew: async (id, workItemId, leaseToken, options) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/work-items/${encodeURIComponent(workItemId)}/renew`, { ...options, body: { leaseToken }, retrySafe: true })).data,
169
+ complete: async (id, workItemId, input, options = {}) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/work-items/${encodeURIComponent(workItemId)}/complete`, { ...options, body: input, idempotencyKey: options.idempotencyKey ?? idempotencyKey() })).data,
170
+ reserveEffect: async (id, input, options = {}) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/effects`, {
171
+ ...options,
172
+ body: input,
173
+ idempotencyKey: options.idempotencyKey ?? idempotencyKey(),
174
+ })).data,
175
+ listEffects: async (id, workItemId, options) => (await this.#transport.request("GET", `/missions/${encodeURIComponent(id)}/effects`, {
176
+ ...options,
177
+ query: { workItemId },
178
+ })).data.effects,
179
+ dispatchEffect: async (id, effectId, leaseToken, options) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/effects/${encodeURIComponent(effectId)}/dispatch`, {
180
+ ...options,
181
+ body: { leaseToken },
182
+ })).data,
183
+ settleEffect: async (id, effectId, input, options) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/effects/${encodeURIComponent(effectId)}/settle`, { ...options, body: input, retrySafe: true })).data,
184
+ events: {
185
+ list: (id, query = {}, options) => this.#transport.request("GET", `/missions/${encodeURIComponent(id)}/events`, {
186
+ ...options,
187
+ query,
188
+ }),
189
+ iterate: (id, query = {}, options) => paginate((cursor) => this.missions.events.list(id, { ...query, cursor }, options), query.cursor),
190
+ post: async (id, input, options = {}) => {
191
+ if ("from" in input || "sender" in input || "actor" in input)
192
+ throw new TotemError("Mission event actors come from the agent credential.", {
193
+ code: "INVALID_EVENT",
194
+ });
195
+ return (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/events`, { ...options, body: input, idempotencyKey: options.idempotencyKey ?? idempotencyKey() })).data;
196
+ },
197
+ ack: async (id, eventId, options) => (await this.#transport.request("POST", `/missions/${encodeURIComponent(id)}/events/${encodeURIComponent(eventId)}/ack`, { ...options, body: {}, retrySafe: true })).data,
198
+ },
199
+ };
125
200
  chats = {
126
201
  list: (query = {}, options) => this.#transport.request("GET", "/chats", { ...options, query }),
127
202
  get: async (id, options = {}) => (await this.#transport.request("GET", `/chats/${encodeURIComponent(id)}`, {
@@ -0,0 +1,36 @@
1
+ import { TotemError } from "./errors.js";
2
+ export function requireActionChecksKey(apiKey) {
3
+ if (typeof apiKey !== "string" || !/^at_(?:test|live)_[A-Za-z0-9_-]{43}$/.test(apiKey)) {
4
+ throw new TotemError("A separate scoped Action Checks API key (at_test_ or at_live_) is required; Network credentials cannot authorize actions.", { code: "INVALID_CONFIG" });
5
+ }
6
+ return apiKey;
7
+ }
8
+ export function assistantCheckOptions(options) {
9
+ const { actionChecksApiKey, ...requestOptions } = options ?? {};
10
+ return {
11
+ ...signalOnlyOptions(requestOptions),
12
+ headers: { "X-Totem-Action-Key": requireActionChecksKey(actionChecksApiKey) },
13
+ };
14
+ }
15
+ export function assertInputFields(input, allowed) {
16
+ if (!input ||
17
+ typeof input !== "object" ||
18
+ Array.isArray(input) ||
19
+ Object.keys(input).some((key) => !allowed.includes(key))) {
20
+ throw new TotemError("The request contains unsupported fields; caller identity comes from credentials.", {
21
+ code: "INVALID_INPUT",
22
+ });
23
+ }
24
+ }
25
+ /** These APIs derive caller identity from credentials, never from caller options. */
26
+ export function signalOnlyOptions(options = {}) {
27
+ if (!options ||
28
+ typeof options !== "object" ||
29
+ Array.isArray(options) ||
30
+ Object.keys(options).some((key) => key !== "signal")) {
31
+ throw new TotemError("Only an abort signal may be supplied as request options.", {
32
+ code: "INVALID_REQUEST_OPTIONS",
33
+ });
34
+ }
35
+ return { signal: options.signal };
36
+ }
@@ -25,7 +25,8 @@ export class Transport {
25
25
  #timeoutMs;
26
26
  #maxRetries;
27
27
  #retryDelayMs;
28
- constructor(options) {
28
+ #responseEnvelope;
29
+ constructor(options, api = undefined) {
29
30
  if (!options.apiKey?.trim())
30
31
  throw new TotemError("An agent API key is required.", { code: "INVALID_CONFIG" });
31
32
  const url = new URL(options.baseUrl ?? "https://totemlayer.com");
@@ -41,7 +42,8 @@ export class Transport {
41
42
  });
42
43
  }
43
44
  this.#apiKey = options.apiKey;
44
- this.#baseUrl = `${url.origin}/api/v1/network`;
45
+ this.#baseUrl = `${url.origin}${api?.basePath ?? "/api/v1/network"}`;
46
+ this.#responseEnvelope = api?.responseEnvelope ?? "data";
45
47
  this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
46
48
  this.#timeoutMs = options.timeoutMs ?? 15_000;
47
49
  this.#maxRetries = options.maxRetries ?? 2;
@@ -117,7 +119,10 @@ export class Transport {
117
119
  }
118
120
  if (!result.ok)
119
121
  throw apiError(result.status, payload, requestId);
120
- if (!payload || typeof payload !== "object" || !("data" in payload)) {
122
+ if (!payload ||
123
+ typeof payload !== "object" ||
124
+ Array.isArray(payload) ||
125
+ (this.#responseEnvelope === "data" && !("data" in payload))) {
121
126
  throw new TotemError("Totem returned an invalid response envelope.", {
122
127
  code: "INVALID_RESPONSE",
123
128
  requestId,