openmausbot 0.1.69 → 0.1.70

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.
@@ -449,6 +449,7 @@ export class RoutineManager {
449
449
  timeoutMinutes: loadTimeoutMinutes(routine.timeoutMinutes),
450
450
  attachments: loadAttachments(routine.attachments),
451
451
  sourceThreadId: persistedSourceThreadId.parse(routine.sourceThreadId),
452
+ resultsThreadId: persistedSourceThreadId.parse(routine.resultsThreadId),
452
453
  };
453
454
  if (loaded.timeoutMinutes === undefined)
454
455
  delete loaded.timeoutMinutes;
@@ -467,6 +468,7 @@ export class RoutineManager {
467
468
  timeoutMinutes: loadTimeoutMinutes(run.timeoutMinutes),
468
469
  attachments: loadAttachments(run.attachments),
469
470
  sourceThreadId: persistedSourceThreadId.parse(run.sourceThreadId),
471
+ resultsThreadId: persistedSourceThreadId.parse(run.resultsThreadId),
470
472
  };
471
473
  if (loaded.timeoutMinutes === undefined)
472
474
  delete loaded.timeoutMinutes;
@@ -619,11 +621,12 @@ export class RoutineManager {
619
621
  createdAt: at,
620
622
  updatedAt: at,
621
623
  };
624
+ const discardResults = this.applyResultsInput(routine, input.resultsThreadId);
622
625
  this.commitMutation(() => {
623
626
  this.routines.unshift(routine);
624
627
  if (request)
625
628
  this.rememberRoutineRequest(request, routine.id, at);
626
- });
629
+ }, discardResults);
627
630
  this.emitRoutine(routine);
628
631
  return cloneRoutine(routine);
629
632
  }
@@ -664,9 +667,14 @@ export class RoutineManager {
664
667
  if (clean.schedule.type === "interval" && clean.enabled && nextRunAt === null) {
665
668
  throw new Error("This interval has no future runs. Choose a later end date or turn it off.");
666
669
  }
670
+ const destination = { ...routine, ...clean };
671
+ if (destination.botId !== routine.botId)
672
+ delete destination.resultsThreadId;
673
+ const discardResults = this.applyResultsInput(destination, patch.resultsThreadId);
667
674
  const cancelledRuns = [];
668
675
  this.commitMutation(() => {
669
676
  Object.assign(routine, clean, {
677
+ resultsThreadId: destination.resultsThreadId,
670
678
  nextRunAt,
671
679
  // `updatedAt` doubles as the optimistic revision on durable routine
672
680
  // confirmation cards. Keep it monotonic even for two writes in one ms.
@@ -692,7 +700,7 @@ export class RoutineManager {
692
700
  }
693
701
  if (request)
694
702
  this.rememberRoutineRequest(request, routine.id, now);
695
- });
703
+ }, discardResults);
696
704
  for (const run of cancelledRuns)
697
705
  this.emitRun(run);
698
706
  this.emitRoutine(routine);
@@ -803,15 +811,19 @@ export class RoutineManager {
803
811
  if (!routine)
804
812
  return null;
805
813
  let run;
814
+ const allocations = [];
815
+ const previousDestination = routine.resultsThreadId;
806
816
  this.commitMutation(() => {
807
- run = this.newRun(routine, this.now(), true);
808
- // A chat-confirmed "run now" reports back to the conversation that
809
- // invoked this one run. It must not silently rebind future schedules.
817
+ run = this.newRun(routine, this.now(), true, allocations, request?.threadId ?? routine.sourceThreadId);
818
+ // Preserve the invoking chat as provenance/fallback for this run.
819
+ // An explicitly configured results destination continues to win.
810
820
  if (request)
811
821
  run.sourceThreadId = request.threadId;
812
822
  if (request)
813
823
  this.rememberRoutineRequest(request, run.id, this.now());
814
- });
824
+ }, () => this.discardResultsThreads(allocations));
825
+ if (routine.resultsThreadId !== previousDestination)
826
+ this.emitRoutine(routine);
815
827
  this.emitRun(run);
816
828
  queueMicrotask(() => void this.tick());
817
829
  return cloneRun(run);
@@ -978,55 +990,57 @@ export class RoutineManager {
978
990
  await this.options.interruptTurn?.(run.botId, threadId, run.runOn ?? "maus").catch(() => { });
979
991
  }
980
992
  }
981
- let changed = false;
982
- const missedRuns = [];
983
- for (const routine of this.routines) {
984
- if (!routine.enabled || routine.nextRunAt == null || routine.nextRunAt > now)
985
- continue;
986
- const pendingAt = routine.nextRunAt;
987
- const late = now - pendingAt;
988
- const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
989
- ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
990
- : pendingAt;
991
- // One slow interval run must not build an unbounded queue of stale
992
- // copies behind it. The series still advances on its original phase.
993
- const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
994
- if (!overlapping) {
995
- if (late > CATCH_UP_MS) {
996
- const missed = this.newRun(routine, scheduledFor, false);
997
- missed.status = "missed";
998
- missed.finishedAt = now;
999
- missed.error = "This computer was offline for more than 12 hours after the scheduled time";
1000
- this.emitRun(missed);
1001
- missedRuns.push(cloneRun(missed));
1002
- }
1003
- else {
1004
- const run = this.newRun(routine, scheduledFor, false);
1005
- this.emitRun(run);
993
+ const dueRoutines = this.routines.filter((routine) => routine.enabled && routine.nextRunAt != null && routine.nextRunAt <= now);
994
+ const scheduledRuns = [];
995
+ const allocations = [];
996
+ if (dueRoutines.length > 0) {
997
+ this.commitMutation(() => {
998
+ for (const routine of dueRoutines) {
999
+ const pendingAt = routine.nextRunAt;
1000
+ const late = now - pendingAt;
1001
+ const scheduledFor = routine.schedule.type === "interval" && late <= CATCH_UP_MS
1002
+ ? latestIntervalOccurrence(routine.schedule, now) ?? pendingAt
1003
+ : pendingAt;
1004
+ // One slow interval run must not build an unbounded queue of stale
1005
+ // copies behind it. The series still advances on its original phase.
1006
+ const overlapping = routine.schedule.type === "interval" && this.runs.some((run) => run.routineId === routine.id && ["queued", "running", "waiting"].includes(run.status));
1007
+ if (!overlapping) {
1008
+ const run = this.newRun(routine, scheduledFor, false, allocations);
1009
+ if (late > CATCH_UP_MS) {
1010
+ run.status = "missed";
1011
+ run.finishedAt = now;
1012
+ run.error = "This computer was offline for more than 12 hours after the scheduled time";
1013
+ }
1014
+ scheduledRuns.push(run);
1015
+ }
1016
+ routine.nextRunAt =
1017
+ routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
1018
+ // `updatedAt` is the optimistic definition revision carried by
1019
+ // routine confirmation cards. Moving the scheduler cursor is runtime
1020
+ // progress, not a definition edit, so recurring ticks must not make a
1021
+ // still-accurate pending confirmation stale. A one-time routine does
1022
+ // mutate its definition by auto-disabling after its occurrence.
1023
+ if (routine.schedule.type === "once") {
1024
+ routine.enabled = false;
1025
+ routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1026
+ }
1027
+ else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
1028
+ routine.enabled = false;
1029
+ routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1030
+ }
1006
1031
  }
1007
- }
1008
- routine.nextRunAt =
1009
- routine.schedule.type === "once" ? null : nextOccurrence(routine.schedule, Math.max(now, scheduledFor));
1010
- // `updatedAt` is the optimistic definition revision carried by
1011
- // routine confirmation cards. Moving the scheduler cursor is runtime
1012
- // progress, not a definition edit, so recurring ticks must not make a
1013
- // still-accurate pending confirmation stale. A one-time routine does
1014
- // mutate its definition by auto-disabling after its occurrence.
1015
- if (routine.schedule.type === "once") {
1016
- routine.enabled = false;
1017
- routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1018
- }
1019
- else if (routine.schedule.type === "interval" && routine.nextRunAt === null) {
1020
- routine.enabled = false;
1021
- routine.updatedAt = Math.max(now, routine.updatedAt + 1);
1022
- }
1032
+ }, () => this.discardResultsThreads(allocations));
1033
+ }
1034
+ // Reporting may persist transcript cards, so publish only after the
1035
+ // scheduler batch (including each destination) is durable.
1036
+ for (const routine of dueRoutines)
1023
1037
  this.emitRoutine(routine);
1024
- changed = true;
1038
+ for (const run of scheduledRuns)
1039
+ this.emitRun(run);
1040
+ for (const run of scheduledRuns) {
1041
+ if (run.status === "missed")
1042
+ this.options.onRunFailed?.(cloneRun(run));
1025
1043
  }
1026
- if (changed)
1027
- this.save();
1028
- for (const missed of missedRuns)
1029
- this.options.onRunFailed?.(missed);
1030
1044
  // Oldest queued requests have priority. New manual/webhook arrivals
1031
1045
  // must not continually overtake work that has already waited. Snapshot
1032
1046
  // the queue because each dispatch can asynchronously add/cancel work.
@@ -1262,7 +1276,16 @@ export class RoutineManager {
1262
1276
  return schedule.at;
1263
1277
  return nextOccurrence(schedule, now);
1264
1278
  }
1265
- newRun(routine, scheduledFor, manual) {
1279
+ newRun(routine, scheduledFor, manual, allocations, sourceThreadId = routine.sourceThreadId) {
1280
+ if (routine.target === "bot" && this.options.resolveResultsThread) {
1281
+ const destination = this.options.resolveResultsThread({ ...routine, sourceThreadId }, false);
1282
+ if (destination !== routine.resultsThreadId) {
1283
+ if (destination)
1284
+ allocations.push({ botId: routine.botId, threadId: destination });
1285
+ routine.resultsThreadId = destination;
1286
+ routine.updatedAt = Math.max(this.now(), routine.updatedAt + 1);
1287
+ }
1288
+ }
1266
1289
  const run = {
1267
1290
  id: randomUUID(),
1268
1291
  routineId: routine.id,
@@ -1279,12 +1302,44 @@ export class RoutineManager {
1279
1302
  status: "queued",
1280
1303
  manual,
1281
1304
  triggerSource: manual ? "manual" : "schedule",
1282
- sourceThreadId: routine.sourceThreadId,
1305
+ sourceThreadId,
1306
+ resultsThreadId: routine.resultsThreadId,
1283
1307
  createdAt: this.now(),
1284
1308
  };
1285
1309
  this.runs.push(run);
1286
1310
  return run;
1287
1311
  }
1312
+ discardResultsThreads(allocations) {
1313
+ for (const { botId, threadId } of allocations) {
1314
+ try {
1315
+ this.options.discardResultsThread?.(botId, threadId);
1316
+ }
1317
+ catch (error) {
1318
+ console.error("routine: could not discard uncommitted results thread", error);
1319
+ }
1320
+ }
1321
+ }
1322
+ applyResultsInput(routine, value) {
1323
+ if (routine.target !== "bot") {
1324
+ if (value != null)
1325
+ throw Object.assign(new Error("Results threads are only available for bot routines"), { status: 400 });
1326
+ delete routine.resultsThreadId;
1327
+ return;
1328
+ }
1329
+ if (value === undefined)
1330
+ return;
1331
+ if (value === null) {
1332
+ const destination = this.options.resolveResultsThread?.(routine, true);
1333
+ if (!destination)
1334
+ throw new Error("Could not create a results thread for this routine");
1335
+ routine.resultsThreadId = destination;
1336
+ return () => this.options.discardResultsThread?.(routine.botId, destination);
1337
+ }
1338
+ if (typeof value !== "string" || !value.trim() || !this.options.isResultsThread?.(routine.botId, value.trim())) {
1339
+ throw Object.assign(new Error("Choose a visible results thread belonging to this bot"), { status: 400 });
1340
+ }
1341
+ routine.resultsThreadId = value.trim();
1342
+ }
1288
1343
  emitRoutine(routine) {
1289
1344
  this.options.emit?.({ kind: "routine", routine: cloneRoutine(routine) });
1290
1345
  }
@@ -1331,7 +1386,7 @@ export class RoutineManager {
1331
1386
  * state if writing or renaming that file fails so a retry cannot mistake an
1332
1387
  * uncommitted action for a durable one.
1333
1388
  */
1334
- commitMutation(mutate) {
1389
+ commitMutation(mutate, rollback) {
1335
1390
  const before = {
1336
1391
  routines: this.routines.map(cloneRoutine),
1337
1392
  runs: this.runs.map(cloneRun),
@@ -1345,6 +1400,12 @@ export class RoutineManager {
1345
1400
  this.routines = before.routines;
1346
1401
  this.runs = before.runs;
1347
1402
  this.routineRequestReceipts = before.receipts;
1403
+ try {
1404
+ rollback?.();
1405
+ }
1406
+ catch (cleanupError) {
1407
+ console.error("routine: could not discard uncommitted results thread", cleanupError);
1408
+ }
1348
1409
  throw error;
1349
1410
  }
1350
1411
  }
@@ -57,11 +57,27 @@ export function queueSteeredMessage(botId, threadId, text, options = {}) {
57
57
  replyToId: options.replyToId,
58
58
  sendId: options.sendId,
59
59
  reason: options.reason,
60
+ unattended: options.unattended,
60
61
  });
61
62
  queues.set(threadId, entry);
62
63
  changed();
63
64
  return { id };
64
65
  }
66
+ /** Where a thread stands among this bot's threads waiting for a free slot:
67
+ * 1 for the next to start. The drain visits queues in insertion order, so
68
+ * insertion order is the line. Null when nothing of this bot's is waiting
69
+ * on that thread. */
70
+ export function queuedThreadPosition(botId, threadId) {
71
+ let position = 0;
72
+ for (const [candidate, entry] of queues) {
73
+ if (entry.botId !== botId || !entry.items.some((item) => item.reason === "capacity"))
74
+ continue;
75
+ position += 1;
76
+ if (candidate === threadId)
77
+ return position;
78
+ }
79
+ return null;
80
+ }
65
81
  /** Drain every queue whose task is idle: append the held lines (leaf is now
66
82
  * the finished turn's last item), then one run per thread whose prompt is
67
83
  * the texts separated by a blank line. `userMessage` is the last appended line
@@ -108,7 +124,10 @@ export function drainSteeredMessages(store, run, isBlocked) {
108
124
  // message into one HTML block, which makes that attachment stop being a
109
125
  // native image when the combined follow-up is dispatched.
110
126
  const prompt = entry.items.map((item) => item.prompt).join("\n\n");
111
- void run(entry.botId, threadId, prompt, last, appended.map((message) => message.id));
127
+ void run(entry.botId, threadId, prompt, last, appended.map((message) => message.id),
128
+ // one unattended line makes the whole drained turn unattended: a
129
+ // person's words in the same queue cannot re-attend a bot's own
130
+ entry.items.some((item) => item.unattended === true));
112
131
  }
113
132
  }
114
133
  /** Find the receipt for a retry whose message is still waiting to drain. */
@@ -26,6 +26,7 @@ export function isProjectEmoji(value) {
26
26
  const TASK_PATCH_FIELDS = [
27
27
  "title", "projectId", "modelSelection", "approvalMode", "autoApprove", "alwaysAllow",
28
28
  "unread", "rewound", "pinnedMessageId", "resumeCursors", "lastInstanceId", "cwd",
29
+ "routineRunId",
29
30
  ];
30
31
  /** Everything the BOT authored is scrubbed of content-shaped secrets before
31
32
  * it is stored: its reply text, a tool title (an ACP engine's title can be
@@ -1444,7 +1445,7 @@ export class Store {
1444
1445
  }
1445
1446
  /** A fresh context on the same bot: new thread, new session, same
1446
1447
  * persona/tools/computer. Becomes the active task. */
1447
- createTask(botId, title, activate = true, projectId) {
1448
+ createTask(botId, title, activate = true, projectId, openedBy) {
1448
1449
  const bot = this.bot(botId);
1449
1450
  if (!bot)
1450
1451
  return null;
@@ -1455,6 +1456,7 @@ export class Store {
1455
1456
  title: title?.trim().slice(0, 80) || UNTITLED_THREAD,
1456
1457
  createdAt: Date.now(),
1457
1458
  ...(projectId ? { projectId } : {}),
1459
+ ...(openedBy ? { openedBy: structuredClone(openedBy) } : {}),
1458
1460
  resumeCursors: {},
1459
1461
  modelSelection: structuredClone(bot.modelSelection),
1460
1462
  approvalMode: approvalModeFor(bot),
@@ -1472,6 +1474,20 @@ export class Store {
1472
1474
  this.emit({ type: "bot", botId });
1473
1475
  return task;
1474
1476
  }
1477
+ /** Attach (or complete) the opener record after the thread exists — the
1478
+ * handoff id is only known once the thread it targets has an id, so a
1479
+ * peer-opened thread is created first and stamped second. Never reachable
1480
+ * from the HTTP task PATCH: openedBy is not a TASK_PATCH_FIELD. */
1481
+ setTaskOpenedBy(botId, threadId, openedBy) {
1482
+ const bot = this.bot(botId);
1483
+ const task = this.taskByThread(botId, threadId);
1484
+ if (!bot || !task)
1485
+ return null;
1486
+ task.openedBy = structuredClone(openedBy);
1487
+ this.saveBots();
1488
+ this.emit({ type: "bot", botId });
1489
+ return task;
1490
+ }
1475
1491
  switchTask(botId, threadId) {
1476
1492
  const bot = this.bot(botId);
1477
1493
  const task = bot?.tasks?.find((t) => t.threadId === threadId);
@@ -1503,10 +1519,12 @@ export class Store {
1503
1519
  if (!bot.tasks.some((t) => t.threadId === threadId))
1504
1520
  return null;
1505
1521
  bot.tasks = bot.tasks.filter((t) => t.threadId !== threadId);
1506
- this.deleteThreadRecord(threadId);
1507
- if (bot.threadId === threadId) {
1508
- this.mirrorActiveTask(bot, bot.tasks[0]);
1522
+ const visible = bot.tasks.find((task) => !task.routineRunId)
1523
+ ?? this.createTask(botId, undefined, bot.threadId === threadId);
1524
+ if (bot.threadId === threadId || this.taskByThread(botId, bot.threadId)?.routineRunId) {
1525
+ this.mirrorActiveTask(bot, visible);
1509
1526
  }
1527
+ this.deleteThreadRecord(threadId);
1510
1528
  bot.unread = bot.tasks.some((task) => task.unread);
1511
1529
  this.refreshBotActivity(bot);
1512
1530
  this.saveBots();
@@ -36,6 +36,7 @@ export function computerPrompt(kind) {
36
36
  }
37
37
  export const COMPOSIO_PROMPT = " The user's connected apps (Gmail, Calendar, Slack, Notion, and the rest) are reachable through the composio tools — find the right one with COMPOSIO_SEARCH_TOOLS, read its arguments with COMPOSIO_GET_TOOL_SCHEMAS, then run it with COMPOSIO_MULTI_EXECUTE_TOOL. Reach for them before telling the user you have no access to a service.";
38
38
  export const CREDENTIAL_PROMPT = " If a supported API key is missing, use request_credential to create a secure credential request. A freshly QR-paired mobile app or the desktop app can show the secure entry card. Never claim it opened unless the request succeeded, and never ask the user to paste credentials into chat.";
39
+ export const THREADS_PROMPT = " A thread is one conversation with its own history and its own run; a bot can have several running at once, and the person sees them as rows under that bot. Use start_thread to open one on yourself for separate work, or on a teammate to hand them a job that should run on its own. Use list_threads to see how the ones you opened are going. When you mention a thread to the person, write its title as #Title so it links. Do not use a ticket comment, a note, or a room post as a stand-in for a thread.";
39
40
  export const ROUTINE_PROMPT = " If the user explicitly asks to list or review, schedule, run, or change routines, use list_routines and propose_routine or propose_routine_action. A proposal is not applied until the user confirms its in-app card, so never claim the action completed before that confirmation.";
40
41
  export const ROUTINE_EXECUTION_PROMPT = " Execute this routine now: use available peer tools for required handoffs rather than merely announcing that you will wait; after an accepted delegation, end this turn for automatic resumption, and report a concrete blocker if no handoff is possible.";
41
42
  export const LEARN_PROMPT = " If the user sends /learn or asks you to save a reusable procedure from this work, use skills_list and skill_manage. Create new skills; update an existing learned skill only when the user explicitly asks to revise that exact name. Include source provenance and wait for the review card decision.";
@@ -29,6 +29,11 @@ export function createTeamBackup(store, routines, name) {
29
29
  const tasks = record.tasks?.length ? record.tasks : [{ threadId: record.threadId, title: "Conversation", createdAt: record.createdAt }];
30
30
  return tasks.map((task) => ({
31
31
  key: task.threadId, title: task.title, createdAt: task.createdAt,
32
+ // Who opened the thread travels; the handoff id does not — the
33
+ // delegation ledger is process-local and never part of a backup.
34
+ openedBy: "openedBy" in task && task.openedBy
35
+ ? { botId: task.openedBy.botId, name: task.openedBy.name, at: task.openedBy.at }
36
+ : undefined,
32
37
  activeLeafId: store.activeLeaf(task.threadId),
33
38
  messages: store.messagesFor(task.threadId).map((message) => ({
34
39
  id: message.id, role: message.role, text: messageText(message), at: message.at,
@@ -131,10 +136,19 @@ export function importTeamBackup(store, routines, input, selection) {
131
136
  }
132
137
  for (const source of backup.bots) {
133
138
  const bot = store.bot(botIds.get(source.key));
134
- const tasks = source.tasks.map((task, i) => ({
135
- threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
136
- modelSelection: structuredClone(selection), activity: "idle", busy: false, unread: false,
137
- }));
139
+ const tasks = source.tasks.map((task, i) => {
140
+ const record = {
141
+ threadId: i === 0 ? bot.threadId : newId(), title: task.title, createdAt: task.createdAt, resumeCursors: {},
142
+ modelSelection: structuredClone(selection), activity: "idle", busy: false, unread: false,
143
+ };
144
+ // Same rule as a message's `from`: the opener is remapped to its
145
+ // imported twin, and an opener outside this backup leaves no record
146
+ // rather than a bot id that resolves to a stranger.
147
+ const opener = task.openedBy && botIds.get(task.openedBy.botId);
148
+ if (task.openedBy && opener)
149
+ record.openedBy = { botId: opener, name: task.openedBy.name, at: task.openedBy.at };
150
+ return record;
151
+ });
138
152
  // Own the task IDs before writing their transcripts, so rollback also
139
153
  // removes partially imported history if persistence fails midway.
140
154
  store.patchBot(bot.id, { tasks, threadId: tasks[source.tasks.findIndex((task) => task.key === source.activeTask)].threadId });
@@ -20,6 +20,7 @@ const task = z.object({
20
20
  key,
21
21
  title: z.string().max(2_000),
22
22
  createdAt: timestamp,
23
+ openedBy: z.object({ botId: key, name, at: timestamp }).optional(),
23
24
  activeLeafId: key.nullable(),
24
25
  messages: z.array(message).max(100_000),
25
26
  });
@@ -141,9 +141,11 @@ function windowsKnownDirs() {
141
141
  var cached = null;
142
142
  var probed = false;
143
143
  var loginShellPath = null;
144
+ var registeredDirs = [];
144
145
  function augmentedPath() {
145
146
  if (cached === null) {
146
147
  cached = mergePaths([
148
+ ...registeredDirs.filter((d) => existsSync(d)),
147
149
  ...process.env.OMB_EXTRA_PATH ? process.env.OMB_EXTRA_PATH.split(delimiter) : [],
148
150
  ...process.env.PATH ? process.env.PATH.split(delimiter) : [],
149
151
  // Keep the last successful login-shell result while a rescan starts a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",