totemlayer 0.1.3 → 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.
- package/README.md +36 -11
- package/bin/agent-profile.mjs +466 -0
- package/bin/agent-runner.mjs +609 -0
- package/bin/agent-setup.mjs +659 -0
- package/bin/collaboration-companion.mjs +14 -33
- package/bin/collaboration-listener.mjs +13 -27
- package/bin/device-approval.mjs +93 -0
- package/bin/docker-test-sandbox.mjs +61 -0
- package/bin/local-mcp.mjs +1 -1
- package/bin/local-model-provider.mjs +29 -0
- package/bin/mcp-install.mjs +257 -14
- package/bin/mcp.mjs +1 -1
- package/bin/mission-agent-service.mjs +441 -0
- package/bin/mission-agent-session.mjs +188 -0
- package/bin/mission-supervisor.mjs +166 -0
- package/bin/mission-worker.mjs +3383 -0
- package/bin/network-runtime.mjs +86 -27
- package/bin/network.mjs +96 -5
- package/bin/platform-client.mjs +84 -0
- package/bin/sdk/action-types.js +1 -0
- package/bin/sdk/actions.js +84 -0
- package/bin/sdk/index.js +75 -0
- package/bin/sdk/request-options.js +36 -0
- package/bin/sdk/transport.js +8 -3
- package/bin/totem.mjs +23 -2
- package/package.json +1 -1
package/bin/network-runtime.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
const text = typeof
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
462
|
+
"Use --provider qwen-local --model <installed-model>. No model is downloaded automatically.",
|
|
410
463
|
);
|
|
411
|
-
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
[--
|
|
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 (
|
|
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"],
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
const INHERITED_ENVIRONMENT = new Set(
|
|
4
|
+
[
|
|
5
|
+
"PATH",
|
|
6
|
+
"HOME",
|
|
7
|
+
"USER",
|
|
8
|
+
"TMPDIR",
|
|
9
|
+
"LANG",
|
|
10
|
+
"LC_ALL",
|
|
11
|
+
"CODEX_HOME",
|
|
12
|
+
"CLAUDE_CONFIG_DIR",
|
|
13
|
+
"XDG_CONFIG_HOME",
|
|
14
|
+
"XDG_DATA_HOME",
|
|
15
|
+
"XDG_RUNTIME_DIR",
|
|
16
|
+
"USERPROFILE",
|
|
17
|
+
"HOMEDRIVE",
|
|
18
|
+
"HOMEPATH",
|
|
19
|
+
"APPDATA",
|
|
20
|
+
"LOCALAPPDATA",
|
|
21
|
+
"SYSTEMROOT",
|
|
22
|
+
"WINDIR",
|
|
23
|
+
"COMSPEC",
|
|
24
|
+
"PATHEXT",
|
|
25
|
+
"TEMP",
|
|
26
|
+
"TMP",
|
|
27
|
+
"PROGRAMDATA",
|
|
28
|
+
"PROGRAMFILES",
|
|
29
|
+
"TOTEM_NETWORK_API_KEY_FILE",
|
|
30
|
+
"TOTEM_BASE_URL",
|
|
31
|
+
"TOTEM_TEST_SANDBOX",
|
|
32
|
+
].map((name) => name.toUpperCase()),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export function localClientEnvironment(environment = process.env, extra = {}) {
|
|
36
|
+
return {
|
|
37
|
+
...Object.fromEntries(
|
|
38
|
+
Object.entries(environment).filter(
|
|
39
|
+
([name, value]) => value && INHERITED_ENVIRONMENT.has(name.toUpperCase()),
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
...extra,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function powershellInvocation(script, environment = process.env) {
|
|
47
|
+
const systemRoot = environment.SystemRoot ?? environment.SYSTEMROOT;
|
|
48
|
+
return {
|
|
49
|
+
command: systemRoot
|
|
50
|
+
? join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
51
|
+
: "powershell.exe",
|
|
52
|
+
args: [
|
|
53
|
+
"-NoProfile",
|
|
54
|
+
"-NonInteractive",
|
|
55
|
+
"-EncodedCommand",
|
|
56
|
+
Buffer.from(script, "utf16le").toString("base64"),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// PowerShell resolves both native executables and npm's .cmd shims. Only a
|
|
62
|
+
// base64-encoded JSON argument array is interpolated into the script, so a
|
|
63
|
+
// Windows path can never become shell syntax.
|
|
64
|
+
export function installedClientInvocation(command, args, environment = process.env) {
|
|
65
|
+
const payload = Buffer.from(JSON.stringify([command, ...args]), "utf8").toString("base64");
|
|
66
|
+
return powershellInvocation(
|
|
67
|
+
`$ErrorActionPreference = 'Stop'\n$items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json\n$client = [string]$items[0]\n$clientArgs = @($items | Select-Object -Skip 1)\n& $client @clientArgs\nif ($null -eq $LASTEXITCODE) { exit 1 }\nexit $LASTEXITCODE`,
|
|
68
|
+
environment,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function streamingClientInvocation(command, args, environment = process.env) {
|
|
73
|
+
if (!["codex", "claude"].includes(command))
|
|
74
|
+
throw new Error("Choose Codex or Claude Code as the local provider.");
|
|
75
|
+
// All arguments are Totem-owned flags except a resumable session ID. A
|
|
76
|
+
// Windows command shell is needed for npm-installed .cmd shims, so reject
|
|
77
|
+
// anything that could be interpreted as shell syntax before launching it.
|
|
78
|
+
if (!args.every((arg) => /^[A-Za-z0-9_-]+$/.test(arg)))
|
|
79
|
+
throw new Error("The local client arguments are not safe to launch on Windows.");
|
|
80
|
+
return {
|
|
81
|
+
command: environment.ComSpec ?? environment.COMSPEC ?? "cmd.exe",
|
|
82
|
+
args: ["/d", "/s", "/c", [command, ...args].join(" ")],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -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)}`, {
|