@febro28/aya-bridge 0.1.2
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/LICENSE +21 -0
- package/README.md +287 -0
- package/bin/aya.js +9 -0
- package/examples/aya-bridge.service +26 -0
- package/package.json +18 -0
- package/src/bridge.js +940 -0
- package/src/cli.js +235 -0
- package/test/bridge.test.js +126 -0
- package/test/run.js +544 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const { constants: fsConstants } = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const readline = require("node:readline/promises");
|
|
7
|
+
const { stdin, stdout } = require("node:process");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
BridgeDaemon,
|
|
11
|
+
createLogger,
|
|
12
|
+
defaultConfig,
|
|
13
|
+
expandHome,
|
|
14
|
+
pathExists,
|
|
15
|
+
readJSON,
|
|
16
|
+
resolvePaths
|
|
17
|
+
} = (() => {
|
|
18
|
+
const mod = require("./bridge");
|
|
19
|
+
return {
|
|
20
|
+
...mod,
|
|
21
|
+
pathExists: async (target) => {
|
|
22
|
+
try {
|
|
23
|
+
await fs.access(target);
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
pathWritable: async (target) => {
|
|
30
|
+
try {
|
|
31
|
+
await fs.access(target, fsConstants.W_OK);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const args = { _: [] };
|
|
42
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
43
|
+
const part = argv[i];
|
|
44
|
+
if (!part.startsWith("--")) {
|
|
45
|
+
args._.push(part);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const eqIdx = part.indexOf("=");
|
|
49
|
+
if (eqIdx > -1) {
|
|
50
|
+
args[part.slice(2, eqIdx)] = part.slice(eqIdx + 1);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const key = part.slice(2);
|
|
54
|
+
const next = argv[i + 1];
|
|
55
|
+
if (!next || next.startsWith("--")) {
|
|
56
|
+
args[key] = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
args[key] = next;
|
|
60
|
+
i += 1;
|
|
61
|
+
}
|
|
62
|
+
return args;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function promptInput(rl, label, defaultValue = "") {
|
|
66
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
67
|
+
const answer = await rl.question(`${label}${suffix}: `);
|
|
68
|
+
const trimmed = answer.trim();
|
|
69
|
+
return trimmed || defaultValue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function commandInit(args) {
|
|
73
|
+
const baseDir = expandHome(String(args["base-dir"] || "").trim() || undefined);
|
|
74
|
+
const config = defaultConfig(baseDir);
|
|
75
|
+
const paths = resolvePaths(config);
|
|
76
|
+
const exists = await pathExists(paths.configPath);
|
|
77
|
+
if (exists && !args.force) {
|
|
78
|
+
console.log(JSON.stringify({ ok: true, config_path: paths.configPath, reused: true }, null, 2));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rl = args["non-interactive"] ? null : readline.createInterface({ input: stdin, output: stdout });
|
|
83
|
+
try {
|
|
84
|
+
config.aya.api_base_url = normalizeArg(
|
|
85
|
+
args["aya-api-base-url"],
|
|
86
|
+
rl ? await promptInput(rl, "AYA API base URL", config.aya.api_base_url) : config.aya.api_base_url
|
|
87
|
+
);
|
|
88
|
+
config.openclaw.hook_url = normalizeArg(
|
|
89
|
+
args["openclaw-hook-url"],
|
|
90
|
+
rl ? await promptInput(rl, "OpenClaw hook URL", config.openclaw.hook_url) : config.openclaw.hook_url
|
|
91
|
+
);
|
|
92
|
+
config.openclaw.hook_token = normalizeArg(
|
|
93
|
+
args["openclaw-hook-token"],
|
|
94
|
+
rl ? await promptInput(rl, "OpenClaw hook token", config.openclaw.hook_token) : config.openclaw.hook_token
|
|
95
|
+
);
|
|
96
|
+
config.openclaw.agent_id = normalizeArg(
|
|
97
|
+
args["openclaw-agent-id"],
|
|
98
|
+
rl ? await promptInput(rl, "OpenClaw agent ID", config.openclaw.agent_id) : config.openclaw.agent_id
|
|
99
|
+
);
|
|
100
|
+
} finally {
|
|
101
|
+
await rl?.close();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const daemon = new BridgeDaemon({ config, logger: createLogger("info") });
|
|
105
|
+
await daemon.ensureLayout();
|
|
106
|
+
await daemon.saveConfig();
|
|
107
|
+
console.log(JSON.stringify({ ok: true, config_path: daemon.paths.configPath }, null, 2));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function commandLogin(args) {
|
|
111
|
+
const daemon = await BridgeDaemon.fromDisk({ logLevel: "info" });
|
|
112
|
+
let apiKey = String(args["api-key"] || "").trim();
|
|
113
|
+
if (args.stdin) {
|
|
114
|
+
apiKey = (await readStdin()).trim();
|
|
115
|
+
}
|
|
116
|
+
if (!apiKey) {
|
|
117
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
118
|
+
try {
|
|
119
|
+
apiKey = await promptInput(rl, "AYA API key", "");
|
|
120
|
+
} finally {
|
|
121
|
+
await rl.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!apiKey) {
|
|
125
|
+
throw Object.assign(new Error("AYA API key is required"), { exitCode: 3 });
|
|
126
|
+
}
|
|
127
|
+
daemon.session = {
|
|
128
|
+
...daemon.session,
|
|
129
|
+
api_key: apiKey
|
|
130
|
+
};
|
|
131
|
+
await daemon.relogin();
|
|
132
|
+
console.log(JSON.stringify({ ok: true, session_path: daemon.paths.sessionPath }, null, 2));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function commandServe(args) {
|
|
136
|
+
const logger = createLogger(String(args["log-level"] || "info"));
|
|
137
|
+
const daemon = await BridgeDaemon.fromDisk({ logLevel: String(args["log-level"] || "info"), logger });
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
process.on("SIGINT", () => controller.abort());
|
|
140
|
+
process.on("SIGTERM", () => controller.abort());
|
|
141
|
+
await daemon.serve(controller.signal);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function commandStatus() {
|
|
145
|
+
const daemon = await BridgeDaemon.fromDisk({ logLevel: "error" });
|
|
146
|
+
const tokenFiles = await fs.readdir(daemon.paths.tokenDir).catch(() => []);
|
|
147
|
+
const wakeFiles = await fs.readdir(daemon.paths.wakeQueueDir).catch(() => []);
|
|
148
|
+
const output = {
|
|
149
|
+
version: "0.1.0",
|
|
150
|
+
api_base_url: daemon.config.aya.api_base_url,
|
|
151
|
+
hook_url: daemon.config.openclaw.hook_url,
|
|
152
|
+
agent_id: daemon.session.agent_id || daemon.config.openclaw.agent_id || "",
|
|
153
|
+
has_session: Boolean(daemon.session.session_token),
|
|
154
|
+
last_acknowledged_delivery_id: daemon.state.last_acknowledged_delivery_id || "",
|
|
155
|
+
last_connected_at: daemon.state.last_connected_at || null,
|
|
156
|
+
last_stream_status: daemon.state.last_stream_status || "idle",
|
|
157
|
+
token_file_count: tokenFiles.filter((file) => file.endsWith(".json")).length,
|
|
158
|
+
wake_queue_count: wakeFiles.filter((file) => file.endsWith(".json")).length
|
|
159
|
+
};
|
|
160
|
+
console.log(JSON.stringify(output, null, 2));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function commandLogout() {
|
|
164
|
+
const config = await readJSON(path.join(expandHome("~/.areyouai"), "config.json"), null);
|
|
165
|
+
if (!config) {
|
|
166
|
+
throw Object.assign(new Error("bridge config not found; run init first"), { exitCode: 2 });
|
|
167
|
+
}
|
|
168
|
+
const daemon = await BridgeDaemon.fromDisk({ logLevel: "error" });
|
|
169
|
+
await fs.rm(daemon.paths.sessionPath, { force: true });
|
|
170
|
+
console.log(JSON.stringify({ ok: true, session_path: daemon.paths.sessionPath, removed: true }, null, 2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function commandDoctor() {
|
|
174
|
+
const daemon = await BridgeDaemon.fromDisk({ logLevel: "error" });
|
|
175
|
+
const checks = {
|
|
176
|
+
config_exists: await pathExists(daemon.paths.configPath),
|
|
177
|
+
session_exists: await pathExists(daemon.paths.sessionPath),
|
|
178
|
+
token_dir_exists: await pathExists(daemon.paths.tokenDir),
|
|
179
|
+
wake_queue_dir_exists: await pathExists(daemon.paths.wakeQueueDir),
|
|
180
|
+
token_dir_writable: await pathWritable(daemon.paths.tokenDir),
|
|
181
|
+
wake_queue_dir_writable: await pathWritable(daemon.paths.wakeQueueDir),
|
|
182
|
+
openclaw_hook_configured: Boolean(String(daemon.config.openclaw.hook_url || "").trim()) && Boolean(String(daemon.config.openclaw.hook_token || "").trim()),
|
|
183
|
+
api_health: false
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
const response = await daemon.fetch(new URL("/healthz", `${daemon.config.aya.api_base_url}/`));
|
|
187
|
+
checks.api_health = response.ok;
|
|
188
|
+
} catch {
|
|
189
|
+
checks.api_health = false;
|
|
190
|
+
}
|
|
191
|
+
console.log(JSON.stringify(checks, null, 2));
|
|
192
|
+
if (!checks.config_exists || !checks.api_health || !checks.openclaw_hook_configured || !checks.token_dir_writable || !checks.wake_queue_dir_writable) {
|
|
193
|
+
throw Object.assign(new Error("doctor failed"), { exitCode: 2 });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeArg(flagValue, promptedValue) {
|
|
198
|
+
const raw = String(flagValue || promptedValue || "").trim();
|
|
199
|
+
return raw.replace(/\/+$/, "");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readStdin() {
|
|
203
|
+
const chunks = [];
|
|
204
|
+
for await (const chunk of stdin) {
|
|
205
|
+
chunks.push(chunk);
|
|
206
|
+
}
|
|
207
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function main(argv) {
|
|
211
|
+
const args = parseArgs(argv);
|
|
212
|
+
const [command] = args._;
|
|
213
|
+
switch (command) {
|
|
214
|
+
case "init":
|
|
215
|
+
return commandInit(args);
|
|
216
|
+
case "login":
|
|
217
|
+
return commandLogin(args);
|
|
218
|
+
case "serve":
|
|
219
|
+
return commandServe(args);
|
|
220
|
+
case "status":
|
|
221
|
+
return commandStatus(args);
|
|
222
|
+
case "logout":
|
|
223
|
+
return commandLogout(args);
|
|
224
|
+
case "doctor":
|
|
225
|
+
return commandDoctor(args);
|
|
226
|
+
default:
|
|
227
|
+
console.log("usage: aya <init|login|serve|status|logout|doctor> [--flags]");
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
main,
|
|
234
|
+
parseArgs
|
|
235
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs/promises");
|
|
6
|
+
const http = require("node:http");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
BridgeDaemon,
|
|
12
|
+
createLogger,
|
|
13
|
+
defaultConfig,
|
|
14
|
+
parseSSE
|
|
15
|
+
} = require("../src/bridge");
|
|
16
|
+
|
|
17
|
+
async function withServer(handler, fn) {
|
|
18
|
+
const server = http.createServer(handler);
|
|
19
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
20
|
+
try {
|
|
21
|
+
const address = server.address();
|
|
22
|
+
return await fn(`http://${address.address}:${address.port}`);
|
|
23
|
+
} finally {
|
|
24
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("parseSSE yields event payloads", async () => {
|
|
29
|
+
async function* body() {
|
|
30
|
+
yield Buffer.from("event: stream.hello\n");
|
|
31
|
+
yield Buffer.from("data: {\"type\":\"stream.hello\",\"resume_status\":\"fresh\"}\n\n");
|
|
32
|
+
yield Buffer.from("id: dly_1\nevent: room.turn_ready\n");
|
|
33
|
+
yield Buffer.from("data: {\"delivery_id\":\"dly_1\",\"room_id\":\"room_1\"}\n\n");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const events = [];
|
|
37
|
+
for await (const event of parseSSE(body())) {
|
|
38
|
+
events.push(event);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
assert.equal(events.length, 2);
|
|
42
|
+
assert.equal(events[0].event, "stream.hello");
|
|
43
|
+
assert.equal(events[0].data.resume_status, "fresh");
|
|
44
|
+
assert.equal(events[1].id, "dly_1");
|
|
45
|
+
assert.equal(events[1].event, "room.turn_ready");
|
|
46
|
+
assert.equal(events[1].data.room_id, "room_1");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("handleTurnReady writes token, acks, then wakes", async () => {
|
|
50
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "aya-bridge-"));
|
|
51
|
+
const markers = [];
|
|
52
|
+
|
|
53
|
+
await withServer(async (req, res) => {
|
|
54
|
+
if (req.method === "POST" && req.url === "/v1/rooms/room_1/access-token") {
|
|
55
|
+
markers.push("token");
|
|
56
|
+
res.writeHead(201, { "Content-Type": "application/json" });
|
|
57
|
+
res.end(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
room_id: "room_1",
|
|
60
|
+
agent_id: "agt_test",
|
|
61
|
+
token: "rat_token_1",
|
|
62
|
+
scope: "room:automation",
|
|
63
|
+
expires_at: new Date(Date.now() + 300000).toISOString()
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (req.method === "POST" && req.url === "/v1/agent/stream/ack") {
|
|
69
|
+
markers.push("ack");
|
|
70
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
71
|
+
res.end(JSON.stringify({ status: "acked" }));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.writeHead(404).end();
|
|
75
|
+
}, async (ayaBaseURL) => {
|
|
76
|
+
await withServer(async (req, res) => {
|
|
77
|
+
if (req.method === "POST" && req.url === "/hooks/agent") {
|
|
78
|
+
markers.push("wake");
|
|
79
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
80
|
+
res.end(JSON.stringify({ ok: true }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.writeHead(404).end();
|
|
84
|
+
}, async (hookURL) => {
|
|
85
|
+
const config = defaultConfig(tmpDir);
|
|
86
|
+
config.aya.api_base_url = ayaBaseURL;
|
|
87
|
+
config.openclaw.hook_url = `${hookURL}/hooks/agent`;
|
|
88
|
+
config.openclaw.hook_token = "oc_hook_test";
|
|
89
|
+
config.openclaw.agent_id = "main";
|
|
90
|
+
const bridge = new BridgeDaemon({
|
|
91
|
+
config,
|
|
92
|
+
logger: createLogger("error"),
|
|
93
|
+
session: {
|
|
94
|
+
api_key: "aya_api_test",
|
|
95
|
+
session_token: "as_test",
|
|
96
|
+
agent_id: "agt_test"
|
|
97
|
+
},
|
|
98
|
+
state: {
|
|
99
|
+
last_acknowledged_delivery_id: "",
|
|
100
|
+
last_connected_at: null,
|
|
101
|
+
last_stream_status: "idle"
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
await bridge.ensureLayout();
|
|
105
|
+
|
|
106
|
+
await bridge.handleTurnReady({
|
|
107
|
+
type: "room.turn_ready",
|
|
108
|
+
delivery_id: "dly_1",
|
|
109
|
+
room_id: "room_1",
|
|
110
|
+
next_turn: 0,
|
|
111
|
+
next_actor_id: "agt_test"
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const tokenPath = path.join(bridge.paths.tokenDir, "room_1.json");
|
|
115
|
+
const token = JSON.parse(await fs.readFile(tokenPath, "utf8"));
|
|
116
|
+
assert.equal(token.token, "rat_token_1");
|
|
117
|
+
|
|
118
|
+
const wakeFiles = await fs.readdir(bridge.paths.wakeQueueDir);
|
|
119
|
+
assert.equal(wakeFiles.length, 0);
|
|
120
|
+
assert.deepEqual(markers, ["token", "ack", "wake"]);
|
|
121
|
+
|
|
122
|
+
const state = JSON.parse(await fs.readFile(bridge.paths.statePath, "utf8"));
|
|
123
|
+
assert.equal(state.last_acknowledged_delivery_id, "dly_1");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|