@txzy/automatic-bridge 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.
- package/README.md +57 -0
- package/bin/automatic-bridge.js +7 -0
- package/package.json +29 -0
- package/src/client.mjs +50 -0
- package/src/config.mjs +134 -0
- package/src/detect.mjs +69 -0
- package/src/main.mjs +198 -0
- package/src/runner.mjs +217 -0
- package/src/utils.mjs +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# automatic-bridge
|
|
2
|
+
|
|
3
|
+
A small npm bridge that connects Automatic channels to Hermes or Codex CLI.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `automatic-bridge doctor`
|
|
8
|
+
- `automatic-bridge init`
|
|
9
|
+
- `automatic-bridge once`
|
|
10
|
+
- `automatic-bridge smoke`
|
|
11
|
+
- `automatic-bridge run`
|
|
12
|
+
|
|
13
|
+
## Install locally
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd bridge
|
|
17
|
+
npm install
|
|
18
|
+
npm link
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install from npm or git
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @txzy/automatic-bridge doctor
|
|
25
|
+
npx @txzy/automatic-bridge init
|
|
26
|
+
npx @txzy/automatic-bridge run
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
You can set config via env, config file, or CLI options.
|
|
32
|
+
|
|
33
|
+
Required at minimum:
|
|
34
|
+
|
|
35
|
+
- `AUTOMATIC_BASE_URL`
|
|
36
|
+
- `AUTOMATIC_CHANNEL_IDS`
|
|
37
|
+
|
|
38
|
+
Optional:
|
|
39
|
+
|
|
40
|
+
- `BRIDGE_AGENT_KIND=hermes|codex`
|
|
41
|
+
- `HERMES_CLI_BIN`
|
|
42
|
+
- `CODEX_CLI_BIN`
|
|
43
|
+
- `HERMES_CLI_ARGS`
|
|
44
|
+
- `CODEX_CLI_ARGS`
|
|
45
|
+
- `AGENT_API_KEY`
|
|
46
|
+
- `BRIDGE_POLL_INTERVAL_MS`
|
|
47
|
+
- `BRIDGE_HISTORY_LIMIT`
|
|
48
|
+
- `BRIDGE_REPLY_LIMIT`
|
|
49
|
+
- `BRIDGE_AGENT_TIMEOUT_MS`
|
|
50
|
+
|
|
51
|
+
## Smoke test
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npx automatic-bridge smoke
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This sends a probe message to the first configured channel and waits for the bridge to process it.
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@txzy/automatic-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bridge Automatic channels to Hermes or Codex CLI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"automatic-bridge": "./bin/automatic-bridge.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"doctor": "node ./bin/automatic-bridge.js doctor",
|
|
23
|
+
"init": "node ./bin/automatic-bridge.js init",
|
|
24
|
+
"once": "node ./bin/automatic-bridge.js once",
|
|
25
|
+
"smoke": "node ./bin/automatic-bridge.js smoke",
|
|
26
|
+
"run": "node ./bin/automatic-bridge.js run",
|
|
27
|
+
"pack": "npm pack"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const JSON_HEADERS = { "Content-Type": "application/json" };
|
|
2
|
+
|
|
3
|
+
export function authHeaders(apiKey) {
|
|
4
|
+
if (!apiKey) return JSON_HEADERS;
|
|
5
|
+
return { ...JSON_HEADERS, Authorization: `Bearer ${apiKey}` };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function fetchJson(url, options = {}) {
|
|
9
|
+
const response = await fetch(url, options);
|
|
10
|
+
const text = await response.text();
|
|
11
|
+
let parsed = null;
|
|
12
|
+
if (text) {
|
|
13
|
+
try {
|
|
14
|
+
parsed = JSON.parse(text);
|
|
15
|
+
} catch {
|
|
16
|
+
parsed = text;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const message = typeof parsed === "object" && parsed && "error" in parsed ? parsed.error : text || response.statusText;
|
|
21
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}: ${message}`);
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getAuthStatus(baseUrl, channelId) {
|
|
27
|
+
return fetchJson(`${baseUrl}/api/channels/${channelId}/auth/status`, { cache: "no-store" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getMessages(baseUrl, channelId, sinceId = 0, role = "all") {
|
|
31
|
+
return fetchJson(`${baseUrl}/api/channels/${channelId}/messages?role=${encodeURIComponent(role)}&since_id=${encodeURIComponent(sinceId)}`, {
|
|
32
|
+
cache: "no-store",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function postUserMessage(baseUrl, channelId, content) {
|
|
37
|
+
return fetchJson(`${baseUrl}/api/channels/${channelId}/user-message`, {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: JSON_HEADERS,
|
|
40
|
+
body: JSON.stringify({ content }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function postAgentMessage(baseUrl, channelId, content, apiKey = "") {
|
|
45
|
+
return fetchJson(`${baseUrl}/api/channels/${channelId}/agent-message`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: authHeaders(apiKey),
|
|
48
|
+
body: JSON.stringify({ content }),
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { defaultAgentArgs, detectAgentBinary, detectAgentKind } from "./detect.mjs";
|
|
4
|
+
import { defaultConfigFile, defaultStateFile, parseIntValue, parseList, pathExists, readJson, splitCommandLine, writeJson } from "./utils.mjs";
|
|
5
|
+
|
|
6
|
+
function resolveOption(options, ...names) {
|
|
7
|
+
for (const name of names) {
|
|
8
|
+
if (options[name] !== undefined && options[name] !== null && options[name] !== "") return options[name];
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeChannelIds(value) {
|
|
14
|
+
if (Array.isArray(value)) return value.map(String).map((s) => s.trim()).filter(Boolean);
|
|
15
|
+
return parseList(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeConfig(raw) {
|
|
19
|
+
const agentKind = String(raw.agentKind || raw.BRIDGE_AGENT_KIND || detectAgentKind()).toLowerCase();
|
|
20
|
+
const agentBin = raw.agentBin || raw.HERMES_CLI_BIN || raw.CODEX_CLI_BIN || detectAgentBinary(agentKind);
|
|
21
|
+
const agentArgs = Array.isArray(raw.agentArgs)
|
|
22
|
+
? raw.agentArgs
|
|
23
|
+
: splitCommandLine(raw.agentArgs || raw.HERMES_CLI_ARGS || raw.CODEX_CLI_ARGS || defaultAgentArgs(agentKind).join(","));
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
baseUrl: String(raw.baseUrl || raw.AUTOMATIC_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""),
|
|
27
|
+
channelIds: normalizeChannelIds(raw.channelIds || raw.AUTOMATIC_CHANNEL_IDS || raw.AUTOMATIC_CHANNEL_ID),
|
|
28
|
+
agentKind,
|
|
29
|
+
agentBin,
|
|
30
|
+
agentArgs,
|
|
31
|
+
pollIntervalMs: parseIntValue(raw.pollIntervalMs ?? raw.BRIDGE_POLL_INTERVAL_MS, 1500),
|
|
32
|
+
historyLimit: parseIntValue(raw.historyLimit ?? raw.BRIDGE_HISTORY_LIMIT, 12),
|
|
33
|
+
replyLimit: parseIntValue(raw.replyLimit ?? raw.BRIDGE_REPLY_LIMIT, 1900),
|
|
34
|
+
agentTimeoutMs: parseIntValue(raw.agentTimeoutMs ?? raw.BRIDGE_AGENT_TIMEOUT_MS, 120_000),
|
|
35
|
+
apiKey: String(raw.apiKey ?? raw.AGENT_API_KEY ?? ""),
|
|
36
|
+
promptTemplate: String(raw.promptTemplate || raw.HERMES_PROMPT_TEMPLATE || "You are Hermes connected to Automatic channel {channelId}. Reply as the agent in a lightweight channel.\n\nRecent conversation:\n{history}\n\nLatest user message:\n{message}\n\nReply concisely and naturally as the agent."),
|
|
37
|
+
configPath: raw.configPath || raw.AUTOMATIC_BRIDGE_CONFIG || defaultConfigFile(),
|
|
38
|
+
statePath: raw.statePath || raw.BRIDGE_STATE_FILE || defaultStateFile(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readCliOptions(argv) {
|
|
43
|
+
const options = {};
|
|
44
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (!arg.startsWith("--")) continue;
|
|
47
|
+
const [flag, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
48
|
+
const key = flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
49
|
+
if (inline !== undefined) {
|
|
50
|
+
options[key] = inline;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const next = argv[i + 1];
|
|
54
|
+
if (next && !next.startsWith("--")) {
|
|
55
|
+
options[key] = next;
|
|
56
|
+
i += 1;
|
|
57
|
+
} else {
|
|
58
|
+
options[key] = true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return options;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function loadConfig(argvOptions = {}, extra = {}) {
|
|
65
|
+
const cliOptions = { ...readCliOptions(extra.argv || []), ...argvOptions };
|
|
66
|
+
const configPath = path.resolve(String(resolveOption(cliOptions, "configPath", "config") || defaultConfigFile()));
|
|
67
|
+
let fileConfig = {};
|
|
68
|
+
if (await pathExists(configPath)) {
|
|
69
|
+
try {
|
|
70
|
+
fileConfig = await readJson(configPath);
|
|
71
|
+
} catch {
|
|
72
|
+
fileConfig = {};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const env = process.env;
|
|
77
|
+
const combined = {
|
|
78
|
+
...fileConfig,
|
|
79
|
+
...env,
|
|
80
|
+
...cliOptions,
|
|
81
|
+
configPath,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const cfg = normalizeConfig(combined);
|
|
85
|
+
const cliChannelIds = resolveOption(cliOptions, "channelIds", "channelIdsCsv", "AUTOMATIC_CHANNEL_IDS");
|
|
86
|
+
cfg.channelIds = normalizeChannelIds(cliChannelIds || fileConfig.channelIds || env.AUTOMATIC_CHANNEL_IDS || env.AUTOMATIC_CHANNEL_ID);
|
|
87
|
+
const singleChannelId = resolveOption(cliOptions, "channelId");
|
|
88
|
+
if (singleChannelId) cfg.channelIds = [String(singleChannelId)];
|
|
89
|
+
if (cliChannelIds) cfg.channelIds = normalizeChannelIds(cliChannelIds);
|
|
90
|
+
const cliBaseUrl = resolveOption(cliOptions, "baseUrl");
|
|
91
|
+
if (cliBaseUrl) cfg.baseUrl = String(cliBaseUrl).replace(/\/$/, "");
|
|
92
|
+
const cliAgentKind = resolveOption(cliOptions, "agentKind");
|
|
93
|
+
if (cliAgentKind) cfg.agentKind = String(cliAgentKind).toLowerCase();
|
|
94
|
+
const cliAgentBin = resolveOption(cliOptions, "agentBin");
|
|
95
|
+
if (cliAgentBin) cfg.agentBin = String(cliAgentBin);
|
|
96
|
+
const cliAgentArgs = resolveOption(cliOptions, "agentArgs");
|
|
97
|
+
if (cliAgentArgs) cfg.agentArgs = splitCommandLine(cliAgentArgs);
|
|
98
|
+
const cliPollIntervalMs = resolveOption(cliOptions, "pollIntervalMs");
|
|
99
|
+
if (cliPollIntervalMs) cfg.pollIntervalMs = parseIntValue(cliPollIntervalMs, cfg.pollIntervalMs);
|
|
100
|
+
const cliHistoryLimit = resolveOption(cliOptions, "historyLimit");
|
|
101
|
+
if (cliHistoryLimit) cfg.historyLimit = parseIntValue(cliHistoryLimit, cfg.historyLimit);
|
|
102
|
+
const cliReplyLimit = resolveOption(cliOptions, "replyLimit");
|
|
103
|
+
if (cliReplyLimit) cfg.replyLimit = parseIntValue(cliReplyLimit, cfg.replyLimit);
|
|
104
|
+
const cliAgentTimeoutMs = resolveOption(cliOptions, "agentTimeoutMs");
|
|
105
|
+
if (cliAgentTimeoutMs) cfg.agentTimeoutMs = parseIntValue(cliAgentTimeoutMs, cfg.agentTimeoutMs);
|
|
106
|
+
const cliApiKey = resolveOption(cliOptions, "apiKey");
|
|
107
|
+
if (cliApiKey) cfg.apiKey = String(cliApiKey);
|
|
108
|
+
const cliPromptTemplate = resolveOption(cliOptions, "promptTemplate");
|
|
109
|
+
if (cliPromptTemplate) cfg.promptTemplate = String(cliPromptTemplate);
|
|
110
|
+
const cliStatePath = resolveOption(cliOptions, "statePath");
|
|
111
|
+
if (cliStatePath) cfg.statePath = String(cliStatePath);
|
|
112
|
+
cfg.configPath = configPath;
|
|
113
|
+
return cfg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function writeInitConfig(targetPath, config) {
|
|
117
|
+
const payload = {
|
|
118
|
+
baseUrl: config.baseUrl,
|
|
119
|
+
channelIds: config.channelIds,
|
|
120
|
+
agentKind: config.agentKind,
|
|
121
|
+
agentBin: config.agentBin,
|
|
122
|
+
agentArgs: config.agentArgs,
|
|
123
|
+
pollIntervalMs: config.pollIntervalMs,
|
|
124
|
+
historyLimit: config.historyLimit,
|
|
125
|
+
replyLimit: config.replyLimit,
|
|
126
|
+
agentTimeoutMs: config.agentTimeoutMs,
|
|
127
|
+
apiKey: config.apiKey,
|
|
128
|
+
promptTemplate: config.promptTemplate,
|
|
129
|
+
statePath: config.statePath,
|
|
130
|
+
};
|
|
131
|
+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
|
132
|
+
await writeJson(targetPath, payload);
|
|
133
|
+
return payload;
|
|
134
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const CANDIDATES = {
|
|
7
|
+
hermes: [
|
|
8
|
+
process.env.HERMES_CLI_BIN,
|
|
9
|
+
"hermes",
|
|
10
|
+
path.join(os.homedir(), ".hermes", "hermes-agent", "venv", "bin", "hermes"),
|
|
11
|
+
],
|
|
12
|
+
codex: [
|
|
13
|
+
process.env.CODEX_CLI_BIN,
|
|
14
|
+
"codex",
|
|
15
|
+
],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isExecutable(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function commandExists(command) {
|
|
28
|
+
const result = spawnSync(command, ["--help"], { stdio: "ignore", shell: false });
|
|
29
|
+
return !result.error && result.status !== 127;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function probeCandidate(candidate) {
|
|
33
|
+
if (!candidate) return null;
|
|
34
|
+
if (path.isAbsolute(candidate)) {
|
|
35
|
+
return isExecutable(candidate) ? candidate : null;
|
|
36
|
+
}
|
|
37
|
+
return commandExists(candidate) ? candidate : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function detectAgentKind(preferredKind) {
|
|
41
|
+
const kind = String(preferredKind || "").toLowerCase();
|
|
42
|
+
if (kind === "codex") return "codex";
|
|
43
|
+
if (kind === "hermes") return "hermes";
|
|
44
|
+
if (probeCandidate(process.env.CODEX_CLI_BIN) || commandExists("codex")) return "codex";
|
|
45
|
+
return "hermes";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function detectAgentBinary(kind) {
|
|
49
|
+
const lower = String(kind || "hermes").toLowerCase();
|
|
50
|
+
const candidates = CANDIDATES[lower] || CANDIDATES.hermes;
|
|
51
|
+
for (const candidate of candidates) {
|
|
52
|
+
const resolved = probeCandidate(candidate);
|
|
53
|
+
if (resolved) return resolved;
|
|
54
|
+
}
|
|
55
|
+
return lower === "codex" ? "codex" : "hermes";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function defaultAgentArgs(kind) {
|
|
59
|
+
if (String(kind).toLowerCase() === "codex") {
|
|
60
|
+
return ["exec", "--skip-git-repo-check", "--full-auto", "{prompt}"];
|
|
61
|
+
}
|
|
62
|
+
return ["chat", "-Q", "--source", "automatic-bridge", "-q", "{prompt}"];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function commandAvailable(binary) {
|
|
66
|
+
if (!binary) return false;
|
|
67
|
+
if (path.isAbsolute(binary)) return isExecutable(binary);
|
|
68
|
+
return commandExists(binary);
|
|
69
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { commandAvailable, detectAgentBinary, detectAgentKind } from "./detect.mjs";
|
|
3
|
+
import { getAuthStatus } from "./client.mjs";
|
|
4
|
+
import { loadConfig, writeInitConfig } from "./config.mjs";
|
|
5
|
+
import { bootstrapChannel, createState, runLoop, runSmoke, stepChannel } from "./runner.mjs";
|
|
6
|
+
import { defaultConfigFile, log, pathExists } from "./utils.mjs";
|
|
7
|
+
|
|
8
|
+
function printUsage() {
|
|
9
|
+
console.log(`automatic-bridge
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
automatic-bridge doctor [--config <path>]
|
|
13
|
+
automatic-bridge init [--config <path>] [--force]
|
|
14
|
+
automatic-bridge once [--config <path>]
|
|
15
|
+
automatic-bridge smoke [--config <path>] [--probe-text <text>]
|
|
16
|
+
automatic-bridge run [--config <path>]
|
|
17
|
+
|
|
18
|
+
Options are also read from env and config file.
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgv(argv) {
|
|
23
|
+
const args = [...argv];
|
|
24
|
+
const command = args.find((arg) => !arg.startsWith("--")) || "help";
|
|
25
|
+
const options = {};
|
|
26
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
27
|
+
const arg = args[i];
|
|
28
|
+
if (!arg.startsWith("--")) continue;
|
|
29
|
+
const [flag, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
30
|
+
const key = flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
31
|
+
if (inline !== undefined) {
|
|
32
|
+
options[key] = inline;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const next = args[i + 1];
|
|
36
|
+
if (next && !next.startsWith("--")) {
|
|
37
|
+
options[key] = next;
|
|
38
|
+
i += 1;
|
|
39
|
+
} else {
|
|
40
|
+
options[key] = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { command, options };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summarizeConfig(config) {
|
|
47
|
+
return {
|
|
48
|
+
configPath: config.configPath,
|
|
49
|
+
baseUrl: config.baseUrl,
|
|
50
|
+
channelIds: config.channelIds,
|
|
51
|
+
agentKind: config.agentKind,
|
|
52
|
+
agentBin: config.agentBin,
|
|
53
|
+
agentArgs: config.agentArgs,
|
|
54
|
+
pollIntervalMs: config.pollIntervalMs,
|
|
55
|
+
historyLimit: config.historyLimit,
|
|
56
|
+
replyLimit: config.replyLimit,
|
|
57
|
+
agentTimeoutMs: config.agentTimeoutMs,
|
|
58
|
+
statePath: config.statePath,
|
|
59
|
+
apiKeyPresent: Boolean(config.apiKey),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function commandDoctor(config) {
|
|
64
|
+
const report = {
|
|
65
|
+
...summarizeConfig(config),
|
|
66
|
+
cliDetected: {
|
|
67
|
+
kind: detectAgentKind(config.agentKind),
|
|
68
|
+
binary: detectAgentBinary(config.agentKind),
|
|
69
|
+
},
|
|
70
|
+
checks: [],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (!config.channelIds.length) {
|
|
74
|
+
report.checks.push({ ok: false, check: "channelIds", message: "No channel IDs configured" });
|
|
75
|
+
} else {
|
|
76
|
+
report.checks.push({ ok: true, check: "channelIds", message: config.channelIds.join(",") });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
report.checks.push({
|
|
80
|
+
ok: Boolean(config.baseUrl),
|
|
81
|
+
check: "baseUrl",
|
|
82
|
+
message: config.baseUrl,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
report.checks.push({
|
|
86
|
+
ok: Boolean(config.agentBin),
|
|
87
|
+
check: "agentBin",
|
|
88
|
+
message: config.agentBin,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
report.checks.push({
|
|
92
|
+
ok: commandAvailable(config.agentBin),
|
|
93
|
+
check: "agentBin:available",
|
|
94
|
+
message: commandAvailable(config.agentBin) ? "available" : "not found",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (config.channelIds[0]) {
|
|
98
|
+
try {
|
|
99
|
+
const auth = await getAuthStatus(config.baseUrl, config.channelIds[0]);
|
|
100
|
+
report.checks.push({ ok: true, check: "auth/status", message: auth });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
report.checks.push({ ok: false, check: "auth/status", message: error?.message || String(error) });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ok = report.checks.every((check) => check.ok);
|
|
107
|
+
console.log(JSON.stringify(report, null, 2));
|
|
108
|
+
process.exitCode = ok ? 0 : 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function commandInit(config, options) {
|
|
112
|
+
const targetPath = path.resolve(String(options.configPath || config.configPath || defaultConfigFile()));
|
|
113
|
+
const force = Boolean(options.force);
|
|
114
|
+
if (!force && await pathExists(targetPath)) {
|
|
115
|
+
console.log(`[automatic-bridge] config exists: ${targetPath} (use --force to overwrite)`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const payload = await writeInitConfig(targetPath, config);
|
|
119
|
+
console.log(JSON.stringify({ written: targetPath, config: payload }, null, 2));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadStates(config) {
|
|
123
|
+
return config.channelIds.map((channelId) => createState(channelId, config));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function bootstrapAll(states, config) {
|
|
127
|
+
for (const state of states) {
|
|
128
|
+
await bootstrapChannel(state, config.baseUrl);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function commandOnce(config) {
|
|
133
|
+
const states = await loadStates(config);
|
|
134
|
+
await bootstrapAll(states, config);
|
|
135
|
+
let processed = 0;
|
|
136
|
+
for (const state of states) {
|
|
137
|
+
const results = await stepChannel(state, config.baseUrl);
|
|
138
|
+
processed += results.filter(Boolean).length;
|
|
139
|
+
}
|
|
140
|
+
console.log(JSON.stringify({ ok: true, processed, channels: config.channelIds }, null, 2));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function commandSmoke(config, options) {
|
|
144
|
+
if (!config.channelIds.length) {
|
|
145
|
+
throw new Error("smoke requires at least one channel id");
|
|
146
|
+
}
|
|
147
|
+
const state = createState(config.channelIds[0], config);
|
|
148
|
+
await bootstrapChannel(state, config.baseUrl);
|
|
149
|
+
const probeText = options.probeText || `automatic-bridge-smoke-${Date.now()}`;
|
|
150
|
+
const result = await runSmoke(config.baseUrl, state, probeText, Number(options.timeoutMs || config.agentTimeoutMs || 60_000));
|
|
151
|
+
console.log(JSON.stringify({ ok: true, probeText, result: { created: Boolean(result.created), replyCount: result.result.length } }, null, 2));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function commandRun(config) {
|
|
155
|
+
if (!config.channelIds.length) {
|
|
156
|
+
throw new Error("No channel IDs configured");
|
|
157
|
+
}
|
|
158
|
+
const states = await loadStates(config);
|
|
159
|
+
await bootstrapAll(states, config);
|
|
160
|
+
const shutdown = () => {
|
|
161
|
+
log("shutdown requested");
|
|
162
|
+
for (const state of states) state.stopped = true;
|
|
163
|
+
};
|
|
164
|
+
process.once("SIGINT", shutdown);
|
|
165
|
+
process.once("SIGTERM", shutdown);
|
|
166
|
+
await Promise.all(states.map((state) => runLoop(state, config.baseUrl, config.pollIntervalMs)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
170
|
+
const { command, options } = parseArgv(argv);
|
|
171
|
+
if (command === "help" || options.help || options.h) {
|
|
172
|
+
printUsage();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const config = await loadConfig(options, { argv });
|
|
177
|
+
|
|
178
|
+
switch (command) {
|
|
179
|
+
case "doctor":
|
|
180
|
+
await commandDoctor(config);
|
|
181
|
+
return;
|
|
182
|
+
case "init":
|
|
183
|
+
await commandInit(config, options);
|
|
184
|
+
return;
|
|
185
|
+
case "once":
|
|
186
|
+
await commandOnce(config);
|
|
187
|
+
return;
|
|
188
|
+
case "smoke":
|
|
189
|
+
await commandSmoke(config, options);
|
|
190
|
+
return;
|
|
191
|
+
case "run":
|
|
192
|
+
await commandRun(config);
|
|
193
|
+
return;
|
|
194
|
+
default:
|
|
195
|
+
printUsage();
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { defaultAgentArgs, detectAgentBinary } from "./detect.mjs";
|
|
4
|
+
import { getMessages, postAgentMessage, postUserMessage } from "./client.mjs";
|
|
5
|
+
import { log, makePrompt, parseIntValue, splitCommandLine, truncate } from "./utils.mjs";
|
|
6
|
+
|
|
7
|
+
export function buildTranscript(messages, limit) {
|
|
8
|
+
const tail = messages.slice(Math.max(0, messages.length - limit));
|
|
9
|
+
return tail
|
|
10
|
+
.map((message) => {
|
|
11
|
+
const role = message.sender === "agent" ? "AGENT" : "USER";
|
|
12
|
+
return `${role}: ${message.content}`;
|
|
13
|
+
})
|
|
14
|
+
.join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createState(channelId, config) {
|
|
18
|
+
return {
|
|
19
|
+
channelId,
|
|
20
|
+
history: [],
|
|
21
|
+
seenIds: new Set(),
|
|
22
|
+
lastId: 0,
|
|
23
|
+
errorStreak: 0,
|
|
24
|
+
stopped: false,
|
|
25
|
+
historyLimit: config.historyLimit,
|
|
26
|
+
replyLimit: config.replyLimit,
|
|
27
|
+
apiKey: config.apiKey,
|
|
28
|
+
agentKind: config.agentKind,
|
|
29
|
+
agentBin: config.agentBin,
|
|
30
|
+
agentArgs: config.agentArgs,
|
|
31
|
+
promptTemplate: config.promptTemplate,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appendUniqueMessage(state, message) {
|
|
36
|
+
if (state.seenIds.has(message.id)) return false;
|
|
37
|
+
state.seenIds.add(message.id);
|
|
38
|
+
state.history.push(message);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveAgentCommand(state, prompt) {
|
|
43
|
+
const kind = String(state.agentKind || "hermes").toLowerCase();
|
|
44
|
+
const bin = state.agentBin || detectAgentBinary(kind);
|
|
45
|
+
const templateArgs = state.agentArgs?.length ? state.agentArgs : defaultAgentArgs(kind);
|
|
46
|
+
const args = [];
|
|
47
|
+
let insertedPrompt = false;
|
|
48
|
+
for (const part of templateArgs) {
|
|
49
|
+
if (part === "{prompt}") {
|
|
50
|
+
args.push(prompt);
|
|
51
|
+
insertedPrompt = true;
|
|
52
|
+
} else {
|
|
53
|
+
args.push(part);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!insertedPrompt) args.push(prompt);
|
|
57
|
+
return { kind, bin, args };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseAgentOutput(kind, output) {
|
|
61
|
+
const text = String(output ?? "").replace(/\r/g, "").trim();
|
|
62
|
+
if (!text) return "";
|
|
63
|
+
|
|
64
|
+
if (kind === "codex") {
|
|
65
|
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
66
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (!line.startsWith("tokens used") && !line.startsWith("warning:")) {
|
|
69
|
+
return line;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines[lines.length - 1] || "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const lines = text.split("\n");
|
|
76
|
+
const cleaned = [];
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed) {
|
|
80
|
+
if (cleaned.length > 0 && cleaned[cleaned.length - 1] !== "") {
|
|
81
|
+
cleaned.push("");
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (trimmed.startsWith("session_id:")) break;
|
|
86
|
+
if (trimmed.startsWith("╭") || trimmed.startsWith("╰") || trimmed.startsWith("│")) continue;
|
|
87
|
+
cleaned.push(line.trimEnd());
|
|
88
|
+
}
|
|
89
|
+
while (cleaned.length && cleaned[0] === "") cleaned.shift();
|
|
90
|
+
while (cleaned.length && cleaned[cleaned.length - 1] === "") cleaned.pop();
|
|
91
|
+
return cleaned.join("\n").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function runAgent(state, prompt) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const { kind, bin, args } = resolveAgentCommand(state, prompt);
|
|
97
|
+
const child = spawn(bin, args, {
|
|
98
|
+
env: process.env,
|
|
99
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
100
|
+
shell: false,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let stdout = "";
|
|
104
|
+
let stderr = "";
|
|
105
|
+
const timeoutMs = parseIntValue(process.env.BRIDGE_AGENT_TIMEOUT_MS, 120_000);
|
|
106
|
+
const timeout = setTimeout(() => {
|
|
107
|
+
child.kill("SIGTERM");
|
|
108
|
+
setTimeout(() => child.kill("SIGKILL"), 3000).unref?.();
|
|
109
|
+
reject(new Error(`${kind} timed out after ${timeoutMs}ms`));
|
|
110
|
+
}, timeoutMs);
|
|
111
|
+
|
|
112
|
+
child.stdout.on("data", (chunk) => {
|
|
113
|
+
stdout += chunk.toString("utf8");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.stderr.on("data", (chunk) => {
|
|
117
|
+
stderr += chunk.toString("utf8");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
child.on("error", (error) => {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
reject(error);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
child.on("close", (code) => {
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
if (code !== 0) {
|
|
128
|
+
reject(new Error(`${kind} exited with code ${code}: ${stderr.trim() || stdout.trim()}`));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const reply = parseAgentOutput(kind, stdout || stderr);
|
|
132
|
+
resolve(reply);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function bootstrapChannel(state, baseUrl) {
|
|
138
|
+
const data = await getMessages(baseUrl, state.channelId, 0, "all");
|
|
139
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
140
|
+
state.history = [];
|
|
141
|
+
state.seenIds = new Set();
|
|
142
|
+
for (const message of messages) appendUniqueMessage(state, message);
|
|
143
|
+
state.lastId = messages.length ? messages[messages.length - 1].id : 0;
|
|
144
|
+
log(`channel=${state.channelId}`, `bootstrapped last_id=${state.lastId} history=${state.history.length}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function processUserMessage(state, baseUrl, message) {
|
|
148
|
+
const transcript = buildTranscript(state.history, state.historyLimit);
|
|
149
|
+
const prompt = makePrompt(state.promptTemplate, {
|
|
150
|
+
channelId: state.channelId,
|
|
151
|
+
message: message.content,
|
|
152
|
+
history: transcript || "(no prior conversation)",
|
|
153
|
+
prompt: "",
|
|
154
|
+
});
|
|
155
|
+
log(`channel=${state.channelId}`, `asking agent for user message #${message.id}`);
|
|
156
|
+
const replyRaw = await runAgent(state, prompt);
|
|
157
|
+
const reply = truncate(replyRaw.trim(), state.replyLimit);
|
|
158
|
+
if (!reply) {
|
|
159
|
+
log(`channel=${state.channelId}`, `agent returned empty reply for message #${message.id}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const result = await postAgentMessage(baseUrl, state.channelId, reply, state.apiKey);
|
|
163
|
+
const posted = result?.message;
|
|
164
|
+
if (posted) appendUniqueMessage(state, posted);
|
|
165
|
+
log(`channel=${state.channelId}`, `posted agent reply${posted?.id ? ` #${posted.id}` : ""}`);
|
|
166
|
+
return posted ?? result ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function stepChannel(state, baseUrl) {
|
|
170
|
+
const data = await getMessages(baseUrl, state.channelId, state.lastId, "all");
|
|
171
|
+
const messages = Array.isArray(data?.messages) ? data.messages : [];
|
|
172
|
+
if (!messages.length) return [];
|
|
173
|
+
|
|
174
|
+
const userMessages = [];
|
|
175
|
+
for (const message of messages) {
|
|
176
|
+
appendUniqueMessage(state, message);
|
|
177
|
+
if (message.id > state.lastId) state.lastId = message.id;
|
|
178
|
+
if (message.sender === "user") userMessages.push(message);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const results = [];
|
|
182
|
+
for (const userMessage of userMessages) {
|
|
183
|
+
results.push(await processUserMessage(state, baseUrl, userMessage));
|
|
184
|
+
}
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function runLoop(state, baseUrl, pollIntervalMs) {
|
|
189
|
+
while (!state.stopped) {
|
|
190
|
+
try {
|
|
191
|
+
await stepChannel(state, baseUrl);
|
|
192
|
+
state.errorStreak = 0;
|
|
193
|
+
await sleep(pollIntervalMs);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
state.errorStreak += 1;
|
|
196
|
+
const backoff = Math.min(15_000, pollIntervalMs * Math.min(8, 2 ** state.errorStreak));
|
|
197
|
+
log(`channel=${state.channelId}`, `poll failed`, error?.message || error, `backoff=${backoff}ms`);
|
|
198
|
+
await sleep(backoff);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function runSmoke(baseUrl, state, probeText, timeoutMs = 60_000) {
|
|
204
|
+
const probe = probeText || `automatic-bridge-smoke-${Date.now()}`;
|
|
205
|
+
const startAt = Date.now();
|
|
206
|
+
const created = await postUserMessage(baseUrl, state.channelId, probe);
|
|
207
|
+
const createdId = created?.message?.id ?? created?.id ?? null;
|
|
208
|
+
log(`channel=${state.channelId}`, `probe sent${createdId ? ` #${createdId}` : ""}`, JSON.stringify(probe));
|
|
209
|
+
|
|
210
|
+
while (Date.now() - startAt < timeoutMs) {
|
|
211
|
+
const result = await stepChannel(state, baseUrl);
|
|
212
|
+
if (result.length > 0) return { probe, created, result };
|
|
213
|
+
await sleep(1000);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw new Error(`Smoke test timed out after ${timeoutMs}ms`);
|
|
217
|
+
}
|
package/src/utils.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const PREFIX = "[automatic-bridge]";
|
|
6
|
+
|
|
7
|
+
export function log(...args) {
|
|
8
|
+
console.log(PREFIX, new Date().toISOString(), ...args);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function warn(...args) {
|
|
12
|
+
console.warn(PREFIX, new Date().toISOString(), ...args);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseList(value) {
|
|
16
|
+
return String(value ?? "")
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((part) => part.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseIntValue(value, fallback) {
|
|
23
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
24
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
25
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function truncate(text, limit) {
|
|
29
|
+
const string = String(text ?? "");
|
|
30
|
+
if (string.length <= limit) return string;
|
|
31
|
+
return `${string.slice(0, Math.max(0, limit - 1))}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function expandHome(input) {
|
|
35
|
+
const value = String(input ?? "");
|
|
36
|
+
if (!value.startsWith("~")) return value;
|
|
37
|
+
return path.join(os.homedir(), value.slice(1));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function pathExists(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
await fs.access(filePath);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function readJson(filePath) {
|
|
50
|
+
const text = await fs.readFile(filePath, "utf8");
|
|
51
|
+
return JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeJson(filePath, value) {
|
|
55
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
56
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function homeConfigDir() {
|
|
60
|
+
return path.join(os.homedir(), ".config", "automatic-bridge");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function defaultStateFile() {
|
|
64
|
+
return path.join(homeConfigDir(), "state.json");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function defaultConfigFile() {
|
|
68
|
+
return path.join(homeConfigDir(), "config.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function splitCommandLine(value) {
|
|
72
|
+
return parseList(value).filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function replacePlaceholders(template, values) {
|
|
76
|
+
return String(template)
|
|
77
|
+
.replaceAll("{channelId}", values.channelId ?? "")
|
|
78
|
+
.replaceAll("{message}", values.message ?? "")
|
|
79
|
+
.replaceAll("{history}", values.history ?? "")
|
|
80
|
+
.replaceAll("{prompt}", values.prompt ?? "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makePrompt(template, values) {
|
|
84
|
+
return replacePlaceholders(template, values);
|
|
85
|
+
}
|