@leg3ndy/otto-bridge 0.5.3 → 0.5.5

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/dist/config.js CHANGED
@@ -33,6 +33,26 @@ function sanitizePollIntervalMs(value, fallback = DEFAULT_CLAWD_CURSOR_POLL_INTE
33
33
  }
34
34
  return Math.max(250, Math.floor(parsed));
35
35
  }
36
+ export function normalizeInstalledExtensions(values) {
37
+ if (!Array.isArray(values)) {
38
+ return [];
39
+ }
40
+ const seen = new Set();
41
+ const normalized = [];
42
+ for (const item of values) {
43
+ const slug = String(item || "")
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9._-]+/g, "-")
47
+ .replace(/^-+|-+$/g, "");
48
+ if (!slug || seen.has(slug)) {
49
+ continue;
50
+ }
51
+ seen.add(slug);
52
+ normalized.push(slug);
53
+ }
54
+ return normalized;
55
+ }
36
56
  function migrateLegacyExecutor(current) {
37
57
  if (platform() === "darwin"
38
58
  && current?.type === "clawd-cursor"
@@ -67,7 +87,11 @@ export async function loadBridgeConfig() {
67
87
  ...parsed,
68
88
  apiBaseUrl: sanitizeApiBaseUrl(parsed.apiBaseUrl),
69
89
  wsUrl: buildWebSocketUrl(parsed.apiBaseUrl),
90
+ // Older pairings may have persisted an outdated bridgeVersion in config.json.
91
+ // The runtime must always report the currently installed package version.
92
+ bridgeVersion: BRIDGE_VERSION,
70
93
  executor: resolveExecutorConfig(undefined, migrateLegacyExecutor(parsed.executor)),
94
+ installedExtensions: normalizeInstalledExtensions(parsed.installedExtensions),
71
95
  };
72
96
  }
73
97
  catch {
@@ -144,6 +168,7 @@ export function buildBridgeConfig(params) {
144
168
  approvalMode: params.approvalMode || "preview",
145
169
  capabilities: Array.isArray(params.capabilities) ? [...params.capabilities] : [],
146
170
  metadata: params.metadata || {},
171
+ installedExtensions: [],
147
172
  pairedAt: new Date().toISOString(),
148
173
  executor: resolveExecutorConfig(undefined, params.executor),
149
174
  };
@@ -288,6 +288,12 @@ function clipText(value, maxLength) {
288
288
  }
289
289
  return `${value.slice(0, maxLength)}...`;
290
290
  }
291
+ function clipTextPreview(value, maxLength) {
292
+ if (value.length <= maxLength) {
293
+ return value;
294
+ }
295
+ return `${value.slice(0, maxLength)}\n\n[conteudo truncado: mostrando ${maxLength} de ${value.length} caracteres. Peca um trecho mais especifico se quiser continuar.]`;
296
+ }
291
297
  const TEXTUTIL_READABLE_EXTENSIONS = new Set([
292
298
  ".doc",
293
299
  ".docx",
@@ -1020,6 +1026,8 @@ end tell
1020
1026
  title: page.title,
1021
1027
  url: page.url,
1022
1028
  text: page.text,
1029
+ playerTitle: page.playerTitle || "",
1030
+ playerState: page.playerState || "",
1023
1031
  };
1024
1032
  }
1025
1033
  resolveExpectedBrowserHref(rawHref, baseUrl) {
@@ -1058,6 +1066,22 @@ end tell
1058
1066
  return true;
1059
1067
  }
1060
1068
  }
1069
+ const beforePlayerTitle = normalizeText(before?.playerTitle || "");
1070
+ const afterPlayerTitle = normalizeText(after.playerTitle || "");
1071
+ const beforePlayerState = normalizeText(before?.playerState || "");
1072
+ const afterPlayerState = normalizeText(after.playerState || "");
1073
+ const playerLooksActive = afterPlayerState.includes("pause") || afterPlayerState.includes("pausar");
1074
+ if (afterUrl.includes("music.youtube.com")) {
1075
+ if (beforePlayerState && afterPlayerState && beforePlayerState !== afterPlayerState && playerLooksActive) {
1076
+ return true;
1077
+ }
1078
+ if (beforePlayerTitle && afterPlayerTitle && beforePlayerTitle !== afterPlayerTitle) {
1079
+ return true;
1080
+ }
1081
+ if (!beforePlayerTitle && afterPlayerTitle && playerLooksActive) {
1082
+ return true;
1083
+ }
1084
+ }
1061
1085
  const beforeTitle = normalizeText(before?.title || "");
1062
1086
  const afterTitle = normalizeText(after.title || "");
1063
1087
  if (beforeTitle && afterTitle && beforeTitle !== afterTitle) {
@@ -1259,6 +1283,7 @@ const normalize = (value) => String(value || "")
1259
1283
  .replace(/[\\u0300-\\u036f]/g, "")
1260
1284
  .toLowerCase();
1261
1285
  const normalizedDescription = normalize(rawDescription);
1286
+ const isYouTubeMusic = location.hostname.includes("music.youtube.com");
1262
1287
  const wantsFirst = /\\b(primeir[ao]?|first)\\b/.test(normalizedDescription);
1263
1288
  const wantsVideo = /\\b(video|videos|musica|faixa|youtube|resultado|watch)\\b/.test(normalizedDescription) || location.hostname.includes("youtube");
1264
1289
  const stopWords = new Set([
@@ -1275,7 +1300,19 @@ const tokens = Array.from(new Set(
1275
1300
  .filter((token) => token.length >= 3 && !stopWords.has(token))
1276
1301
  ));
1277
1302
 
1278
- const candidateSelectors = location.hostname.includes("youtube")
1303
+ const candidateSelectors = isYouTubeMusic
1304
+ ? [
1305
+ "ytmusic-responsive-list-item-renderer a[href*='watch?v=']",
1306
+ "ytmusic-responsive-list-item-renderer button[aria-label]",
1307
+ "ytmusic-responsive-list-item-renderer tp-yt-paper-icon-button",
1308
+ "ytmusic-responsive-list-item-renderer ytmusic-item-thumbnail-overlay-renderer button",
1309
+ "ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer a[href*='watch?v=']",
1310
+ "a[href*='watch?v=']",
1311
+ "button",
1312
+ "[role='button']",
1313
+ "[role='link']"
1314
+ ]
1315
+ : location.hostname.includes("youtube")
1279
1316
  ? [
1280
1317
  "ytd-video-renderer a#video-title",
1281
1318
  "ytd-video-renderer ytd-thumbnail a",
@@ -1339,8 +1376,11 @@ function scoreCandidate(element, rank) {
1339
1376
 
1340
1377
  if (wantsFirst) score += Math.max(0, 40 - rank);
1341
1378
  if (wantsVideo && normalizedHref.includes("/watch")) score += 30;
1379
+ if (isYouTubeMusic && normalizedHref.includes("watch?v=")) score += 36;
1380
+ if (isYouTubeMusic && element.closest("ytmusic-responsive-list-item-renderer, ytmusic-player-bar")) score += 24;
1342
1381
  if (location.hostname.includes("youtube") && element.closest("ytd-video-renderer, ytd-rich-item-renderer, ytd-rich-grid-media")) score += 20;
1343
1382
  if (element.id === "video-title") score += 12;
1383
+ if (isYouTubeMusic && /\\b(play|pause|reproduzir|tocar)\\b/.test(normalizedText)) score += 12;
1344
1384
  if (!normalizedText && normalizedHref.includes("/watch")) score += 8;
1345
1385
 
1346
1386
  for (const phrase of quotedPhrases) {
@@ -1445,7 +1485,7 @@ tell application "Safari"
1445
1485
  activate
1446
1486
  if (count of windows) = 0 then error "Safari nao possui janelas abertas."
1447
1487
  delay 1
1448
- set pageJson to do JavaScript "(function(){const title=document.title||''; const url=location.href||''; const text=((document.body&&document.body.innerText)||'').trim().slice(0, 12000); return JSON.stringify({title:title,url:url,text:text});})();" in current tab of front window
1488
+ set pageJson to do JavaScript "(function(){const title=document.title||''; const url=location.href||''; const text=((document.body&&document.body.innerText)||'').trim().slice(0, 12000); const playerButton=document.querySelector('ytmusic-player-bar #play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button#play-pause-button, ytmusic-player-bar tp-yt-paper-icon-button.play-pause-button'); const playerTitle=(Array.from(document.querySelectorAll('ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=\"title\"]')).map((node)=>((node&&node.textContent)||'').trim()).find(Boolean))||''; const playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||''; return JSON.stringify({title:title,url:url,text:text,playerTitle:playerTitle,playerState:playerState});})();" in current tab of front window
1449
1489
  end tell
1450
1490
  return pageJson
1451
1491
  `;
@@ -1456,6 +1496,8 @@ return pageJson
1456
1496
  title: asString(parsed.title) || "",
1457
1497
  url: asString(parsed.url) || "",
1458
1498
  text: asString(parsed.text) || "",
1499
+ playerTitle: asString(parsed.playerTitle) || "",
1500
+ playerState: asString(parsed.playerState) || "",
1459
1501
  };
1460
1502
  }
1461
1503
  catch (error) {
@@ -1478,6 +1520,8 @@ return pageTitle & linefeed & pageUrl
1478
1520
  title: String(title || "").trim(),
1479
1521
  url: String(url || "").trim(),
1480
1522
  text: "",
1523
+ playerTitle: "",
1524
+ playerState: "",
1481
1525
  };
1482
1526
  }
1483
1527
  }
@@ -1715,7 +1759,7 @@ if let output = String(data: data, encoding: .utf8) {
1715
1759
  resized,
1716
1760
  };
1717
1761
  }
1718
- async readLocalFile(filePath, maxChars = 4000) {
1762
+ async readLocalFile(filePath, maxChars = 1800) {
1719
1763
  const resolved = expandUserPath(filePath);
1720
1764
  const extension = path.extname(resolved).toLowerCase();
1721
1765
  if (TEXTUTIL_READABLE_EXTENSIONS.has(extension)) {
@@ -1726,7 +1770,7 @@ if let output = String(data: data, encoding: .utf8) {
1726
1770
  resolved,
1727
1771
  ]);
1728
1772
  const content = sanitizeTextForJsonTransport(stdout);
1729
- return clipText(content || "(arquivo sem texto legivel)", maxChars);
1773
+ return clipTextPreview(content || "(arquivo sem texto legivel)", maxChars);
1730
1774
  }
1731
1775
  const raw = await readFile(resolved);
1732
1776
  if (isLikelyBinaryBuffer(raw)) {
@@ -1735,7 +1779,7 @@ if let output = String(data: data, encoding: .utf8) {
1735
1779
  return clipText(`O arquivo ${filename} parece ser binario (${detectedType}) e nao pode ser lido como texto puro pelo Otto Bridge ainda.`, maxChars);
1736
1780
  }
1737
1781
  const content = sanitizeTextForJsonTransport(raw.toString("utf8"));
1738
- return clipText(content || "(arquivo vazio)", maxChars);
1782
+ return clipTextPreview(content || "(arquivo vazio)", maxChars);
1739
1783
  }
1740
1784
  async listLocalFiles(directoryPath, limit = 40) {
1741
1785
  const resolved = expandUserPath(directoryPath);
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import process from "node:process";
4
- import { clearBridgeConfig, getBridgeConfigPath, loadBridgeConfig, resolveApiBaseUrl, resolveExecutorConfig, } from "./config.js";
4
+ import { clearBridgeConfig, getBridgeConfigPath, loadBridgeConfig, normalizeInstalledExtensions, resolveApiBaseUrl, resolveExecutorConfig, saveBridgeConfig, } from "./config.js";
5
5
  import { pairDevice } from "./pairing.js";
6
6
  import { BridgeRuntime } from "./runtime.js";
7
7
  import { BRIDGE_PACKAGE_NAME, BRIDGE_VERSION, DEFAULT_PAIR_TIMEOUT_SECONDS, DEFAULT_POLL_INTERVAL_MS, } from "./types.js";
@@ -56,6 +56,9 @@ function printUsage() {
56
56
  otto-bridge pair --api http://localhost:8000 --code ABC123 [--name "Meu PC"] [--executor native-macos|mock|clawd-cursor]
57
57
  otto-bridge run [--executor native-macos|mock|clawd-cursor] [--clawd-url http://127.0.0.1:3847]
58
58
  otto-bridge status
59
+ otto-bridge extensions --list
60
+ otto-bridge extensions --install github
61
+ otto-bridge extensions --uninstall github
59
62
  otto-bridge version
60
63
  otto-bridge update [--tag latest|next] [--dry-run]
61
64
  otto-bridge unpair
@@ -63,6 +66,8 @@ function printUsage() {
63
66
  Examples:
64
67
  otto-bridge pair --api https://api.leg3ndy.com.br --code ABC123
65
68
  otto-bridge run
69
+ otto-bridge extensions --install github
70
+ otto-bridge extensions --list
66
71
  otto-bridge version
67
72
  otto-bridge update
68
73
  otto-bridge update --dry-run
@@ -108,11 +113,15 @@ async function runPairCommand(args) {
108
113
  console.log(`[otto-bridge] config=${getBridgeConfigPath()}`);
109
114
  console.log("[otto-bridge] next step: run `otto-bridge run` to keep this device online");
110
115
  }
111
- async function runRuntimeCommand(args) {
116
+ async function loadRequiredBridgeConfig() {
112
117
  const config = await loadBridgeConfig();
113
118
  if (!config) {
114
119
  throw new Error("No local pairing found. Run `otto-bridge pair --code <CODE>` first.");
115
120
  }
121
+ return config;
122
+ }
123
+ async function runRuntimeCommand(args) {
124
+ const config = await loadRequiredBridgeConfig();
116
125
  const runtimeConfig = {
117
126
  ...config,
118
127
  executor: resolveExecutorOverrides(args, config.executor),
@@ -136,10 +145,61 @@ async function runStatusCommand() {
136
145
  ws_url: config.wsUrl,
137
146
  approval_mode: config.approvalMode,
138
147
  capabilities: config.capabilities,
148
+ installed_extensions: config.installedExtensions,
139
149
  paired_at: config.pairedAt,
140
150
  executor: config.executor,
141
151
  }, null, 2));
142
152
  }
153
+ async function runExtensionsCommand(args) {
154
+ const config = await loadRequiredBridgeConfig();
155
+ const installValue = option(args, "install");
156
+ const uninstallValue = option(args, "uninstall");
157
+ if (installValue && uninstallValue) {
158
+ throw new Error("Use apenas uma acao por vez: --install ou --uninstall.");
159
+ }
160
+ if (installValue) {
161
+ const nextExtensions = normalizeInstalledExtensions([
162
+ ...config.installedExtensions,
163
+ ...installValue.split(","),
164
+ ]);
165
+ const added = nextExtensions.filter((item) => !config.installedExtensions.includes(item));
166
+ if (!added.length) {
167
+ console.log("[otto-bridge] nenhuma extensao nova para instalar");
168
+ return;
169
+ }
170
+ await saveBridgeConfig({
171
+ ...config,
172
+ installedExtensions: nextExtensions,
173
+ });
174
+ console.log(`[otto-bridge] extensoes instaladas: ${added.join(", ")}`);
175
+ console.log("[otto-bridge] rode `otto-bridge run` novamente se quiser sincronizar agora com a web");
176
+ return;
177
+ }
178
+ if (uninstallValue) {
179
+ const removeSet = new Set(normalizeInstalledExtensions(uninstallValue.split(",")));
180
+ const nextExtensions = config.installedExtensions.filter((item) => !removeSet.has(item));
181
+ const removed = config.installedExtensions.filter((item) => removeSet.has(item));
182
+ if (!removed.length) {
183
+ console.log("[otto-bridge] nenhuma extensao correspondente estava instalada");
184
+ return;
185
+ }
186
+ await saveBridgeConfig({
187
+ ...config,
188
+ installedExtensions: nextExtensions,
189
+ });
190
+ console.log(`[otto-bridge] extensoes removidas: ${removed.join(", ")}`);
191
+ console.log("[otto-bridge] rode `otto-bridge run` novamente se quiser sincronizar agora com a web");
192
+ return;
193
+ }
194
+ if (!config.installedExtensions.length) {
195
+ console.log("[otto-bridge] nenhuma extensao instalada");
196
+ return;
197
+ }
198
+ console.log("[otto-bridge] extensoes instaladas:");
199
+ for (const extension of config.installedExtensions) {
200
+ console.log(`- ${extension}`);
201
+ }
202
+ }
143
203
  async function runUnpairCommand() {
144
204
  await clearBridgeConfig();
145
205
  console.log("[otto-bridge] local pairing cleared");
@@ -173,6 +233,9 @@ async function main() {
173
233
  case "status":
174
234
  await runStatusCommand();
175
235
  return;
236
+ case "extensions":
237
+ await runExtensionsCommand(args);
238
+ return;
176
239
  case "version":
177
240
  printVersion();
178
241
  return;
package/dist/runtime.js CHANGED
@@ -6,6 +6,52 @@ import { JobCancelledError } from "./executors/shared.js";
6
6
  function delay(ms) {
7
7
  return new Promise((resolve) => setTimeout(resolve, ms));
8
8
  }
9
+ function parseSemverTuple(value) {
10
+ const text = String(value || "").trim().replace(/^[vV]/, "");
11
+ if (!text) {
12
+ return null;
13
+ }
14
+ const parts = text.split(".");
15
+ const parsed = [];
16
+ for (const part of parts) {
17
+ const match = part.match(/^(\d+)/);
18
+ if (!match) {
19
+ return null;
20
+ }
21
+ parsed.push(Number(match[1]));
22
+ }
23
+ return parsed.length > 0 ? parsed : null;
24
+ }
25
+ function compareSemver(left, right) {
26
+ const a = parseSemverTuple(left);
27
+ const b = parseSemverTuple(right);
28
+ if (!a && !b)
29
+ return 0;
30
+ if (!a)
31
+ return -1;
32
+ if (!b)
33
+ return 1;
34
+ const maxLength = Math.max(a.length, b.length);
35
+ for (let index = 0; index < maxLength; index += 1) {
36
+ const leftPart = a[index] ?? 0;
37
+ const rightPart = b[index] ?? 0;
38
+ if (leftPart < rightPart)
39
+ return -1;
40
+ if (leftPart > rightPart)
41
+ return 1;
42
+ }
43
+ return 0;
44
+ }
45
+ function bridgeReleaseFromMessage(message) {
46
+ const nested = message.bridge_release;
47
+ if (nested && typeof nested === "object") {
48
+ return nested;
49
+ }
50
+ if (message.latest_version || message.min_supported_version || message.update_command) {
51
+ return message;
52
+ }
53
+ return null;
54
+ }
9
55
  async function parseSocketMessage(data) {
10
56
  if (typeof data === "string") {
11
57
  return JSON.parse(data);
@@ -25,6 +71,7 @@ export class BridgeRuntime {
25
71
  config;
26
72
  reconnectDelayMs = DEFAULT_RECONNECT_BASE_DELAY_MS;
27
73
  executor;
74
+ lastBridgeReleaseNoticeKey = null;
28
75
  pendingConfirmations = new Map();
29
76
  activeCancels = new Map();
30
77
  constructor(config, executor) {
@@ -71,7 +118,10 @@ export class BridgeRuntime {
71
118
  device_name: this.config.deviceName,
72
119
  bridge_version: this.config.bridgeVersion,
73
120
  capabilities: this.config.capabilities,
74
- metadata: this.config.metadata,
121
+ metadata: {
122
+ ...(this.config.metadata || {}),
123
+ installed_extensions: this.config.installedExtensions,
124
+ },
75
125
  }));
76
126
  heartbeatTimer = setInterval(() => {
77
127
  if (socket.readyState === WebSocket.OPEN) {
@@ -119,6 +169,7 @@ export class BridgeRuntime {
119
169
  console.log(`[otto-bridge] server hello device=${String(message.device_id || "")}`);
120
170
  return;
121
171
  case "device.hello_ack":
172
+ this.maybeLogBridgeReleaseNotice(message);
122
173
  case "device.heartbeat_ack":
123
174
  return;
124
175
  case "device.job.start":
@@ -145,6 +196,29 @@ export class BridgeRuntime {
145
196
  console.log(`[otto-bridge] event=${type || "unknown"} payload=${JSON.stringify(message)}`);
146
197
  }
147
198
  }
199
+ maybeLogBridgeReleaseNotice(message) {
200
+ const release = bridgeReleaseFromMessage(message);
201
+ if (!release) {
202
+ return;
203
+ }
204
+ const latestVersion = String(release.latest_version || "").trim();
205
+ const minSupportedVersion = String(release.min_supported_version || "").trim();
206
+ const updateCommand = String(release.update_command || "otto-bridge update").trim() || "otto-bridge update";
207
+ const updateRequired = release.update_required === true || (minSupportedVersion ? compareSemver(this.config.bridgeVersion, minSupportedVersion) < 0 : false);
208
+ const updateAvailable = release.update_available === true || (latestVersion ? compareSemver(this.config.bridgeVersion, latestVersion) < 0 : false);
209
+ const noticeKey = [latestVersion, minSupportedVersion, updateRequired ? "req" : "ok", updateAvailable ? "avail" : "cur"].join("|");
210
+ if (!noticeKey.trim() || this.lastBridgeReleaseNoticeKey === noticeKey) {
211
+ return;
212
+ }
213
+ this.lastBridgeReleaseNoticeKey = noticeKey;
214
+ if (updateRequired) {
215
+ console.warn(`[otto-bridge] update required current=${this.config.bridgeVersion} min_supported=${minSupportedVersion || "unknown"} latest=${latestVersion || "unknown"} command="${updateCommand}"`);
216
+ return;
217
+ }
218
+ if (updateAvailable) {
219
+ console.log(`[otto-bridge] update available current=${this.config.bridgeVersion} latest=${latestVersion || "unknown"} command="${updateCommand}"`);
220
+ }
221
+ }
148
222
  resolveConfirmation(message) {
149
223
  const jobId = String(message.job_id || "");
150
224
  const action = String(message.action || "").trim().toLowerCase();
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.5.3";
2
+ export const BRIDGE_VERSION = "0.5.5";
3
3
  export const BRIDGE_PACKAGE_NAME = "@leg3ndy/otto-bridge";
4
4
  export const DEFAULT_API_BASE_URL = "http://localhost:8000";
5
5
  export const DEFAULT_POLL_INTERVAL_MS = 3000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",