@leg3ndy/otto-bridge 0.9.0 → 0.9.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/README.md CHANGED
@@ -15,6 +15,8 @@ Para o estado atual da arquitetura, capacidades entregues, limitacoes e roadmap
15
15
 
16
16
  Para o corte de arquitetura do `0.9.0`, veja [`leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_0_RELEASE.md`](../leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_0_RELEASE.md).
17
17
 
18
+ Para o patch atual `0.9.2`, com confirmacao bloqueante correta e exclusao segura via Lixeira, veja [`leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_2_PATCH.md`](../leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_2_PATCH.md).
19
+
18
20
  ## Distribuicao
19
21
 
20
22
  Fluxo recomendado agora:
@@ -35,14 +37,14 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
35
37
 
36
38
  ```bash
37
39
  npm pack
38
- npm install -g ./leg3ndy-otto-bridge-0.9.0.tgz
40
+ npm install -g ./leg3ndy-otto-bridge-0.9.2.tgz
39
41
  ```
40
42
 
41
- No `0.9.0`, `playwright` segue como dependencia obrigatoria no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
43
+ Na linha `0.9.2`, `playwright` segue como dependencia obrigatoria no `otto-bridge`. O primeiro `npm install -g @leg3ndy/otto-bridge` pode demorar mais porque instala o browser persistente usado pelo WhatsApp Web e pelos fluxos web em background do bridge.
42
44
 
43
- No macOS, o `0.9.0` usa o provider `macos-helper`, um helper `WKWebView` sem Dock para o WhatsApp Web. O helper sobe com user-agent de Chrome moderno para evitar o bloqueio do WhatsApp ao detectar Safari/WebKit. O runtime antigo com Chromium/Playwright fica disponivel apenas como override explicito via `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
45
+ No macOS, a linha `0.9.2` usa o provider `macos-helper`, um helper `WKWebView` sem Dock para o WhatsApp Web. O helper sobe com user-agent de Chrome moderno para evitar o bloqueio do WhatsApp ao detectar Safari/WebKit. O runtime antigo com Chromium/Playwright fica disponivel apenas como override explicito via `OTTO_BRIDGE_WHATSAPP_RUNTIME_PROVIDER=embedded-playwright`.
44
46
 
45
- No nivel arquitetural, o `0.9.0` muda o papel do bridge: ele publica tools e resultados estruturados para o Otto, em vez de injetar resposta pronta como caminho principal do chat.
47
+ No nivel arquitetural, o `0.9.0` marcou a mudanca de papel do bridge: ele publica tools e resultados estruturados para o Otto, em vez de injetar resposta pronta como caminho principal do chat. O `0.9.2` consolida esse caminho com confirmacao bloqueante correta, exclusao segura via Lixeira, planner LLM-first e leitura/listagem local sem truncamento silencioso.
46
48
 
47
49
  ## Publicacao
48
50
 
@@ -112,7 +114,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
112
114
 
113
115
  ### WhatsApp Web em background
114
116
 
115
- Fluxo recomendado no `0.9.0`:
117
+ Fluxo recomendado na linha `0.9.2`:
116
118
 
117
119
  ```bash
118
120
  otto-bridge extensions --install whatsappweb
@@ -122,13 +124,13 @@ otto-bridge extensions --status whatsappweb
122
124
 
123
125
  O setup agora abre o login do WhatsApp Web no helper/background browser do proprio bridge. Depois do QR code, o Otto usa a sessao local em background, sem depender de aba visivel no Safari.
124
126
 
125
- Contrato do `0.9.0`:
127
+ Contrato da linha `0.9.2`:
126
128
 
127
129
  - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
128
130
  - `otto-bridge run`: mantem o browser persistente do WhatsApp vivo em background enquanto o runtime estiver ativo, sem depender de uma aba aberta no Safari
129
131
  - ao parar o `otto-bridge run`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
130
132
 
131
- ## Handoff rapido do 0.9.0
133
+ ## Handoff rapido da linha 0.9.2
132
134
 
133
135
  Ja fechado no codigo:
134
136
 
@@ -138,6 +140,9 @@ Ja fechado no codigo:
138
140
  - prompt bridge-aware no chat normal para ajudar o Otto a responder com base no que realmente aconteceu no device
139
141
  - runtime local agora publica `local_tools` para o Otto/backend saberem exatamente o que o device consegue fazer
140
142
  - o caminho principal do bridge usa `summary`/`narration_context` no lugar de resposta automatica pronta
143
+ - `read_file` agora entrega conteudo completo segmentado em `content_chunks` para o Otto
144
+ - `list_files` sem `limit` agora lista o diretorio inteiro, sem fallback silencioso para 40 itens
145
+ - o planner do bridge prioriza o modelo para escolher a tool certa pelo contexto; regex fica como fallback
141
146
 
142
147
  Ainda precisa reteste em campo:
143
148
 
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, readFile, readdir, stat } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
@@ -702,6 +702,46 @@ function clipTextPreview(value, maxLength) {
702
702
  }
703
703
  return `${value.slice(0, maxLength)}\n\n[conteudo truncado: mostrando ${maxLength} de ${value.length} caracteres. Peca um trecho mais especifico se quiser continuar.]`;
704
704
  }
705
+ function sanitizeFileName(value, fallback = "otto-note.txt") {
706
+ const normalized = String(value || "")
707
+ .normalize("NFD")
708
+ .replace(/\p{Diacritic}/gu, "")
709
+ .replace(/[\/\\?%*:|"<>]/g, "-")
710
+ .replace(/\s+/g, " ")
711
+ .trim();
712
+ const collapsed = normalized.replace(/\.+/g, ".").replace(/^\.+/, "").replace(/\.+$/, "");
713
+ const candidate = collapsed || fallback;
714
+ return path.extname(candidate) ? candidate : `${candidate}.txt`;
715
+ }
716
+ function chunkTextForTransport(value, chunkSize) {
717
+ const normalized = String(value || "");
718
+ if (!normalized) {
719
+ return [];
720
+ }
721
+ const safeChunkSize = Math.max(400, Math.min(Math.round(chunkSize || 4000), 32_000));
722
+ const chunks = [];
723
+ let cursor = 0;
724
+ let index = 0;
725
+ while (cursor < normalized.length) {
726
+ let nextCursor = Math.min(normalized.length, cursor + safeChunkSize);
727
+ if (nextCursor < normalized.length) {
728
+ const lastLineBreak = normalized.lastIndexOf("\n", nextCursor);
729
+ if (lastLineBreak > cursor + Math.floor(safeChunkSize * 0.35)) {
730
+ nextCursor = lastLineBreak + 1;
731
+ }
732
+ }
733
+ const text = normalized.slice(cursor, nextCursor);
734
+ chunks.push({
735
+ index,
736
+ start_char: cursor,
737
+ end_char: nextCursor,
738
+ text,
739
+ });
740
+ cursor = nextCursor;
741
+ index += 1;
742
+ }
743
+ return chunks;
744
+ }
705
745
  const TEXTUTIL_READABLE_EXTENSIONS = new Set([
706
746
  ".doc",
707
747
  ".docx",
@@ -975,14 +1015,38 @@ function parseStructuredActions(job) {
975
1015
  if (type === "read_file" || type === "read_local_file") {
976
1016
  const filePath = asString(action.path);
977
1017
  if (filePath) {
978
- const maxChars = typeof action.max_chars === "number" ? Math.max(200, Math.min(12000, action.max_chars)) : undefined;
1018
+ const maxChars = typeof action.max_chars === "number" ? Math.max(400, Math.min(32_000, action.max_chars)) : undefined;
979
1019
  actions.push({ type: "read_file", path: filePath, max_chars: maxChars });
980
1020
  }
981
1021
  continue;
982
1022
  }
1023
+ if (type === "write_text_file" || type === "write_file" || type === "save_text_file" || type === "save_file") {
1024
+ const targetPath = asString(action.path)
1025
+ || asString(action.destination)
1026
+ || asString(action.file_path)
1027
+ || asString(action.target)
1028
+ || asString(action.directory)
1029
+ || asString(action.folder)
1030
+ || (asString(action.filename) ? "~/Desktop" : "");
1031
+ const text = asString(action.text) || asString(action.content) || asString(action.body) || asString(action.value);
1032
+ const source = asString(action.source) || asString(action.input_source);
1033
+ if (targetPath && (text || source)) {
1034
+ actions.push({
1035
+ type: "write_text_file",
1036
+ path: targetPath,
1037
+ text: text || undefined,
1038
+ content: asString(action.content) || undefined,
1039
+ body: asString(action.body) || undefined,
1040
+ source: source || undefined,
1041
+ filename: asString(action.filename) || asString(action.file_name) || asString(action.name) || asString(action.title) || undefined,
1042
+ append: action.append === true,
1043
+ });
1044
+ }
1045
+ continue;
1046
+ }
983
1047
  if (type === "list_files" || type === "ls") {
984
1048
  const filePath = asString(action.path) || "~";
985
- const limit = typeof action.limit === "number" ? Math.max(1, Math.min(200, action.limit)) : undefined;
1049
+ const limit = typeof action.limit === "number" ? Math.max(1, Math.min(5_000, action.limit)) : undefined;
986
1050
  actions.push({ type: "list_files", path: filePath, limit });
987
1051
  continue;
988
1052
  }
@@ -1192,6 +1256,7 @@ export class NativeMacOSJobExecutor {
1192
1256
  lastActiveApp = null;
1193
1257
  lastVisualTargetDescription = null;
1194
1258
  lastVisualTargetApp = null;
1259
+ lastReadFrontmostPage = null;
1195
1260
  lastSatisfiedSpotifyDescription = null;
1196
1261
  lastSatisfiedSpotifyConfirmedPlaying = false;
1197
1262
  lastSatisfiedSpotifyAt = 0;
@@ -1330,6 +1395,10 @@ export class NativeMacOSJobExecutor {
1330
1395
  if (action.type === "read_frontmost_page") {
1331
1396
  await reporter.progress(progressPercent, `Lendo a pagina ativa em ${action.app || "Safari"}`);
1332
1397
  const page = await this.readFrontmostPage(action.app || "Safari");
1398
+ this.lastReadFrontmostPage = {
1399
+ app: action.app || "Safari",
1400
+ ...page,
1401
+ };
1333
1402
  if (!page.text && this.bridgeConfig?.apiBaseUrl && this.bridgeConfig?.deviceToken) {
1334
1403
  await reporter.progress(progressPercent, "Safari bloqueou leitura direta; vou analisar a pagina pela tela");
1335
1404
  const screenshotPath = await this.takeScreenshot();
@@ -1389,14 +1458,39 @@ export class NativeMacOSJobExecutor {
1389
1458
  }
1390
1459
  if (action.type === "read_file") {
1391
1460
  await reporter.progress(progressPercent, `Lendo ${action.path}`);
1392
- const fileContent = await this.readLocalFile(action.path, action.max_chars);
1393
- completionNotes.push(`Conteudo de ${action.path}:\n${fileContent}`);
1461
+ const fileContent = await this.readLocalFileSnapshot(action.path, action.max_chars);
1462
+ resultPayload.read_file = fileContent;
1463
+ resultPayload.summary = fileContent.summary;
1464
+ completionNotes.push(fileContent.summary);
1465
+ continue;
1466
+ }
1467
+ if (action.type === "trash_path") {
1468
+ await reporter.progress(progressPercent, `Movendo ${action.path} para a Lixeira`);
1469
+ const trashed = await this.movePathToTrashSnapshot(action.path);
1470
+ resultPayload.trash_path = trashed;
1471
+ resultPayload.summary = trashed.summary;
1472
+ completionNotes.push(trashed.summary);
1473
+ continue;
1474
+ }
1475
+ if (action.type === "write_text_file") {
1476
+ const targetLabel = action.filename ? `${action.path}/${action.filename}` : action.path;
1477
+ await reporter.progress(progressPercent, `Escrevendo arquivo de texto em ${targetLabel}`);
1478
+ const resolvedContent = this.resolveWriteTextFileContent(action);
1479
+ if (!resolvedContent) {
1480
+ throw new Error("Nenhum texto foi informado para gravar no arquivo local.");
1481
+ }
1482
+ const written = await this.writeTextFileSnapshot(action.path, resolvedContent.content, action.filename, action.append === true, resolvedContent.source);
1483
+ resultPayload.write_text_file = written;
1484
+ resultPayload.summary = written.summary;
1485
+ completionNotes.push(written.summary);
1394
1486
  continue;
1395
1487
  }
1396
1488
  if (action.type === "list_files") {
1397
1489
  await reporter.progress(progressPercent, `Listando arquivos em ${action.path}`);
1398
- const listing = await this.listLocalFiles(action.path, action.limit);
1399
- completionNotes.push(`Arquivos em ${action.path}:\n${listing}`);
1490
+ const listing = await this.listLocalFilesSnapshot(action.path, action.limit);
1491
+ resultPayload.file_listing = listing;
1492
+ resultPayload.summary = listing.summary;
1493
+ completionNotes.push(listing.summary);
1400
1494
  continue;
1401
1495
  }
1402
1496
  if (action.type === "count_files") {
@@ -2364,7 +2458,7 @@ return appNames as text
2364
2458
  snapshot.summary = `A pasta ${targetPath} tem ${entries.length} item${entries.length === 1 ? "" : "s"} e ocupa ${formatBytesCompact(totalSize)}.${childPreview ? ` Itens visiveis agora: ${childPreview}.` : ""}`;
2365
2459
  return snapshot;
2366
2460
  }
2367
- const preview = includePreview ? await this.readLocalFile(resolved, 1200) : undefined;
2461
+ const preview = includePreview ? await this.readLocalFilePreview(resolved, 1200) : undefined;
2368
2462
  const snapshot = {
2369
2463
  captured_at: new Date().toISOString(),
2370
2464
  path: targetPath,
@@ -4573,25 +4667,68 @@ return {
4573
4667
  if (targetApp !== "Safari") {
4574
4668
  throw new Error("Leitura de pagina frontmost esta disponivel apenas para Safari no momento.");
4575
4669
  }
4576
- const script = `
4577
- tell application "Safari"
4578
- activate
4579
- if (count of windows) = 0 then error "Safari nao possui janelas abertas."
4580
- delay 1
4581
- set jsCode to "(function(){const title=document.title||'';const url=location.href||'';const text=((document.body&&document.body.innerText)||'').trim().slice(0,12000);const isYouTubeMusic=location.hostname.includes('music.youtube.com');const isSpotify=location.hostname.includes('open.spotify.com');let playerButton=null;let playerTitle='';let playerState='';if(isYouTubeMusic){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');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))||'';playerState=(playerButton&&((playerButton.getAttribute('title')||playerButton.getAttribute('aria-label')||playerButton.textContent)||'').trim())||'';}else if(isSpotify){const visible=(node)=>{if(!(node instanceof Element))return false;const rect=node.getBoundingClientRect();if(rect.width<4||rect.height<4)return false;const style=window.getComputedStyle(node);if(style.visibility==='hidden'||style.display==='none'||Number(style.opacity||'1')===0)return false;return rect.bottom>=0&&rect.right>=0&&rect.top<=window.innerHeight&&rect.left<=window.innerWidth;};const spotifyTitleCandidates=Array.from(document.querySelectorAll(\"[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']\")).filter((node)=>visible(node)).map((node)=>({node,text:((node&&node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>entry.text).sort((left,right)=>{const leftBottomBias=(left.rect.top>=window.innerHeight*0.72?200:0)+(left.rect.left<=window.innerWidth*0.45?120:0)+left.rect.top;const rightBottomBias=(right.rect.top>=window.innerHeight*0.72?200:0)+(right.rect.left<=window.innerWidth*0.45?120:0)+right.rect.top;return rightBottomBias-leftBottomBias;});playerTitle=(spotifyTitleCandidates[0]&&spotifyTitleCandidates[0].text)||'';playerButton=Array.from(document.querySelectorAll(\"footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]\")).filter((node)=>visible(node)).map((node)=>({node,label:((node.getAttribute('aria-label')||node.getAttribute('title')||node.textContent)||'').trim(),rect:node.getBoundingClientRect()})).filter((entry)=>/play|pause|tocar|pausar|reproduzir/i.test(entry.label)).sort((left,right)=>{const leftScore=(left.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((left.rect.left+left.rect.width/2)-(window.innerWidth/2)));const rightScore=(right.rect.top>=window.innerHeight*0.72?200:0)+Math.max(0,200-Math.abs((right.rect.left+right.rect.width/2)-(window.innerWidth/2)));return rightScore-leftScore;})[0]?.node||null;playerState=(playerButton&&((playerButton.getAttribute('aria-label')||playerButton.getAttribute('title')||playerButton.textContent)||'').trim())||'';}return JSON.stringify({title,url,text,playerTitle,playerState});})();"
4582
- set pageJson to do JavaScript jsCode in current tab of front window
4583
- end tell
4584
- return pageJson
4585
- `;
4586
4670
  try {
4587
- const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
4588
- const parsed = JSON.parse(stdout.trim() || "{}");
4671
+ const page = await this.runSafariJsonScript(`
4672
+ const title = document.title || "";
4673
+ const url = location.href || "";
4674
+ const text = ((document.body && document.body.innerText) || "").trim().slice(0, 12000);
4675
+ const isYouTubeMusic = location.hostname.includes("music.youtube.com");
4676
+ const isSpotify = location.hostname.includes("open.spotify.com");
4677
+ let playerButton = null;
4678
+ let playerTitle = "";
4679
+ let playerState = "";
4680
+
4681
+ if (isYouTubeMusic) {
4682
+ 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");
4683
+ playerTitle = (Array.from(document.querySelectorAll("ytmusic-player-bar .title, ytmusic-player-bar .content-info-wrapper .title, ytmusic-player-bar [slot=title]"))
4684
+ .map((node) => ((node && node.textContent) || "").trim())
4685
+ .find(Boolean)) || "";
4686
+ playerState = (playerButton && ((playerButton.getAttribute("title") || playerButton.getAttribute("aria-label") || playerButton.textContent) || "").trim()) || "";
4687
+ } else if (isSpotify) {
4688
+ const visible = (node) => {
4689
+ if (!(node instanceof Element)) return false;
4690
+ const rect = node.getBoundingClientRect();
4691
+ if (rect.width < 4 || rect.height < 4) return false;
4692
+ const style = window.getComputedStyle(node);
4693
+ if (style.visibility === "hidden" || style.display === "none" || Number(style.opacity || "1") === 0) return false;
4694
+ return rect.bottom >= 0 && rect.right >= 0 && rect.top <= window.innerHeight && rect.left <= window.innerWidth;
4695
+ };
4696
+ const spotifyTitleCandidates = Array.from(document.querySelectorAll("[data-testid='nowplaying-track-link'], footer a[href*='/track/'], [data-testid='now-playing-widget'] a[href*='/track/'], a[href*='/track/']"))
4697
+ .filter((node) => visible(node))
4698
+ .map((node) => ({ node, text: ((node && node.textContent) || "").trim(), rect: node.getBoundingClientRect() }))
4699
+ .filter((entry) => entry.text)
4700
+ .sort((left, right) => {
4701
+ const leftBottomBias = (left.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + (left.rect.left <= window.innerWidth * 0.45 ? 120 : 0) + left.rect.top;
4702
+ const rightBottomBias = (right.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + (right.rect.left <= window.innerWidth * 0.45 ? 120 : 0) + right.rect.top;
4703
+ return rightBottomBias - leftBottomBias;
4704
+ });
4705
+ playerTitle = (spotifyTitleCandidates[0] && spotifyTitleCandidates[0].text) || "";
4706
+ playerButton = Array.from(document.querySelectorAll("footer button, [data-testid='control-button-playpause'], button[aria-label], button[title]"))
4707
+ .filter((node) => visible(node))
4708
+ .map((node) => ({ node, label: ((node.getAttribute("aria-label") || node.getAttribute("title") || node.textContent) || "").trim(), rect: node.getBoundingClientRect() }))
4709
+ .filter((entry) => /play|pause|tocar|pausar|reproduzir/i.test(entry.label))
4710
+ .sort((left, right) => {
4711
+ const leftScore = (left.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + Math.max(0, 200 - Math.abs((left.rect.left + left.rect.width / 2) - (window.innerWidth / 2)));
4712
+ const rightScore = (right.rect.top >= window.innerHeight * 0.72 ? 200 : 0) + Math.max(0, 200 - Math.abs((right.rect.left + right.rect.width / 2) - (window.innerWidth / 2)));
4713
+ return rightScore - leftScore;
4714
+ })[0]?.node || null;
4715
+ playerState = (playerButton && ((playerButton.getAttribute("aria-label") || playerButton.getAttribute("title") || playerButton.textContent) || "").trim()) || "";
4716
+ }
4717
+
4718
+ return {
4719
+ title,
4720
+ url,
4721
+ text,
4722
+ playerTitle,
4723
+ playerState,
4724
+ };
4725
+ `, {}, { activate: true });
4589
4726
  return {
4590
- title: asString(parsed.title) || "",
4591
- url: asString(parsed.url) || "",
4592
- text: asString(parsed.text) || "",
4593
- playerTitle: asString(parsed.playerTitle) || "",
4594
- playerState: asString(parsed.playerState) || "",
4727
+ title: page.title || "",
4728
+ url: page.url || "",
4729
+ text: page.text || "",
4730
+ playerTitle: page.playerTitle || "",
4731
+ playerState: page.playerState || "",
4595
4732
  };
4596
4733
  }
4597
4734
  catch (error) {
@@ -4961,9 +5098,9 @@ if let output = String(data: data, encoding: .utf8) {
4961
5098
  resized,
4962
5099
  };
4963
5100
  }
4964
- async readLocalFile(filePath, maxChars = 4000) {
4965
- const resolved = await this.resolveReadableFilePath(filePath);
5101
+ async loadReadableFileContent(resolved) {
4966
5102
  const extension = path.extname(resolved).toLowerCase();
5103
+ const mimeType = mimeTypeFromPath(resolved);
4967
5104
  if (TEXTUTIL_READABLE_EXTENSIONS.has(extension)) {
4968
5105
  const { stdout } = await this.runCommandCapture("textutil", [
4969
5106
  "-convert",
@@ -4971,17 +5108,263 @@ if let output = String(data: data, encoding: .utf8) {
4971
5108
  "-stdout",
4972
5109
  resolved,
4973
5110
  ]);
4974
- const content = sanitizeTextForJsonTransport(stdout);
4975
- return clipTextPreview(content || "(arquivo sem texto legivel)", maxChars);
5111
+ return {
5112
+ mimeType,
5113
+ encoding: "utf-8",
5114
+ isBinary: false,
5115
+ content: sanitizeTextForJsonTransport(stdout) || "(arquivo sem texto legivel)",
5116
+ };
4976
5117
  }
4977
5118
  const raw = await readFile(resolved);
4978
5119
  if (isLikelyBinaryBuffer(raw)) {
4979
5120
  const filename = path.basename(resolved);
4980
5121
  const detectedType = extension || "binario";
4981
- return clipText(`O arquivo ${filename} parece ser binario (${detectedType}) e nao pode ser lido como texto puro pelo Otto Bridge ainda.`, maxChars);
5122
+ return {
5123
+ mimeType,
5124
+ isBinary: true,
5125
+ binaryNotice: `O arquivo ${filename} parece ser binario (${detectedType}) e nao pode ser lido como texto puro pelo Otto Bridge ainda.`,
5126
+ };
5127
+ }
5128
+ return {
5129
+ mimeType,
5130
+ encoding: "utf-8",
5131
+ isBinary: false,
5132
+ content: sanitizeTextForJsonTransport(raw.toString("utf8")) || "(arquivo vazio)",
5133
+ };
5134
+ }
5135
+ async readLocalFilePreview(filePath, maxChars = 1200) {
5136
+ const resolved = await this.resolveReadableFilePath(filePath);
5137
+ const loaded = await this.loadReadableFileContent(resolved);
5138
+ if (loaded.isBinary) {
5139
+ return clipText(loaded.binaryNotice || "O arquivo parece ser binario e nao pode ser lido como texto puro.", maxChars);
5140
+ }
5141
+ return clipTextPreview(loaded.content || "(arquivo vazio)", maxChars);
5142
+ }
5143
+ async readLocalFileSnapshot(filePath, chunkSizeChars = 4000) {
5144
+ const resolved = await this.resolveReadableFilePath(filePath);
5145
+ const entryStat = await stat(resolved);
5146
+ const loaded = await this.loadReadableFileContent(resolved);
5147
+ const fileName = path.basename(resolved) || resolved;
5148
+ if (loaded.isBinary) {
5149
+ return {
5150
+ captured_at: new Date().toISOString(),
5151
+ path: filePath,
5152
+ resolved_path: resolved,
5153
+ name: fileName,
5154
+ mime_type: loaded.mimeType,
5155
+ size_bytes: entryStat.size,
5156
+ modified_at: entryStat.mtime.toISOString(),
5157
+ is_binary: true,
5158
+ binary_notice: loaded.binaryNotice,
5159
+ summary: loaded.binaryNotice || `O arquivo ${filePath} parece ser binario e nao pode ser lido como texto puro.`,
5160
+ };
5161
+ }
5162
+ const content = loaded.content || "(arquivo vazio)";
5163
+ const chunks = chunkTextForTransport(content, chunkSizeChars);
5164
+ return {
5165
+ captured_at: new Date().toISOString(),
5166
+ path: filePath,
5167
+ resolved_path: resolved,
5168
+ name: fileName,
5169
+ mime_type: loaded.mimeType,
5170
+ size_bytes: entryStat.size,
5171
+ modified_at: entryStat.mtime.toISOString(),
5172
+ is_binary: false,
5173
+ encoding: loaded.encoding || "utf-8",
5174
+ content: chunks.length <= 1 ? content : undefined,
5175
+ content_char_count: content.length,
5176
+ chunk_size_chars: Math.max(400, Math.min(Math.round(chunkSizeChars || 4000), 32_000)),
5177
+ chunk_count: chunks.length,
5178
+ chunks,
5179
+ summary: `Li ${filePath} por completo (${content.length} caracteres em ${chunks.length} bloco${chunks.length === 1 ? "" : "s"}).`,
5180
+ };
5181
+ }
5182
+ async resolveTrashTargetPath(targetPath) {
5183
+ const resolved = expandUserPath(targetPath);
5184
+ try {
5185
+ await stat(resolved);
5186
+ return resolved;
5187
+ }
5188
+ catch {
5189
+ // Continue into heuristic search below.
5190
+ }
5191
+ const targetName = path.basename(resolved).trim();
5192
+ if (!targetName || targetName === "." || targetName === path.sep) {
5193
+ return resolved;
4982
5194
  }
4983
- const content = sanitizeTextForJsonTransport(raw.toString("utf8"));
4984
- return clipTextPreview(content || "(arquivo vazio)", maxChars);
5195
+ const homeDir = os.homedir();
5196
+ const requestedDir = path.dirname(resolved);
5197
+ const preferredRoots = uniqueStrings([
5198
+ requestedDir && requestedDir !== homeDir ? requestedDir : null,
5199
+ path.join(homeDir, "Downloads"),
5200
+ path.join(homeDir, "Desktop"),
5201
+ path.join(homeDir, "Documents"),
5202
+ homeDir,
5203
+ ]);
5204
+ const found = await this.findPathByName(targetName, preferredRoots);
5205
+ return found || resolved;
5206
+ }
5207
+ async findPathByName(targetName, roots) {
5208
+ const normalizedTarget = normalizeText(targetName).replace(/\s+/g, " ").trim();
5209
+ if (!normalizedTarget) {
5210
+ return null;
5211
+ }
5212
+ for (const root of roots) {
5213
+ let rootStat;
5214
+ try {
5215
+ rootStat = await stat(root);
5216
+ }
5217
+ catch {
5218
+ continue;
5219
+ }
5220
+ if (!rootStat.isDirectory()) {
5221
+ continue;
5222
+ }
5223
+ const queue = [root];
5224
+ while (queue.length > 0) {
5225
+ const current = queue.shift();
5226
+ if (!current)
5227
+ continue;
5228
+ let entries;
5229
+ try {
5230
+ entries = await readdir(current, { withFileTypes: true });
5231
+ }
5232
+ catch {
5233
+ continue;
5234
+ }
5235
+ for (const entry of entries) {
5236
+ const entryPath = path.join(current, entry.name);
5237
+ const normalizedEntryName = normalizeText(entry.name).replace(/\s+/g, " ").trim();
5238
+ if (normalizedEntryName === normalizedTarget) {
5239
+ return entryPath;
5240
+ }
5241
+ if (entry.isDirectory()) {
5242
+ if (!FILE_SEARCH_SKIP_DIRS.has(entry.name)) {
5243
+ queue.push(entryPath);
5244
+ }
5245
+ continue;
5246
+ }
5247
+ if (entry.isFile() && entry.name.toLowerCase() === targetName.toLowerCase()) {
5248
+ return entryPath;
5249
+ }
5250
+ }
5251
+ }
5252
+ }
5253
+ return null;
5254
+ }
5255
+ async resolveUniqueTrashDestination(targetPath) {
5256
+ const ext = path.extname(targetPath);
5257
+ const stem = ext ? targetPath.slice(0, -ext.length) : targetPath;
5258
+ let attempt = 0;
5259
+ let candidate = targetPath;
5260
+ while (true) {
5261
+ try {
5262
+ await stat(candidate);
5263
+ attempt += 1;
5264
+ candidate = `${stem} ${attempt + 1}${ext}`;
5265
+ }
5266
+ catch {
5267
+ return candidate;
5268
+ }
5269
+ }
5270
+ }
5271
+ async movePathToTrashSnapshot(targetPath) {
5272
+ const resolved = await this.resolveTrashTargetPath(targetPath);
5273
+ const entryStat = await stat(resolved);
5274
+ const trashDir = path.join(os.homedir(), ".Trash");
5275
+ await mkdir(trashDir, { recursive: true });
5276
+ const name = path.basename(resolved) || resolved;
5277
+ const trashedPath = await this.resolveUniqueTrashDestination(path.join(trashDir, name));
5278
+ await rename(resolved, trashedPath);
5279
+ const kind = entryStat.isDirectory()
5280
+ ? "directory"
5281
+ : entryStat.isFile()
5282
+ ? "file"
5283
+ : "other";
5284
+ return {
5285
+ captured_at: new Date().toISOString(),
5286
+ path: targetPath,
5287
+ resolved_path: resolved,
5288
+ trashed_path: trashedPath,
5289
+ name,
5290
+ kind,
5291
+ size_bytes: entryStat.size,
5292
+ modified_at: entryStat.mtime.toISOString(),
5293
+ summary: `${kind === "directory" ? "Mandei a pasta" : kind === "file" ? "Mandei o arquivo" : "Mandei o item"} ${name} para a Lixeira.`,
5294
+ };
5295
+ }
5296
+ async resolveWritableTextFilePath(targetPath, filename) {
5297
+ const expanded = expandUserPath(targetPath);
5298
+ const requestedFilename = filename ? sanitizeFileName(filename) : null;
5299
+ if (requestedFilename) {
5300
+ try {
5301
+ const existingStat = await stat(expanded);
5302
+ if (existingStat.isDirectory()) {
5303
+ return path.join(expanded, requestedFilename);
5304
+ }
5305
+ }
5306
+ catch {
5307
+ // Continue below and treat the target as a direct file path.
5308
+ }
5309
+ if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
5310
+ return path.join(expanded, requestedFilename);
5311
+ }
5312
+ }
5313
+ try {
5314
+ const existingStat = await stat(expanded);
5315
+ if (existingStat.isDirectory()) {
5316
+ return path.join(expanded, sanitizeFileName("otto-note.txt"));
5317
+ }
5318
+ }
5319
+ catch {
5320
+ // Continue below and treat the target as a direct file path.
5321
+ }
5322
+ if (String(targetPath || "").trimEnd().endsWith(path.sep)) {
5323
+ return path.join(expanded, sanitizeFileName("otto-note.txt"));
5324
+ }
5325
+ return expanded;
5326
+ }
5327
+ async writeTextFileSnapshot(targetPath, text, filename, append = false, source) {
5328
+ const resolved = await this.resolveWritableTextFilePath(targetPath, filename);
5329
+ const parentDir = path.dirname(resolved);
5330
+ await mkdir(parentDir, { recursive: true });
5331
+ await writeFile(resolved, text, {
5332
+ encoding: "utf8",
5333
+ flag: append ? "a" : "w",
5334
+ });
5335
+ const entryStat = await stat(resolved);
5336
+ const name = path.basename(resolved) || resolved;
5337
+ const preview = clipTextPreview(String(text || "") || "(arquivo vazio)", 240);
5338
+ return {
5339
+ captured_at: new Date().toISOString(),
5340
+ path: targetPath,
5341
+ resolved_path: resolved,
5342
+ name,
5343
+ mime_type: "text/plain; charset=utf-8",
5344
+ size_bytes: entryStat.size,
5345
+ modified_at: entryStat.mtime.toISOString(),
5346
+ append,
5347
+ content_char_count: String(text || "").length,
5348
+ content_preview: preview,
5349
+ source: source || undefined,
5350
+ summary: `${append ? "Atualizei" : "Escrevi"} ${String(text || "").length} caractere${String(text || "").length === 1 ? "" : "s"} em ${resolved}.`,
5351
+ };
5352
+ }
5353
+ resolveWriteTextFileContent(action) {
5354
+ const explicitText = [action.text, action.content, action.body]
5355
+ .map((value) => String(value || "").trim())
5356
+ .find(Boolean);
5357
+ if (explicitText) {
5358
+ return { content: explicitText };
5359
+ }
5360
+ const source = String(action.source || "").trim().toLowerCase();
5361
+ if (source === "last_page_text" || source === "last_page_summary") {
5362
+ const pageText = String(this.lastReadFrontmostPage?.text || "").trim();
5363
+ if (pageText) {
5364
+ return { content: pageText, source };
5365
+ }
5366
+ }
5367
+ return null;
4985
5368
  }
4986
5369
  async resolveReadableFilePath(filePath) {
4987
5370
  const resolved = expandUserPath(filePath);
@@ -5052,22 +5435,55 @@ if let output = String(data: data, encoding: .utf8) {
5052
5435
  }
5053
5436
  return null;
5054
5437
  }
5055
- async listLocalFiles(directoryPath, limit = 40) {
5438
+ async listLocalFilesSnapshot(directoryPath, limit) {
5056
5439
  const resolved = expandUserPath(directoryPath);
5057
- const entries = await readdir(resolved, { withFileTypes: true });
5058
- const items = await Promise.all(entries.slice(0, limit).map(async (entry) => {
5440
+ const allEntries = await readdir(resolved, { withFileTypes: true });
5441
+ const sortedEntries = allEntries.sort((left, right) => {
5442
+ if (left.isDirectory() !== right.isDirectory()) {
5443
+ return left.isDirectory() ? -1 : 1;
5444
+ }
5445
+ return left.name.localeCompare(right.name);
5446
+ });
5447
+ const effectiveLimit = typeof limit === "number" && Number.isFinite(limit) ? Math.max(1, Math.min(Math.round(limit), 5_000)) : null;
5448
+ const selectedEntries = effectiveLimit ? sortedEntries.slice(0, effectiveLimit) : sortedEntries;
5449
+ const items = await Promise.all(selectedEntries.map(async (entry) => {
5059
5450
  const entryPath = path.join(resolved, entry.name);
5060
- let suffix = "";
5451
+ let sizeBytes;
5452
+ let modifiedAt;
5061
5453
  try {
5062
5454
  const entryStat = await stat(entryPath);
5063
- suffix = entry.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
5455
+ if (!entry.isDirectory()) {
5456
+ sizeBytes = entryStat.size;
5457
+ }
5458
+ modifiedAt = entryStat.mtime.toISOString();
5064
5459
  }
5065
5460
  catch {
5066
- suffix = entry.isDirectory() ? "/" : "";
5461
+ // Ignore stat failures and return the visible entry metadata we have.
5067
5462
  }
5068
- return `${entry.name}${suffix}`;
5463
+ return {
5464
+ name: entry.name,
5465
+ path: entryPath,
5466
+ kind: entry.isDirectory() ? "directory" : (entry.isFile() ? "file" : "other"),
5467
+ size_bytes: sizeBytes,
5468
+ modified_at: modifiedAt,
5469
+ };
5069
5470
  }));
5070
- return items.length > 0 ? items.join("\n") : "(pasta vazia)";
5471
+ const summary = items.length === 0
5472
+ ? `A pasta ${directoryPath} esta vazia.`
5473
+ : effectiveLimit && allEntries.length > items.length
5474
+ ? `Listei ${items.length} itens visiveis em ${directoryPath} agora. A pasta tem ${allEntries.length} itens no total.`
5475
+ : `Listei ${items.length} itens de ${directoryPath} por completo.`;
5476
+ return {
5477
+ captured_at: new Date().toISOString(),
5478
+ path: directoryPath,
5479
+ resolved_path: resolved,
5480
+ name: path.basename(resolved) || resolved,
5481
+ item_count: items.length,
5482
+ total_item_count: allEntries.length,
5483
+ limit_applied: effectiveLimit || undefined,
5484
+ entries: items,
5485
+ summary,
5486
+ };
5071
5487
  }
5072
5488
  async countLocalFiles(directoryPath, extensions, recursive = true) {
5073
5489
  const resolved = expandUserPath(directoryPath);
@@ -5410,6 +5826,12 @@ if let output = String(data: data, encoding: .utf8) {
5410
5826
  if (action.type === "read_file") {
5411
5827
  return `${action.path} foi lido no macOS`;
5412
5828
  }
5829
+ if (action.type === "trash_path") {
5830
+ return `${action.path} foi movido para a Lixeira`;
5831
+ }
5832
+ if (action.type === "write_text_file") {
5833
+ return `Arquivo de texto escrito em ${action.filename ? `${action.path}/${action.filename}` : action.path}`;
5834
+ }
5413
5835
  if (action.type === "list_files") {
5414
5836
  return `Arquivos listados em ${action.path}`;
5415
5837
  }
@@ -65,6 +65,23 @@ const NATIVE_MACOS_BASE_TOOLS = [
65
65
  mode: "observe",
66
66
  action_types: ["read_file"],
67
67
  },
68
+ {
69
+ id: "filesystem.write",
70
+ title: "Escrita de arquivo de texto",
71
+ description: "Cria ou atualiza arquivos .txt em caminhos locais, incluindo Desktop e outras pastas do usuario.",
72
+ category: "filesystem",
73
+ mode: "write",
74
+ action_types: ["write_text_file"],
75
+ },
76
+ {
77
+ id: "filesystem.trash",
78
+ title: "Mover item para a Lixeira",
79
+ description: "Move arquivos e pastas locais para a Lixeira do macOS com confirmação.",
80
+ category: "filesystem",
81
+ mode: "write",
82
+ action_types: ["trash_path"],
83
+ requires_confirmation: true,
84
+ },
68
85
  {
69
86
  id: "system.health",
70
87
  title: "Status do Mac",
package/dist/types.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const BRIDGE_CONFIG_VERSION = 1;
2
- export const BRIDGE_VERSION = "0.9.0";
2
+ export const BRIDGE_VERSION = "0.9.2";
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;
@@ -10,4 +10,4 @@ export const DEFAULT_RECONNECT_MAX_DELAY_MS = 15000;
10
10
  export const DEFAULT_EXECUTOR_TYPE = "mock";
11
11
  export const DEFAULT_CLAWD_CURSOR_BASE_URL = "http://127.0.0.1:3847";
12
12
  export const DEFAULT_CLAWD_CURSOR_POLL_INTERVAL_MS = 1500;
13
- export const BRIDGE_LOCAL_TOOLS_VERSION = 1;
13
+ export const BRIDGE_LOCAL_TOOLS_VERSION = 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leg3ndy/otto-bridge",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Local companion for Otto Bridge device pairing and WebSocket runtime.",