@leg3ndy/otto-bridge 0.9.1 → 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,7 +15,7 @@ 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.1`, com hotfixes de stream/renderizacao e completude das filesystem tools, veja [`leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_1_PATCH.md`](../leg3ndy-ai-backend/docs/OTTO_BRIDGE_0_9_1_PATCH.md).
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
19
 
20
20
  ## Distribuicao
21
21
 
@@ -37,14 +37,14 @@ Enquanto o pacote nao estiver publicado, voce pode gerar um tarball local:
37
37
 
38
38
  ```bash
39
39
  npm pack
40
- npm install -g ./leg3ndy-otto-bridge-0.9.1.tgz
40
+ npm install -g ./leg3ndy-otto-bridge-0.9.2.tgz
41
41
  ```
42
42
 
43
- Na linha `0.9.1`, `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.
44
44
 
45
- No macOS, a linha `0.9.1` 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`.
46
46
 
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.1` consolida esse caminho com hotfixes de stream visivel, planner LLM-first e leitura/listagem local sem truncamento silencioso.
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.
48
48
 
49
49
  ## Publicacao
50
50
 
@@ -114,7 +114,7 @@ otto-bridge run --executor clawd-cursor --clawd-url http://127.0.0.1:3847
114
114
 
115
115
  ### WhatsApp Web em background
116
116
 
117
- Fluxo recomendado na linha `0.9.1`:
117
+ Fluxo recomendado na linha `0.9.2`:
118
118
 
119
119
  ```bash
120
120
  otto-bridge extensions --install whatsappweb
@@ -124,13 +124,13 @@ otto-bridge extensions --status whatsappweb
124
124
 
125
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.
126
126
 
127
- Contrato da linha `0.9.1`:
127
+ Contrato da linha `0.9.2`:
128
128
 
129
129
  - `otto-bridge extensions --setup whatsappweb`: autentica a sessao uma vez
130
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
131
131
  - ao parar o `otto-bridge run`: o browser em background e desligado, mas a sessao local fica lembrada para o proximo boot
132
132
 
133
- ## Handoff rapido da linha 0.9.1
133
+ ## Handoff rapido da linha 0.9.2
134
134
 
135
135
  Ja fechado no codigo:
136
136
 
@@ -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,17 @@ 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
+ }
705
716
  function chunkTextForTransport(value, chunkSize) {
706
717
  const normalized = String(value || "");
707
718
  if (!normalized) {
@@ -1009,6 +1020,30 @@ function parseStructuredActions(job) {
1009
1020
  }
1010
1021
  continue;
1011
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
+ }
1012
1047
  if (type === "list_files" || type === "ls") {
1013
1048
  const filePath = asString(action.path) || "~";
1014
1049
  const limit = typeof action.limit === "number" ? Math.max(1, Math.min(5_000, action.limit)) : undefined;
@@ -1221,6 +1256,7 @@ export class NativeMacOSJobExecutor {
1221
1256
  lastActiveApp = null;
1222
1257
  lastVisualTargetDescription = null;
1223
1258
  lastVisualTargetApp = null;
1259
+ lastReadFrontmostPage = null;
1224
1260
  lastSatisfiedSpotifyDescription = null;
1225
1261
  lastSatisfiedSpotifyConfirmedPlaying = false;
1226
1262
  lastSatisfiedSpotifyAt = 0;
@@ -1359,6 +1395,10 @@ export class NativeMacOSJobExecutor {
1359
1395
  if (action.type === "read_frontmost_page") {
1360
1396
  await reporter.progress(progressPercent, `Lendo a pagina ativa em ${action.app || "Safari"}`);
1361
1397
  const page = await this.readFrontmostPage(action.app || "Safari");
1398
+ this.lastReadFrontmostPage = {
1399
+ app: action.app || "Safari",
1400
+ ...page,
1401
+ };
1362
1402
  if (!page.text && this.bridgeConfig?.apiBaseUrl && this.bridgeConfig?.deviceToken) {
1363
1403
  await reporter.progress(progressPercent, "Safari bloqueou leitura direta; vou analisar a pagina pela tela");
1364
1404
  const screenshotPath = await this.takeScreenshot();
@@ -1424,6 +1464,27 @@ export class NativeMacOSJobExecutor {
1424
1464
  completionNotes.push(fileContent.summary);
1425
1465
  continue;
1426
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);
1486
+ continue;
1487
+ }
1427
1488
  if (action.type === "list_files") {
1428
1489
  await reporter.progress(progressPercent, `Listando arquivos em ${action.path}`);
1429
1490
  const listing = await this.listLocalFilesSnapshot(action.path, action.limit);
@@ -4606,25 +4667,68 @@ return {
4606
4667
  if (targetApp !== "Safari") {
4607
4668
  throw new Error("Leitura de pagina frontmost esta disponivel apenas para Safari no momento.");
4608
4669
  }
4609
- const script = `
4610
- tell application "Safari"
4611
- activate
4612
- if (count of windows) = 0 then error "Safari nao possui janelas abertas."
4613
- delay 1
4614
- 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});})();"
4615
- set pageJson to do JavaScript jsCode in current tab of front window
4616
- end tell
4617
- return pageJson
4618
- `;
4619
4670
  try {
4620
- const { stdout } = await this.runCommandCapture("osascript", ["-e", script]);
4621
- 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 });
4622
4726
  return {
4623
- title: asString(parsed.title) || "",
4624
- url: asString(parsed.url) || "",
4625
- text: asString(parsed.text) || "",
4626
- playerTitle: asString(parsed.playerTitle) || "",
4627
- playerState: asString(parsed.playerState) || "",
4727
+ title: page.title || "",
4728
+ url: page.url || "",
4729
+ text: page.text || "",
4730
+ playerTitle: page.playerTitle || "",
4731
+ playerState: page.playerState || "",
4628
4732
  };
4629
4733
  }
4630
4734
  catch (error) {
@@ -5075,6 +5179,193 @@ if let output = String(data: data, encoding: .utf8) {
5075
5179
  summary: `Li ${filePath} por completo (${content.length} caracteres em ${chunks.length} bloco${chunks.length === 1 ? "" : "s"}).`,
5076
5180
  };
5077
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;
5194
+ }
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;
5368
+ }
5078
5369
  async resolveReadableFilePath(filePath) {
5079
5370
  const resolved = expandUserPath(filePath);
5080
5371
  try {
@@ -5535,6 +5826,12 @@ if let output = String(data: data, encoding: .utf8) {
5535
5826
  if (action.type === "read_file") {
5536
5827
  return `${action.path} foi lido no macOS`;
5537
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
+ }
5538
5835
  if (action.type === "list_files") {
5539
5836
  return `Arquivos listados em ${action.path}`;
5540
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.1";
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.1",
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.",