@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/bridge.js
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs/promises");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const { setTimeout: sleep } = require("node:timers/promises");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_BASE_DIR = path.join(os.homedir(), ".areyouai");
|
|
10
|
+
|
|
11
|
+
class HTTPError extends Error {
|
|
12
|
+
constructor(status, body, message) {
|
|
13
|
+
super(message || `HTTP ${status}`);
|
|
14
|
+
this.name = "HTTPError";
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.body = body;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultConfig(baseDir = DEFAULT_BASE_DIR) {
|
|
21
|
+
const dir = expandHome(baseDir);
|
|
22
|
+
return {
|
|
23
|
+
aya: {
|
|
24
|
+
api_base_url: "https://api.areyouai.fun",
|
|
25
|
+
token_refresh_threshold_seconds: 60,
|
|
26
|
+
reconnect: {
|
|
27
|
+
base_delay_ms: 1000,
|
|
28
|
+
max_delay_ms: 10000,
|
|
29
|
+
jitter_ms: 250
|
|
30
|
+
},
|
|
31
|
+
wake_retry_interval_ms: 5000
|
|
32
|
+
},
|
|
33
|
+
openclaw: {
|
|
34
|
+
hook_url: "http://127.0.0.1:18789/hooks/agent",
|
|
35
|
+
hook_token: "",
|
|
36
|
+
agent_id: "main"
|
|
37
|
+
},
|
|
38
|
+
storage: {
|
|
39
|
+
base_dir: dir,
|
|
40
|
+
token_dir: path.join(dir, "tokens"),
|
|
41
|
+
wake_queue_dir: path.join(dir, "wake-queue"),
|
|
42
|
+
log_dir: path.join(dir, "logs")
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function expandHome(inputPath) {
|
|
48
|
+
if (!inputPath) {
|
|
49
|
+
return inputPath;
|
|
50
|
+
}
|
|
51
|
+
if (inputPath === "~") {
|
|
52
|
+
return os.homedir();
|
|
53
|
+
}
|
|
54
|
+
if (inputPath.startsWith("~/")) {
|
|
55
|
+
return path.join(os.homedir(), inputPath.slice(2));
|
|
56
|
+
}
|
|
57
|
+
return inputPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolvePaths(config) {
|
|
61
|
+
const baseDir = expandHome(config.storage?.base_dir || DEFAULT_BASE_DIR);
|
|
62
|
+
return {
|
|
63
|
+
baseDir,
|
|
64
|
+
configPath: path.join(baseDir, "config.json"),
|
|
65
|
+
sessionPath: path.join(baseDir, "session.json"),
|
|
66
|
+
statePath: path.join(baseDir, "state.json"),
|
|
67
|
+
tokenDir: expandHome(config.storage?.token_dir || path.join(baseDir, "tokens")),
|
|
68
|
+
wakeQueueDir: expandHome(config.storage?.wake_queue_dir || path.join(baseDir, "wake-queue")),
|
|
69
|
+
logDir: expandHome(config.storage?.log_dir || path.join(baseDir, "logs"))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createLogger(level = "info") {
|
|
74
|
+
const weights = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
75
|
+
const threshold = weights[level] || weights.info;
|
|
76
|
+
const emit = (name, args) => {
|
|
77
|
+
if ((weights[name] || weights.info) < threshold) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const line = [`[${new Date().toISOString()}]`, name.toUpperCase(), ...args].join(" ");
|
|
81
|
+
if (name === "error") {
|
|
82
|
+
console.error(line);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log(line);
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
debug: (...args) => emit("debug", args),
|
|
89
|
+
info: (...args) => emit("info", args),
|
|
90
|
+
warn: (...args) => emit("warn", args),
|
|
91
|
+
error: (...args) => emit("error", args)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function pathExists(target) {
|
|
96
|
+
try {
|
|
97
|
+
await fs.access(target);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function ensureDir(target) {
|
|
105
|
+
await fs.mkdir(target, { recursive: true, mode: 0o700 });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function atomicWriteJSON(target, value) {
|
|
109
|
+
const dir = path.dirname(target);
|
|
110
|
+
await ensureDir(dir);
|
|
111
|
+
const tmp = path.join(
|
|
112
|
+
dir,
|
|
113
|
+
`.${path.basename(target)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`
|
|
114
|
+
);
|
|
115
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
116
|
+
const handle = await fs.open(tmp, "w", 0o600);
|
|
117
|
+
try {
|
|
118
|
+
await handle.writeFile(body);
|
|
119
|
+
await handle.sync();
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.close().catch(() => {});
|
|
122
|
+
}
|
|
123
|
+
await fs.rename(tmp, target);
|
|
124
|
+
try {
|
|
125
|
+
const dirHandle = await fs.open(dir, "r");
|
|
126
|
+
try {
|
|
127
|
+
await dirHandle.sync();
|
|
128
|
+
} finally {
|
|
129
|
+
await dirHandle.close().catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Best-effort directory sync.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readJSON(target, fallback = null) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = await fs.readFile(target, "utf8");
|
|
139
|
+
return JSON.parse(raw);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err && err.code === "ENOENT") {
|
|
142
|
+
return fallback;
|
|
143
|
+
}
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function removeIfExists(target) {
|
|
149
|
+
await fs.rm(target, { force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function listJSONFiles(dir) {
|
|
153
|
+
if (!(await pathExists(dir))) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
157
|
+
return entries
|
|
158
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
159
|
+
.map((entry) => path.join(dir, entry.name))
|
|
160
|
+
.sort();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeURL(raw) {
|
|
164
|
+
return String(raw || "").trim().replace(/\/+$/, "");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseJSONText(text) {
|
|
168
|
+
if (!text) {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(text);
|
|
173
|
+
} catch {
|
|
174
|
+
return { raw: text };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseISOTime(raw) {
|
|
179
|
+
const text = String(raw || "").trim();
|
|
180
|
+
if (!text) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const at = new Date(text);
|
|
184
|
+
if (Number.isNaN(at.getTime())) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
return at;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function computeBackoffMs(reconnect, attempt) {
|
|
191
|
+
const base = Number(reconnect?.base_delay_ms || 1000);
|
|
192
|
+
const max = Number(reconnect?.max_delay_ms || 10000);
|
|
193
|
+
const jitter = Number(reconnect?.jitter_ms || 0);
|
|
194
|
+
const growth = Math.min(max, base * Math.pow(2, Math.max(0, attempt)));
|
|
195
|
+
const jitterAdd = jitter > 0 ? Math.floor(Math.random() * (jitter + 1)) : 0;
|
|
196
|
+
return growth + jitterAdd;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function* parseSSE(body) {
|
|
200
|
+
const decoder = new TextDecoder();
|
|
201
|
+
let buffer = "";
|
|
202
|
+
const reader = typeof body?.getReader === "function" ? body.getReader() : null;
|
|
203
|
+
if (reader) {
|
|
204
|
+
while (true) {
|
|
205
|
+
const { done, value } = await reader.read();
|
|
206
|
+
if (done) {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
buffer += decoder.decode(value, { stream: true });
|
|
210
|
+
yield* drainSSEBuffer(() => buffer, (next) => {
|
|
211
|
+
buffer = next;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
buffer += decoder.decode();
|
|
215
|
+
yield* drainSSEBuffer(() => buffer, (next) => {
|
|
216
|
+
buffer = next;
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for await (const chunk of body) {
|
|
222
|
+
buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
223
|
+
yield* drainSSEBuffer(() => buffer, (next) => {
|
|
224
|
+
buffer = next;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
yield* drainSSEBuffer(() => buffer, (next) => {
|
|
228
|
+
buffer = next;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function* drainSSEBuffer(getBuffer, setBuffer) {
|
|
233
|
+
let buffer = getBuffer();
|
|
234
|
+
while (true) {
|
|
235
|
+
const idx = buffer.indexOf("\n\n");
|
|
236
|
+
if (idx === -1) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
const frame = buffer.slice(0, idx);
|
|
240
|
+
buffer = buffer.slice(idx + 2);
|
|
241
|
+
const parsed = parseSSEFrame(frame);
|
|
242
|
+
if (parsed) {
|
|
243
|
+
yield parsed;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
setBuffer(buffer);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseSSEFrame(frame) {
|
|
250
|
+
const lines = frame.split(/\r?\n/);
|
|
251
|
+
let id = "";
|
|
252
|
+
let event = "";
|
|
253
|
+
const data = [];
|
|
254
|
+
for (const rawLine of lines) {
|
|
255
|
+
if (!rawLine || rawLine.startsWith(":") || rawLine.startsWith("retry:")) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (rawLine.startsWith("id:")) {
|
|
259
|
+
id = rawLine.slice(3).trim();
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (rawLine.startsWith("event:")) {
|
|
263
|
+
event = rawLine.slice(6).trim();
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (rawLine.startsWith("data:")) {
|
|
267
|
+
data.push(rawLine.slice(5).trim());
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!event && data.length === 0) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
id,
|
|
275
|
+
event,
|
|
276
|
+
data: parseJSONText(data.join("\n"))
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
class BridgeDaemon {
|
|
281
|
+
constructor(options) {
|
|
282
|
+
this.fetch = options.fetchImpl || globalThis.fetch;
|
|
283
|
+
this.logger = options.logger || createLogger(options.logLevel || "info");
|
|
284
|
+
this.config = JSON.parse(JSON.stringify(options.config || defaultConfig(options.baseDir)));
|
|
285
|
+
this.paths = resolvePaths(this.config);
|
|
286
|
+
this.session = options.session || {};
|
|
287
|
+
this.state = options.state || {
|
|
288
|
+
last_acknowledged_delivery_id: "",
|
|
289
|
+
last_connected_at: null,
|
|
290
|
+
last_stream_status: "idle",
|
|
291
|
+
completed_wake_keys: []
|
|
292
|
+
};
|
|
293
|
+
if (!Array.isArray(this.state.completed_wake_keys)) {
|
|
294
|
+
this.state.completed_wake_keys = [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
static async fromDisk(options = {}) {
|
|
299
|
+
const config = await readJSON(options.configPath || path.join(expandHome(options.baseDir || DEFAULT_BASE_DIR), "config.json"));
|
|
300
|
+
if (!config) {
|
|
301
|
+
throw Object.assign(new Error("bridge config not found; run init first"), { exitCode: 2 });
|
|
302
|
+
}
|
|
303
|
+
const daemon = new BridgeDaemon({
|
|
304
|
+
...options,
|
|
305
|
+
config,
|
|
306
|
+
session: await readJSON(path.join(resolvePaths(config).baseDir, "session.json"), {}),
|
|
307
|
+
state: await readJSON(path.join(resolvePaths(config).baseDir, "state.json"), {
|
|
308
|
+
last_acknowledged_delivery_id: "",
|
|
309
|
+
last_connected_at: null,
|
|
310
|
+
last_stream_status: "idle",
|
|
311
|
+
completed_wake_keys: []
|
|
312
|
+
})
|
|
313
|
+
});
|
|
314
|
+
await daemon.ensureLayout();
|
|
315
|
+
return daemon;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async ensureLayout() {
|
|
319
|
+
await ensureDir(this.paths.baseDir);
|
|
320
|
+
await ensureDir(this.paths.tokenDir);
|
|
321
|
+
await ensureDir(this.paths.wakeQueueDir);
|
|
322
|
+
await ensureDir(this.paths.logDir);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async saveConfig() {
|
|
326
|
+
await atomicWriteJSON(this.paths.configPath, this.config);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async saveSession() {
|
|
330
|
+
await atomicWriteJSON(this.paths.sessionPath, this.session);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async saveState() {
|
|
334
|
+
await atomicWriteJSON(this.paths.statePath, this.state);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
normalizeTurnReadyWakeKey(job) {
|
|
338
|
+
if (String(job?.type || "").trim() !== "room.turn_ready") {
|
|
339
|
+
return "";
|
|
340
|
+
}
|
|
341
|
+
const roomId = String(job?.room_id || "").trim();
|
|
342
|
+
const nextActorID = String(job?.next_actor_id || "").trim();
|
|
343
|
+
if (!roomId || !nextActorID || typeof job?.next_turn !== "number") {
|
|
344
|
+
return "";
|
|
345
|
+
}
|
|
346
|
+
return `${roomId}|${job.next_turn}|${nextActorID}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
hasCompletedWakeKey(key) {
|
|
350
|
+
const normalized = String(key || "").trim();
|
|
351
|
+
if (!normalized) {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
return (this.state.completed_wake_keys || []).includes(normalized);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
rememberCompletedWakeKey(key) {
|
|
358
|
+
const normalized = String(key || "").trim();
|
|
359
|
+
if (!normalized) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const current = Array.isArray(this.state.completed_wake_keys) ? this.state.completed_wake_keys : [];
|
|
363
|
+
this.state.completed_wake_keys = [normalized, ...current.filter((item) => item !== normalized)].slice(0, 64);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
forgetCompletedWakeKeysForRoom(roomId) {
|
|
367
|
+
const targetRoomID = String(roomId || "").trim();
|
|
368
|
+
if (!targetRoomID) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const prefix = `${targetRoomID}|`;
|
|
372
|
+
this.state.completed_wake_keys = (Array.isArray(this.state.completed_wake_keys) ? this.state.completed_wake_keys : [])
|
|
373
|
+
.filter((key) => !String(key || "").startsWith(prefix));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async relogin() {
|
|
377
|
+
const apiKey = String(this.session.api_key || "").trim();
|
|
378
|
+
if (!apiKey) {
|
|
379
|
+
throw Object.assign(new Error("AYA API key missing; run login first"), { exitCode: 3 });
|
|
380
|
+
}
|
|
381
|
+
const payload = await this.requestJSON("/v1/agent/login", {
|
|
382
|
+
method: "POST",
|
|
383
|
+
auth: "none",
|
|
384
|
+
body: { api_key: apiKey }
|
|
385
|
+
});
|
|
386
|
+
const sessionToken = String(payload.session_token || "").trim();
|
|
387
|
+
if (!sessionToken) {
|
|
388
|
+
throw Object.assign(new Error("AYA login did not return session_token"), { exitCode: 3 });
|
|
389
|
+
}
|
|
390
|
+
this.session = {
|
|
391
|
+
...this.session,
|
|
392
|
+
session_token: sessionToken,
|
|
393
|
+
updated_at: new Date().toISOString()
|
|
394
|
+
};
|
|
395
|
+
await this.saveSession();
|
|
396
|
+
return this.session;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async ensureSession() {
|
|
400
|
+
if (String(this.session.session_token || "").trim()) {
|
|
401
|
+
return this.session;
|
|
402
|
+
}
|
|
403
|
+
return this.relogin();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
apiURL(pathname) {
|
|
407
|
+
return new URL(pathname, `${normalizeURL(this.config.aya.api_base_url)}/`).toString();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async apiFetch(pathname, options = {}) {
|
|
411
|
+
const {
|
|
412
|
+
method = "GET",
|
|
413
|
+
body,
|
|
414
|
+
headers = {},
|
|
415
|
+
auth = "session",
|
|
416
|
+
retryOnUnauthorized = true,
|
|
417
|
+
signal
|
|
418
|
+
} = options;
|
|
419
|
+
|
|
420
|
+
if (auth === "session") {
|
|
421
|
+
await this.ensureSession();
|
|
422
|
+
}
|
|
423
|
+
const finalHeaders = { ...headers };
|
|
424
|
+
if (auth === "session") {
|
|
425
|
+
finalHeaders.Authorization = `Bearer ${this.session.session_token}`;
|
|
426
|
+
}
|
|
427
|
+
let payloadBody;
|
|
428
|
+
if (body !== undefined) {
|
|
429
|
+
finalHeaders["Content-Type"] = "application/json";
|
|
430
|
+
payloadBody = JSON.stringify(body);
|
|
431
|
+
}
|
|
432
|
+
const response = await this.fetch(this.apiURL(pathname), {
|
|
433
|
+
method,
|
|
434
|
+
headers: finalHeaders,
|
|
435
|
+
body: payloadBody,
|
|
436
|
+
signal
|
|
437
|
+
});
|
|
438
|
+
if (response.status === 401 && auth === "session" && retryOnUnauthorized) {
|
|
439
|
+
await this.relogin();
|
|
440
|
+
return this.apiFetch(pathname, { ...options, retryOnUnauthorized: false });
|
|
441
|
+
}
|
|
442
|
+
return response;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async requestJSON(pathname, options = {}) {
|
|
446
|
+
const response = await this.apiFetch(pathname, options);
|
|
447
|
+
const text = await response.text();
|
|
448
|
+
const body = parseJSONText(text);
|
|
449
|
+
if (!response.ok) {
|
|
450
|
+
throw new HTTPError(response.status, body, `${options.method || "GET"} ${pathname} -> ${response.status}`);
|
|
451
|
+
}
|
|
452
|
+
return body;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async writeRoomToken(roomId, tokenPayload) {
|
|
456
|
+
const file = path.join(this.paths.tokenDir, `${roomId}.json`);
|
|
457
|
+
await atomicWriteJSON(file, {
|
|
458
|
+
room_id: roomId,
|
|
459
|
+
agent_id: tokenPayload.agent_id || this.session.agent_id || "",
|
|
460
|
+
token: tokenPayload.token,
|
|
461
|
+
expires_at: tokenPayload.expires_at,
|
|
462
|
+
scope: tokenPayload.scope || "room:automation",
|
|
463
|
+
updated_at: new Date().toISOString()
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async deleteRoomToken(roomId) {
|
|
468
|
+
await removeIfExists(path.join(this.paths.tokenDir, `${roomId}.json`));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async refreshRoomToken(roomId) {
|
|
472
|
+
const payload = await this.requestJSON(`/v1/rooms/${encodeURIComponent(roomId)}/access-token`, {
|
|
473
|
+
method: "POST"
|
|
474
|
+
});
|
|
475
|
+
const token = String(payload.token || "").trim();
|
|
476
|
+
if (!token) {
|
|
477
|
+
throw new Error(`room access token response missing token for ${roomId}`);
|
|
478
|
+
}
|
|
479
|
+
await this.writeRoomToken(roomId, payload);
|
|
480
|
+
return payload;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async readRoomToken(roomId) {
|
|
484
|
+
return readJSON(path.join(this.paths.tokenDir, `${roomId}.json`), null);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
shouldRefreshToken(expiresAt) {
|
|
488
|
+
const expiry = parseISOTime(expiresAt);
|
|
489
|
+
if (!expiry) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
const thresholdSeconds = Number(this.config.aya?.token_refresh_threshold_seconds || 60);
|
|
493
|
+
const thresholdMs = Math.max(0, thresholdSeconds) * 1000;
|
|
494
|
+
return (expiry.getTime() - Date.now()) <= thresholdMs;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async ensureFreshRoomTokenForJob(job) {
|
|
498
|
+
if (String(job.type || "").trim() !== "room.turn_ready") {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const roomId = String(job.room_id || "").trim();
|
|
502
|
+
if (!roomId) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const current = await this.readRoomToken(roomId);
|
|
507
|
+
const hasToken = String(current?.token || "").trim() !== "";
|
|
508
|
+
const expiresAt = current?.expires_at || job.token_expires_at;
|
|
509
|
+
const needsRefresh = !hasToken || this.shouldRefreshToken(expiresAt);
|
|
510
|
+
if (!needsRefresh) {
|
|
511
|
+
return current;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
this.logger.info(`refreshing room token room_id=${roomId} reason=pre_wake_or_missing`);
|
|
515
|
+
const refreshed = await this.refreshRoomToken(roomId);
|
|
516
|
+
return this.readRoomToken(roomId).catch(() => ({
|
|
517
|
+
room_id: roomId,
|
|
518
|
+
token: refreshed.token,
|
|
519
|
+
expires_at: refreshed.expires_at
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
normalizeTurnReadyWakeKey(job) {
|
|
524
|
+
if (String(job.type || "").trim() !== "room.turn_ready") {
|
|
525
|
+
return "";
|
|
526
|
+
}
|
|
527
|
+
const roomId = String(job.room_id || "").trim();
|
|
528
|
+
const nextActorID = String(job.next_actor_id || "").trim();
|
|
529
|
+
if (!roomId || !nextActorID || typeof job.next_turn !== "number") {
|
|
530
|
+
return "";
|
|
531
|
+
}
|
|
532
|
+
return `${roomId}|${job.next_turn}|${nextActorID}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async findEquivalentPendingWakeJob(job) {
|
|
536
|
+
const key = this.normalizeTurnReadyWakeKey(job);
|
|
537
|
+
if (!key) {
|
|
538
|
+
return "";
|
|
539
|
+
}
|
|
540
|
+
const files = await listJSONFiles(this.paths.wakeQueueDir);
|
|
541
|
+
for (const file of files) {
|
|
542
|
+
const existing = await readJSON(file, null);
|
|
543
|
+
if (!existing) {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (String(existing.status || "pending") === "done") {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (this.normalizeTurnReadyWakeKey(existing) === key) {
|
|
550
|
+
return file;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return "";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async enqueueWakeJob(job) {
|
|
557
|
+
const completedKey = this.normalizeTurnReadyWakeKey(job);
|
|
558
|
+
if (completedKey && this.hasCompletedWakeKey(completedKey)) {
|
|
559
|
+
return "";
|
|
560
|
+
}
|
|
561
|
+
const equivalent = await this.findEquivalentPendingWakeJob(job);
|
|
562
|
+
if (equivalent) {
|
|
563
|
+
return equivalent;
|
|
564
|
+
}
|
|
565
|
+
const file = path.join(this.paths.wakeQueueDir, `${job.delivery_id}.json`);
|
|
566
|
+
const exists = await pathExists(file);
|
|
567
|
+
if (exists) {
|
|
568
|
+
return file;
|
|
569
|
+
}
|
|
570
|
+
await atomicWriteJSON(file, {
|
|
571
|
+
delivery_id: job.delivery_id,
|
|
572
|
+
type: job.type,
|
|
573
|
+
room_id: job.room_id,
|
|
574
|
+
event_id: job.event_id || null,
|
|
575
|
+
reason: job.reason || null,
|
|
576
|
+
room_state: job.room_state || null,
|
|
577
|
+
next_turn: typeof job.next_turn === "number" ? job.next_turn : null,
|
|
578
|
+
next_actor_id: job.next_actor_id || null,
|
|
579
|
+
occurred_at: job.occurred_at || null,
|
|
580
|
+
token_expires_at: job.token_expires_at || null,
|
|
581
|
+
received_at: job.received_at || new Date().toISOString(),
|
|
582
|
+
status: "pending",
|
|
583
|
+
attempt_count: 0
|
|
584
|
+
});
|
|
585
|
+
return file;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async dropWakeJobsForRoom(roomId) {
|
|
589
|
+
const targetRoomID = String(roomId || "").trim();
|
|
590
|
+
if (!targetRoomID) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const files = await listJSONFiles(this.paths.wakeQueueDir);
|
|
594
|
+
for (const file of files) {
|
|
595
|
+
const job = await readJSON(file, null);
|
|
596
|
+
if (!job) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (String(job.room_id || "").trim() !== targetRoomID) {
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
await removeIfExists(file);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async updateWakeJob(jobFile, value) {
|
|
607
|
+
await atomicWriteJSON(jobFile, value);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async ackDelivery(deliveryId) {
|
|
611
|
+
await this.requestJSON("/v1/agent/stream/ack", {
|
|
612
|
+
method: "POST",
|
|
613
|
+
body: { delivery_id: deliveryId }
|
|
614
|
+
});
|
|
615
|
+
this.state.last_acknowledged_delivery_id = deliveryId;
|
|
616
|
+
this.state.last_stream_status = "acked";
|
|
617
|
+
await this.saveState();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async wakeOpenClaw(job) {
|
|
621
|
+
const roomId = String(job.room_id || "").trim();
|
|
622
|
+
const deliveryId = String(job.delivery_id || "").trim();
|
|
623
|
+
const hookURL = normalizeURL(this.config.openclaw.hook_url);
|
|
624
|
+
const hookToken = String(this.config.openclaw.hook_token || "").trim();
|
|
625
|
+
if (!hookURL || !hookToken) {
|
|
626
|
+
throw new Error("OpenClaw hook_url/hook_token missing from bridge config");
|
|
627
|
+
}
|
|
628
|
+
const tokenState = await this.ensureFreshRoomTokenForJob(job);
|
|
629
|
+
const tokenExpiresAt = tokenState?.expires_at || job.token_expires_at || null;
|
|
630
|
+
const tokenPath = path.join(this.paths.tokenDir, `${roomId}.json`);
|
|
631
|
+
const contract = {
|
|
632
|
+
contract: "aya.wake.v1",
|
|
633
|
+
delivery_id: deliveryId,
|
|
634
|
+
event_type: String(job.type || "room.turn_ready"),
|
|
635
|
+
event_id: job.event_id || null,
|
|
636
|
+
reason: job.reason || null,
|
|
637
|
+
room_id: roomId,
|
|
638
|
+
room_state: job.room_state || null,
|
|
639
|
+
next_turn: typeof job.next_turn === "number" ? job.next_turn : null,
|
|
640
|
+
next_actor_id: job.next_actor_id || null,
|
|
641
|
+
occurred_at: job.occurred_at || null,
|
|
642
|
+
token_path: tokenPath,
|
|
643
|
+
token_expires_at: tokenExpiresAt,
|
|
644
|
+
instructions: [
|
|
645
|
+
"Read token_path and fetch fresh /v1/rooms/{id}/context before deciding.",
|
|
646
|
+
"Reply exactly once only if next_actor_id in fresh context equals your agent_id.",
|
|
647
|
+
"If token missing/expired or API returns 401, refresh with POST /v1/rooms/{id}/access-token and retry once.",
|
|
648
|
+
"If API returns 409 turn_mismatch or stale_bundle_hash, stop and wait for next wake."
|
|
649
|
+
]
|
|
650
|
+
};
|
|
651
|
+
const message = [
|
|
652
|
+
"[AYA_WAKE_V1]",
|
|
653
|
+
JSON.stringify(contract)
|
|
654
|
+
].join("\n");
|
|
655
|
+
const payload = {
|
|
656
|
+
agentId: this.config.openclaw.agent_id || "main",
|
|
657
|
+
message,
|
|
658
|
+
name: "areyouai",
|
|
659
|
+
wakeMode: "now",
|
|
660
|
+
deliver: false,
|
|
661
|
+
timeoutSeconds: 120
|
|
662
|
+
};
|
|
663
|
+
const response = await this.fetch(hookURL, {
|
|
664
|
+
method: "POST",
|
|
665
|
+
headers: {
|
|
666
|
+
Authorization: `Bearer ${hookToken}`,
|
|
667
|
+
"Content-Type": "application/json"
|
|
668
|
+
},
|
|
669
|
+
body: JSON.stringify(payload)
|
|
670
|
+
});
|
|
671
|
+
const text = await response.text();
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
throw new HTTPError(response.status, parseJSONText(text), `OpenClaw wake failed ${response.status}`);
|
|
674
|
+
}
|
|
675
|
+
return parseJSONText(text);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async drainWakeQueue() {
|
|
679
|
+
const files = await listJSONFiles(this.paths.wakeQueueDir);
|
|
680
|
+
for (const file of files) {
|
|
681
|
+
const job = await readJSON(file, null);
|
|
682
|
+
if (!job) {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
await this.wakeOpenClaw(job);
|
|
687
|
+
await removeIfExists(file);
|
|
688
|
+
const completedKey = this.normalizeTurnReadyWakeKey(job);
|
|
689
|
+
if (completedKey) {
|
|
690
|
+
this.rememberCompletedWakeKey(completedKey);
|
|
691
|
+
try {
|
|
692
|
+
await this.saveState();
|
|
693
|
+
} catch (err) {
|
|
694
|
+
this.logger.warn(`wake state persist failed delivery_id=${job.delivery_id} room_id=${job.room_id} error=${err.message}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
this.logger.info(`wake delivered delivery_id=${job.delivery_id} room_id=${job.room_id}`);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
const nextJob = {
|
|
700
|
+
...job,
|
|
701
|
+
attempt_count: Number(job.attempt_count || 0) + 1,
|
|
702
|
+
last_error: err.message,
|
|
703
|
+
updated_at: new Date().toISOString()
|
|
704
|
+
};
|
|
705
|
+
await this.updateWakeJob(file, nextJob);
|
|
706
|
+
this.logger.warn(`wake failed delivery_id=${job.delivery_id} room_id=${job.room_id} error=${err.message}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async processRecovery() {
|
|
712
|
+
this.state.last_acknowledged_delivery_id = "";
|
|
713
|
+
this.state.last_stream_status = "recovery";
|
|
714
|
+
await this.saveState();
|
|
715
|
+
const payload = await this.requestJSON("/v1/agent/actionable-rooms");
|
|
716
|
+
for (const item of payload.terminal || []) {
|
|
717
|
+
if (item.room_id) {
|
|
718
|
+
await this.deleteRoomToken(item.room_id);
|
|
719
|
+
await this.dropWakeJobsForRoom(item.room_id);
|
|
720
|
+
this.forgetCompletedWakeKeysForRoom(item.room_id);
|
|
721
|
+
await this.saveState();
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
for (const item of payload.actionable || []) {
|
|
725
|
+
const roomId = String(item.room_id || "").trim();
|
|
726
|
+
const token = String(item.token || item.room_token || "").trim();
|
|
727
|
+
if (!roomId || !token) {
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const wakeKey = this.normalizeTurnReadyWakeKey(item);
|
|
731
|
+
if (wakeKey && this.hasCompletedWakeKey(wakeKey)) {
|
|
732
|
+
await this.dropWakeJobsForRoom(roomId);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
// Recovery payload is authoritative; clear stale queued wake jobs for this room.
|
|
736
|
+
await this.dropWakeJobsForRoom(roomId);
|
|
737
|
+
await this.writeRoomToken(roomId, item);
|
|
738
|
+
const syntheticDeliveryID = `recovery-${roomId}-${item.next_turn ?? "unknown"}`;
|
|
739
|
+
await this.enqueueWakeJob({
|
|
740
|
+
delivery_id: syntheticDeliveryID,
|
|
741
|
+
type: "room.turn_ready",
|
|
742
|
+
room_id: roomId,
|
|
743
|
+
reason: "replay_recovery",
|
|
744
|
+
room_state: item.room_state || "ACTIVE",
|
|
745
|
+
next_turn: typeof item.next_turn === "number" ? item.next_turn : null,
|
|
746
|
+
next_actor_id: item.next_actor_id || null,
|
|
747
|
+
occurred_at: new Date().toISOString(),
|
|
748
|
+
token_expires_at: item.expires_at || null,
|
|
749
|
+
received_at: new Date().toISOString()
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
await this.drainWakeQueue();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
async handleTurnReady(payload) {
|
|
756
|
+
const deliveryId = String(payload.delivery_id || "").trim();
|
|
757
|
+
const roomId = String(payload.room_id || "").trim();
|
|
758
|
+
if (!deliveryId || !roomId) {
|
|
759
|
+
throw new Error("room.turn_ready missing delivery_id or room_id");
|
|
760
|
+
}
|
|
761
|
+
const wakeKey = this.normalizeTurnReadyWakeKey(payload);
|
|
762
|
+
if (wakeKey && this.hasCompletedWakeKey(wakeKey)) {
|
|
763
|
+
await this.ackDelivery(deliveryId);
|
|
764
|
+
this.logger.info(`delivery deduped delivery_id=${deliveryId} room_id=${roomId} event_type=room.turn_ready`);
|
|
765
|
+
return payload;
|
|
766
|
+
}
|
|
767
|
+
const token = String(payload.room_token || "").trim();
|
|
768
|
+
let tokenPayload = payload;
|
|
769
|
+
if (!token) {
|
|
770
|
+
tokenPayload = await this.refreshRoomToken(roomId);
|
|
771
|
+
} else {
|
|
772
|
+
await this.writeRoomToken(roomId, payload);
|
|
773
|
+
}
|
|
774
|
+
await this.enqueueWakeJob({
|
|
775
|
+
delivery_id: deliveryId,
|
|
776
|
+
type: payload.type || "room.turn_ready",
|
|
777
|
+
room_id: roomId,
|
|
778
|
+
event_id: payload.event_id || null,
|
|
779
|
+
reason: payload.reason || null,
|
|
780
|
+
room_state: payload.room_state || "ACTIVE",
|
|
781
|
+
next_turn: typeof payload.next_turn === "number" ? payload.next_turn : null,
|
|
782
|
+
next_actor_id: payload.next_actor_id || null,
|
|
783
|
+
occurred_at: payload.occurred_at || new Date().toISOString(),
|
|
784
|
+
token_expires_at: tokenPayload.expires_at || payload.expires_at || null,
|
|
785
|
+
received_at: new Date().toISOString()
|
|
786
|
+
});
|
|
787
|
+
await this.ackDelivery(deliveryId);
|
|
788
|
+
this.logger.info(`delivery acked delivery_id=${deliveryId} room_id=${roomId} event_type=room.turn_ready`);
|
|
789
|
+
await this.drainWakeQueue();
|
|
790
|
+
return tokenPayload;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async handleTerminal(payload) {
|
|
794
|
+
const deliveryId = String(payload.delivery_id || "").trim();
|
|
795
|
+
const roomId = String(payload.room_id || "").trim();
|
|
796
|
+
if (roomId) {
|
|
797
|
+
await this.deleteRoomToken(roomId);
|
|
798
|
+
await this.dropWakeJobsForRoom(roomId);
|
|
799
|
+
this.forgetCompletedWakeKeysForRoom(roomId);
|
|
800
|
+
await this.saveState();
|
|
801
|
+
}
|
|
802
|
+
if (deliveryId) {
|
|
803
|
+
await this.ackDelivery(deliveryId);
|
|
804
|
+
this.logger.info(`delivery acked delivery_id=${deliveryId} room_id=${roomId} event_type=${payload.type}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async handleStreamEvent(event) {
|
|
809
|
+
const payload = event.data || {};
|
|
810
|
+
switch (event.event) {
|
|
811
|
+
case "stream.hello":
|
|
812
|
+
if (payload.agent_id) {
|
|
813
|
+
this.session.agent_id = payload.agent_id;
|
|
814
|
+
await this.saveSession();
|
|
815
|
+
}
|
|
816
|
+
if (payload.resume_status === "replay_required") {
|
|
817
|
+
await this.processRecovery();
|
|
818
|
+
return "reconnect";
|
|
819
|
+
}
|
|
820
|
+
return "continue";
|
|
821
|
+
case "stream.replay_required":
|
|
822
|
+
await this.processRecovery();
|
|
823
|
+
return "reconnect";
|
|
824
|
+
case "auth.relogin_required":
|
|
825
|
+
await this.relogin();
|
|
826
|
+
return "reconnect";
|
|
827
|
+
case "room.turn_ready":
|
|
828
|
+
await this.handleTurnReady(payload);
|
|
829
|
+
return "continue";
|
|
830
|
+
case "room.closed":
|
|
831
|
+
case "room.purged":
|
|
832
|
+
await this.handleTerminal(payload);
|
|
833
|
+
return "continue";
|
|
834
|
+
default:
|
|
835
|
+
this.logger.debug(`ignoring stream event ${event.event}`);
|
|
836
|
+
return "continue";
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async openStream(signal) {
|
|
841
|
+
const lastDeliveryID = String(this.state.last_acknowledged_delivery_id || "").trim();
|
|
842
|
+
const url = new URL("/v1/agent/stream", `${normalizeURL(this.config.aya.api_base_url)}/`);
|
|
843
|
+
if (lastDeliveryID) {
|
|
844
|
+
url.searchParams.set("last_delivery_id", lastDeliveryID);
|
|
845
|
+
}
|
|
846
|
+
await this.ensureSession();
|
|
847
|
+
let response = await this.fetch(url, {
|
|
848
|
+
method: "GET",
|
|
849
|
+
headers: {
|
|
850
|
+
Authorization: `Bearer ${this.session.session_token}`,
|
|
851
|
+
Accept: "text/event-stream"
|
|
852
|
+
},
|
|
853
|
+
signal
|
|
854
|
+
});
|
|
855
|
+
if (response.status === 401) {
|
|
856
|
+
await this.relogin();
|
|
857
|
+
response = await this.fetch(url, {
|
|
858
|
+
method: "GET",
|
|
859
|
+
headers: {
|
|
860
|
+
Authorization: `Bearer ${this.session.session_token}`,
|
|
861
|
+
Accept: "text/event-stream"
|
|
862
|
+
},
|
|
863
|
+
signal
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (!response.ok) {
|
|
867
|
+
const text = await response.text();
|
|
868
|
+
throw new HTTPError(response.status, parseJSONText(text), `stream connect failed ${response.status}`);
|
|
869
|
+
}
|
|
870
|
+
const contentType = String(response.headers.get("content-type") || "");
|
|
871
|
+
if (!contentType.startsWith("text/event-stream")) {
|
|
872
|
+
throw new Error(`unexpected stream content-type: ${contentType}`);
|
|
873
|
+
}
|
|
874
|
+
this.state.last_connected_at = new Date().toISOString();
|
|
875
|
+
this.state.last_stream_status = "connected";
|
|
876
|
+
await this.saveState();
|
|
877
|
+
return response;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async serve(signal) {
|
|
881
|
+
await this.ensureLayout();
|
|
882
|
+
await this.drainWakeQueue();
|
|
883
|
+
let attempt = 0;
|
|
884
|
+
let wakeRetryTimer = null;
|
|
885
|
+
|
|
886
|
+
try {
|
|
887
|
+
wakeRetryTimer = setInterval(() => {
|
|
888
|
+
this.drainWakeQueue().catch((err) => {
|
|
889
|
+
this.logger.warn(`wake queue drain failed error=${err.message}`);
|
|
890
|
+
});
|
|
891
|
+
}, Number(this.config.aya.wake_retry_interval_ms || 5000));
|
|
892
|
+
|
|
893
|
+
while (!signal?.aborted) {
|
|
894
|
+
try {
|
|
895
|
+
const response = await this.openStream(signal);
|
|
896
|
+
attempt = 0;
|
|
897
|
+
for await (const event of parseSSE(response.body)) {
|
|
898
|
+
const action = await this.handleStreamEvent(event);
|
|
899
|
+
if (action === "reconnect") {
|
|
900
|
+
await response.body?.cancel?.();
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
} catch (err) {
|
|
905
|
+
if (signal?.aborted && err?.name === "AbortError") {
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
this.state.last_stream_status = "disconnected";
|
|
909
|
+
await this.saveState();
|
|
910
|
+
this.logger.warn(`stream disconnected error=${err.message}`);
|
|
911
|
+
}
|
|
912
|
+
if (signal?.aborted) {
|
|
913
|
+
break;
|
|
914
|
+
}
|
|
915
|
+
const delay = computeBackoffMs(this.config.aya.reconnect, attempt);
|
|
916
|
+
attempt += 1;
|
|
917
|
+
await sleep(delay, undefined, { signal }).catch(() => {});
|
|
918
|
+
}
|
|
919
|
+
} finally {
|
|
920
|
+
if (wakeRetryTimer) {
|
|
921
|
+
clearInterval(wakeRetryTimer);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
module.exports = {
|
|
928
|
+
BridgeDaemon,
|
|
929
|
+
HTTPError,
|
|
930
|
+
DEFAULT_BASE_DIR,
|
|
931
|
+
atomicWriteJSON,
|
|
932
|
+
createLogger,
|
|
933
|
+
defaultConfig,
|
|
934
|
+
expandHome,
|
|
935
|
+
listJSONFiles,
|
|
936
|
+
parseSSE,
|
|
937
|
+
parseSSEFrame,
|
|
938
|
+
readJSON,
|
|
939
|
+
resolvePaths
|
|
940
|
+
};
|