@operator-labs/operator-cli 0.1.0

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,122 @@
1
+ // Research pipeline: gather data, analyze, validate, synthesize.
2
+ //
3
+ // Demonstrates: sequential steps with data validation between them,
4
+ // file-as-shared-memory, deterministic logic mixed with agent steps,
5
+ // retry on malformed output, per-run JSONL tracking.
6
+ //
7
+ // Usage: node examples/research-pipeline.mjs ["topic here"]
8
+
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { invoke, think } from "@operator-labs/operator-cli";
12
+ import { randomUUID } from "crypto";
13
+
14
+ const WORK_DIR = join(process.cwd(), "workflows", "research-pipeline");
15
+ for (const dir of ["runs", "state", "results"]) {
16
+ mkdirSync(join(WORK_DIR, dir), { recursive: true });
17
+ }
18
+
19
+ const TOPIC = process.argv[2] || "open source AI frameworks in 2026";
20
+
21
+ function logRun(runId, entry) {
22
+ appendFileSync(join(WORK_DIR, "runs", runId + ".jsonl"),
23
+ JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n");
24
+ }
25
+
26
+ function validateJson(path) {
27
+ try {
28
+ JSON.parse(readFileSync(path, "utf-8"));
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function waitForFile(path, maxWaitMs = 300000) {
36
+ const start = Date.now();
37
+ while (Date.now() - start < maxWaitMs) {
38
+ if (existsSync(path) && readFileSync(path, "utf-8").trim()) return true;
39
+ await new Promise(r => setTimeout(r, 5000));
40
+ }
41
+ return false;
42
+ }
43
+
44
+ // Step 1: Agent gathers raw research
45
+ const findingsPath = join(WORK_DIR, "state/raw-findings.json");
46
+ const msg1 =
47
+ "Research the topic: " + TOPIC + ". " +
48
+ "Browse relevant sources, collect key facts and data points. " +
49
+ "Write raw findings as JSON to " + findingsPath + ". " +
50
+ "Format: {sources: [{url, title, summary}], keyFacts: [string]}";
51
+ const run1 = await invoke(msg1);
52
+ logRun(run1.runId, { type: "invoke", step: "gather", message: msg1.slice(0, 200), ok: run1.ok });
53
+ await waitForFile(findingsPath, 300000);
54
+ logRun(run1.runId, { type: "poll", path: findingsPath, found: existsSync(findingsPath) });
55
+
56
+ if (!validateJson(findingsPath)) {
57
+ const msg1r =
58
+ "The previous research output was not valid JSON. " +
59
+ "Read " + findingsPath + " and fix it. " +
60
+ "Write valid JSON to the same path. " +
61
+ "Format: {sources: [{url, title, summary}], keyFacts: [string]}";
62
+ const run1r = await invoke(msg1r);
63
+ logRun(run1r.runId, { type: "invoke", step: "gather-retry", message: msg1r.slice(0, 200), ok: run1r.ok });
64
+ await waitForFile(findingsPath, 120000);
65
+ }
66
+
67
+ const findings = JSON.parse(readFileSync(findingsPath, "utf-8"));
68
+
69
+ // Step 2: think() to extract themes
70
+ const thinkId = randomUUID();
71
+ const themesResult = await think(JSON.stringify({
72
+ prompt: "Identify the 3-5 main themes from these research findings. " +
73
+ "Return {themes: [{name: string, description: string, relevantFacts: number[]}]}",
74
+ input: findings,
75
+ schema: {
76
+ type: "object",
77
+ properties: {
78
+ themes: {
79
+ type: "array",
80
+ items: {
81
+ type: "object",
82
+ properties: {
83
+ name: { type: "string" },
84
+ description: { type: "string" },
85
+ relevantFacts: { type: "array", items: { type: "number" } },
86
+ },
87
+ required: ["name", "description"],
88
+ },
89
+ },
90
+ },
91
+ required: ["themes"],
92
+ },
93
+ }));
94
+ logRun(thinkId, { type: "think", step: "themes", ok: themesResult.ok, themeCount: themesResult.output?.themes?.length });
95
+
96
+ const themesPath = join(WORK_DIR, "state/themes.json");
97
+ writeFileSync(themesPath, JSON.stringify(themesResult.output, null, 2));
98
+
99
+ // Step 3: Agent writes detailed analysis
100
+ const analysisPath = join(WORK_DIR, "results/analysis.md");
101
+ const msg3 =
102
+ "Read the research findings at " + findingsPath +
103
+ " and the themes at " + themesPath + ". " +
104
+ "Write a detailed analysis to " + analysisPath + ". " +
105
+ "Structure it with one section per theme, citing sources. " +
106
+ "Include a summary table at the top.";
107
+ const run3 = await invoke(msg3);
108
+ logRun(run3.runId, { type: "invoke", step: "analysis", message: msg3.slice(0, 200), ok: run3.ok });
109
+ await waitForFile(analysisPath, 300000);
110
+
111
+ // Step 4: Agent writes executive summary
112
+ const summaryPath = join(WORK_DIR, "results/summary.md");
113
+ const msg4 =
114
+ "Read the analysis at " + analysisPath + ". " +
115
+ "Write a concise executive summary (max 500 words) to " + summaryPath + ". " +
116
+ "Focus on actionable takeaways.";
117
+ const run4 = await invoke(msg4);
118
+ logRun(run4.runId, { type: "invoke", step: "summary", message: msg4.slice(0, 200), ok: run4.ok });
119
+ await waitForFile(summaryPath, 120000);
120
+ logRun(run4.runId, { type: "done" });
121
+
122
+ console.log("results: " + join(WORK_DIR, "results/"));
package/index.mjs ADDED
@@ -0,0 +1,5 @@
1
+ export { invoke } from "./lib/invoke.mjs";
2
+ export { think } from "./lib/think.mjs";
3
+ export { setup } from "./lib/setup.mjs";
4
+ export { resolveBackend, detectInstalledAgents, detectRuntime } from "./lib/backend.mjs";
5
+ export { resolveConfigPath, readConfig, resolveGatewayToken, resolveHooksToken } from "./lib/config.mjs";
@@ -0,0 +1,71 @@
1
+ import { existsSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const BACKENDS = ["openclaw"];
6
+
7
+ const LEGACY_STATE_DIRS = [".clawdbot", ".moldbot", ".moltbot"];
8
+
9
+ function detectRuntime() {
10
+ const { env } = process;
11
+
12
+ if (env.AI_AGENT) {
13
+ const name = env.AI_AGENT.trim().toLowerCase();
14
+ if (name === "openclaw") return "openclaw";
15
+ }
16
+
17
+ if (env.OPENCLAW_SHELL) return "openclaw";
18
+
19
+ return null;
20
+ }
21
+
22
+ const DETECT_INSTALLED = {
23
+ openclaw: () => {
24
+ const home = homedir();
25
+ return [".openclaw", ...LEGACY_STATE_DIRS].some(
26
+ (d) => existsSync(join(home, d)),
27
+ );
28
+ },
29
+ };
30
+
31
+ let _cached = null;
32
+
33
+ export async function resolveBackend() {
34
+ if (_cached) return _cached;
35
+
36
+ const runtime = detectRuntime();
37
+ if (runtime) {
38
+ _cached = await loadBackend(runtime);
39
+ return _cached;
40
+ }
41
+
42
+ for (const name of BACKENDS) {
43
+ if (DETECT_INSTALLED[name]()) {
44
+ _cached = await loadBackend(name);
45
+ return _cached;
46
+ }
47
+ }
48
+
49
+ throw new Error(
50
+ "No agent host detected. Install OpenClaw.",
51
+ );
52
+ }
53
+
54
+ export function detectInstalledAgents() {
55
+ return BACKENDS.filter((name) => DETECT_INSTALLED[name]());
56
+ }
57
+
58
+ export { detectRuntime };
59
+
60
+ async function loadBackend(name) {
61
+ try {
62
+ return await import(`./backends/${name}.mjs`);
63
+ } catch (err) {
64
+ if (err.code === "ERR_MODULE_NOT_FOUND") {
65
+ throw new Error(
66
+ "Backend '" + name + "' is not yet implemented.",
67
+ );
68
+ }
69
+ throw err;
70
+ }
71
+ }
@@ -0,0 +1,151 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ resolveConfigPath,
7
+ readConfig,
8
+ resolveHooksToken,
9
+ resolveGatewayToken,
10
+ resolveGatewayPort,
11
+ resolveStateDir,
12
+ } from "../config.mjs";
13
+ import { post } from "../http.mjs";
14
+
15
+ export const name = "openclaw";
16
+
17
+ const LEGACY_STATE_DIRS = [".clawdbot", ".moldbot", ".moltbot"];
18
+
19
+ export function detect() {
20
+ const home = homedir();
21
+ const dirs = [".openclaw", ...LEGACY_STATE_DIRS];
22
+ return dirs.some((d) => existsSync(join(home, d)));
23
+ }
24
+
25
+ export function skillsDir() {
26
+ return join(resolveStateDir(), "skills");
27
+ }
28
+
29
+ export async function invoke(message, options = {}) {
30
+ const { timeout = 120000, configPath: configOverride, idempotencyKey } = options;
31
+ const configPath = resolveConfigPath(configOverride);
32
+ const config = readConfig(configPath);
33
+ if (!config) {
34
+ console.error("error: config not found at " + configPath);
35
+ console.error(" operator-cli setup");
36
+ return { ok: false, error: "config_not_found" };
37
+ }
38
+
39
+ const token = resolveHooksToken(config);
40
+ if (!token) {
41
+ console.error("error: hooks.token not set in " + configPath);
42
+ console.error(" operator-cli setup");
43
+ return { ok: false, error: "no_hooks_token" };
44
+ }
45
+
46
+ try {
47
+ const port = resolveGatewayPort(config);
48
+ const key = idempotencyKey || createHash("sha256").update(message).digest("hex").slice(0, 32);
49
+ const res = await post(
50
+ `http://localhost:${port}/hooks/operator`,
51
+ { message },
52
+ token,
53
+ timeout,
54
+ { "Idempotency-Key": key },
55
+ );
56
+
57
+ if (res.status >= 200 && res.status < 300) {
58
+ let runId;
59
+ try {
60
+ const json = JSON.parse(res.body);
61
+ runId = json.runId;
62
+ } catch {}
63
+ if (runId) {
64
+ console.log("run_id: " + runId);
65
+ console.log("status: dispatched");
66
+ }
67
+ return { ok: true, runId };
68
+ }
69
+
70
+ console.error("error: gateway returned " + res.status);
71
+ console.error(res.body.slice(0, 500));
72
+ return { ok: false, error: "gateway_" + res.status };
73
+ } catch (err) {
74
+ console.error("error: " + err.message);
75
+ if (err.message.includes("ECONNREFUSED")) {
76
+ console.error(" Is the OpenClaw gateway running on port 18789?");
77
+ }
78
+ return { ok: false, error: err.message };
79
+ }
80
+ }
81
+
82
+ export async function think(prompt, options = {}) {
83
+ const { timeout = 120000, configPath: configOverride } = options;
84
+ const configPath = resolveConfigPath(configOverride);
85
+ const config = readConfig(configPath);
86
+ if (!config) {
87
+ console.error("error: config not found at " + configPath);
88
+ console.error(" operator-cli setup");
89
+ return { ok: false, error: "config_not_found" };
90
+ }
91
+
92
+ const token = resolveGatewayToken(config);
93
+ if (!token) {
94
+ console.error("error: gateway auth token not found");
95
+ console.error(" Set gateway.auth.token in " + configPath);
96
+ console.error(" or export OPENCLAW_GATEWAY_TOKEN=<token>");
97
+ return { ok: false, error: "no_gateway_token" };
98
+ }
99
+
100
+ let args;
101
+ try {
102
+ const parsed = JSON.parse(prompt);
103
+ args = parsed.prompt ? parsed : { prompt };
104
+ } catch {
105
+ args = { prompt };
106
+ }
107
+
108
+ try {
109
+ const port = resolveGatewayPort(config);
110
+ const res = await post(
111
+ `http://localhost:${port}/tools/invoke`,
112
+ { tool: "llm-task", action: "json", args },
113
+ token,
114
+ timeout,
115
+ );
116
+
117
+ if (res.status >= 200 && res.status < 300) {
118
+ try {
119
+ const json = JSON.parse(res.body);
120
+ if (json.ok) {
121
+ const output = json.result?.json ?? json.result?.details ?? json.result;
122
+ if (output !== undefined && output !== null) {
123
+ const text = typeof output === "string" ? output : JSON.stringify(output);
124
+ process.stdout.write(text);
125
+ }
126
+ return { ok: true, output };
127
+ }
128
+ const msg = json.error?.message || res.body.slice(0, 500);
129
+ console.error("error: " + msg);
130
+ return { ok: false, error: msg };
131
+ } catch {
132
+ console.error("error: invalid response from gateway");
133
+ console.error(res.body.slice(0, 500));
134
+ return { ok: false, error: "invalid_response" };
135
+ }
136
+ }
137
+
138
+ console.error("error: gateway returned " + res.status);
139
+ console.error(res.body.slice(0, 500));
140
+ if (res.status === 404) {
141
+ console.error(" Is the llm-task plugin enabled? Run: operator-cli setup");
142
+ }
143
+ return { ok: false, error: "gateway_" + res.status };
144
+ } catch (err) {
145
+ console.error("error: " + err.message);
146
+ if (err.message.includes("ECONNREFUSED")) {
147
+ console.error(" Is the OpenClaw gateway running on port 18789?");
148
+ }
149
+ return { ok: false, error: err.message };
150
+ }
151
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,155 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+
5
+ const LEGACY_STATE_DIRS = [".clawdbot", ".moldbot", ".moltbot"];
6
+ const CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"];
7
+
8
+ /**
9
+ * Resolve the OpenClaw state directory.
10
+ * Mirrors src/config/paths.ts resolveStateDir.
11
+ */
12
+ export function resolveStateDir() {
13
+ const override =
14
+ process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
15
+ if (override) return override;
16
+
17
+ const home = homedir();
18
+ const primary = join(home, ".openclaw");
19
+ if (existsSync(primary)) return primary;
20
+
21
+ for (const legacy of LEGACY_STATE_DIRS) {
22
+ const dir = join(home, legacy);
23
+ if (existsSync(dir)) return dir;
24
+ }
25
+ return primary;
26
+ }
27
+
28
+ /**
29
+ * Resolve the openclaw config file path.
30
+ * Mirrors src/config/paths.ts resolveDefaultConfigCandidates + first-existing pick.
31
+ *
32
+ * Precedence:
33
+ * 1. OPENCLAW_CONFIG_PATH or CLAWDBOT_CONFIG_PATH env var
34
+ * 2. First existing file scanning state dir + legacy dirs/filenames
35
+ * 3. ~/.openclaw/openclaw.json (default even if missing)
36
+ */
37
+ export function resolveConfigPath(override) {
38
+ if (override) return override;
39
+
40
+ const envPath =
41
+ process.env.OPENCLAW_CONFIG_PATH?.trim() || process.env.CLAWDBOT_CONFIG_PATH?.trim();
42
+ if (envPath) return envPath;
43
+
44
+ const home = homedir();
45
+ const stateDir = resolveStateDir();
46
+
47
+ // Scan state dir with all config filenames
48
+ for (const name of CONFIG_FILENAMES) {
49
+ const candidate = join(stateDir, name);
50
+ if (existsSync(candidate)) return candidate;
51
+ }
52
+
53
+ // Scan default + legacy state dirs
54
+ const dirs = [join(home, ".openclaw"), ...LEGACY_STATE_DIRS.map((d) => join(home, d))];
55
+ for (const dir of dirs) {
56
+ for (const name of CONFIG_FILENAMES) {
57
+ const candidate = join(dir, name);
58
+ if (existsSync(candidate)) return candidate;
59
+ }
60
+ }
61
+
62
+ return join(home, ".openclaw", "openclaw.json");
63
+ }
64
+
65
+ /**
66
+ * Read and parse the config file. Returns null on missing or parse error.
67
+ * OpenClaw uses JSON5 (supports comments, trailing commas). We attempt
68
+ * JSON.parse first, then strip // and /* comments as a fallback.
69
+ */
70
+ export function readConfig(configPath) {
71
+ if (!existsSync(configPath)) return null;
72
+ const raw = readFileSync(configPath, "utf-8");
73
+
74
+ // Try strict JSON first (most common)
75
+ try {
76
+ return JSON.parse(raw);
77
+ } catch {}
78
+
79
+ // Fallback: strip comments and trailing commas, then retry.
80
+ // Only strips // comments at the start of a line (with optional whitespace)
81
+ // to avoid mangling URLs like "https://example.com" inside string values.
82
+ try {
83
+ const stripped = raw
84
+ .replace(/^\s*\/\/[^\n]*/gm, "")
85
+ .replace(/\/\*[\s\S]*?\*\//g, "")
86
+ .replace(/,\s*([}\]])/g, "$1");
87
+ return JSON.parse(stripped);
88
+ } catch {}
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Find the hooks token. Always a string in config at hooks.token.
95
+ * No dedicated env var exists for this.
96
+ */
97
+ export function resolveHooksToken(config) {
98
+ const token = config?.hooks?.token;
99
+ if (typeof token === "string" && token.trim()) return token.trim();
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Find the gateway auth token. Checks config then env vars.
105
+ * Mirrors src/gateway/auth.ts + src/gateway/credential-planner.ts.
106
+ *
107
+ * Precedence (config-first):
108
+ * 1. gateway.auth.token in config (plain string)
109
+ * 2. OPENCLAW_GATEWAY_TOKEN env var
110
+ * 3. CLAWDBOT_GATEWAY_TOKEN env var (legacy)
111
+ *
112
+ * Note: gateway.token (top-level, no .auth) is a common miskey and is ignored
113
+ * by OpenClaw. We check it and warn.
114
+ */
115
+ export function resolveGatewayToken(config) {
116
+ // Config value (plain string only; secret refs need gateway-side resolution)
117
+ const fromConfig = config?.gateway?.auth?.token;
118
+ if (typeof fromConfig === "string" && fromConfig.trim()) {
119
+ return fromConfig.trim();
120
+ }
121
+
122
+ // Env vars
123
+ const fromEnv =
124
+ process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
125
+ process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
126
+ if (fromEnv) return fromEnv;
127
+
128
+ return null;
129
+ }
130
+
131
+ /**
132
+ * Resolve the gateway port.
133
+ * Mirrors src/config/paths.ts resolveGatewayPort.
134
+ */
135
+ export function resolveGatewayPort(config) {
136
+ const fromEnv =
137
+ process.env.OPENCLAW_GATEWAY_PORT?.trim() || process.env.CLAWDBOT_GATEWAY_PORT?.trim();
138
+ if (fromEnv && !isNaN(Number(fromEnv))) return Number(fromEnv);
139
+ const fromConfig = config?.gateway?.port;
140
+ if (typeof fromConfig === "number" && fromConfig > 0) return fromConfig;
141
+ return 18789;
142
+ }
143
+
144
+ /**
145
+ * Check for common config miskeys and return warnings.
146
+ */
147
+ export function checkConfigWarnings(config) {
148
+ const warnings = [];
149
+ if (config?.gateway?.token && !config?.gateway?.auth?.token) {
150
+ warnings.push(
151
+ "gateway.token is set but ignored by OpenClaw. Did you mean gateway.auth.token?",
152
+ );
153
+ }
154
+ return warnings;
155
+ }
package/lib/http.mjs ADDED
@@ -0,0 +1,31 @@
1
+ import { request } from "http";
2
+
3
+ /**
4
+ * POST JSON to a local gateway endpoint. Returns { status, body }.
5
+ */
6
+ export function post(url, data, token, timeoutMs = 120000, extraHeaders = {}) {
7
+ const payload = JSON.stringify(data);
8
+ return new Promise((resolve, reject) => {
9
+ const req = request(url, {
10
+ method: "POST",
11
+ headers: {
12
+ Authorization: "Bearer " + token,
13
+ "Content-Type": "application/json",
14
+ "Content-Length": Buffer.byteLength(payload),
15
+ ...extraHeaders,
16
+ },
17
+ timeout: timeoutMs,
18
+ }, (res) => {
19
+ let body = "";
20
+ res.on("data", (chunk) => body += chunk);
21
+ res.on("end", () => resolve({ status: res.statusCode, body }));
22
+ });
23
+ req.on("error", reject);
24
+ req.on("timeout", () => {
25
+ req.destroy();
26
+ reject(new Error("request timed out after " + timeoutMs + "ms"));
27
+ });
28
+ req.write(payload);
29
+ req.end();
30
+ });
31
+ }
package/lib/invoke.mjs ADDED
@@ -0,0 +1,24 @@
1
+ import { resolveBackend } from "./backend.mjs";
2
+ import { applySafety } from "./safety.mjs";
3
+
4
+ /**
5
+ * Spawn a full agent session via the active backend.
6
+ *
7
+ * Returns immediately after dispatch. The agent runs in the background --
8
+ * poll for your expected output to know when it's done.
9
+ *
10
+ * @param {string} message - The task prompt
11
+ * @param {object} [options]
12
+ * @param {boolean} [options.safety=true] - Prepend safety rules.
13
+ * Pass { safety: false } if the user explicitly requests unrestricted access.
14
+ * @param {number} [options.timeout=120000] - Request timeout in ms (not agent timeout)
15
+ * @param {string} [options.configPath] - Override config path (OpenClaw only)
16
+ * @param {string} [options.idempotencyKey] - Dedup key (OpenClaw only)
17
+ * @returns {{ ok: boolean, runId?: string, error?: string }}
18
+ */
19
+ export async function invoke(message, options = {}) {
20
+ const { safety = true, ...backendOpts } = options;
21
+ const backend = await resolveBackend();
22
+ const body = safety ? applySafety(message, backend.name) : message;
23
+ return backend.invoke(body, backendOpts);
24
+ }
package/lib/safety.mjs ADDED
@@ -0,0 +1,36 @@
1
+ import { join } from "path";
2
+ import { resolveStateDir } from "./config.mjs";
3
+
4
+ function getDenyPaths(backendName) {
5
+ if (backendName === "openclaw") {
6
+ const stateDir = resolveStateDir();
7
+ return [
8
+ join(stateDir, "skills"),
9
+ join(stateDir, "hooks"),
10
+ "openclaw.json",
11
+ ];
12
+ }
13
+ return [];
14
+ }
15
+
16
+ function buildSafetyPrefix(denyPaths) {
17
+ return `OPERATOR SAFETY RULES:
18
+ You MUST NOT write to the following paths or any files within them:
19
+ ${denyPaths.map((p) => "- " + p).join("\n")}
20
+
21
+ Do not create new skills, modify hook transforms, or change agent
22
+ configuration. If your task requires writing to these paths, report
23
+ that the operation is not permitted and continue with other work.
24
+
25
+ ---
26
+
27
+ `;
28
+ }
29
+
30
+ export function applySafety(message, backendName) {
31
+ const paths = getDenyPaths(backendName);
32
+ if (paths.length === 0) return message;
33
+ return buildSafetyPrefix(paths) + message;
34
+ }
35
+
36
+ export { getDenyPaths };