synabun 2.0.5 → 2.0.7

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.
Files changed (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
- import { CONN, ERR, LIMITS } from './protocol.js';
10
+ import { CONN, CONN_WARNINGS, ERR, LIMITS, VOICE_MARKER } from './protocol.js';
11
11
  import { parseJid, sameAccount } from './identity.js';
12
12
  import { redactWa } from './redact.js';
13
13
 
@@ -18,7 +18,7 @@ export const RUNTIME_EXPORTS = Object.freeze([
18
18
  ]);
19
19
 
20
20
  const QUOTED_MAX = 1000;
21
- const DOWNLOAD_TIMEOUT_MS = 30_000;
21
+ export const DOWNLOAD_TIMEOUT_MS = 30_000;
22
22
  const VERSION_RECHECK_MS = 6 * 3600_000;
23
23
 
24
24
  function adapterError(code, message, cause) {
@@ -235,68 +235,103 @@ export function disconnectCode(err) {
235
235
  return null;
236
236
  }
237
237
 
238
+ // Baileys 7.0.0-rc14 (the pinned connector) closes with 500 for more than a
239
+ // bad session: 500 is DisconnectReason.badSession, but it is also the code
240
+ // getErrorCodeFromStreamError (Utils/generics.js) gives any <stream:error>
241
+ // without a code of its own ("Stream Errored (<first child tag>)", the child
242
+ // node in `data`), the code getCodeFromWSError gives a WebSocket error it
243
+ // cannot place ("WebSocket Error (…)"), and the default of a <failure> without
244
+ // a reason ("Connection Failure"). Baileys itself never names a bad session:
245
+ // only WhatsApp's own stream-error child tag can, and only that wipes the
246
+ // saved credentials. Every other 500 is transport noise and is retried.
247
+ const STREAM_ERROR = /^Stream Errored \(/;
248
+ const INVALID_SESSION_TAG = /\b(?:bad[-_ ]?session|not[-_ ]?authori[sz]ed|invalid[-_ ]?session)\b/i;
249
+
250
+ /** WhatsApp said the saved session is invalid: a stream error whose reason tag names it (`<not-authorized/>`). */
251
+ export function invalidSessionSignal(err) {
252
+ const message = typeof err?.message === 'string' ? err.message : '';
253
+ if (!STREAM_ERROR.test(message)) return false;
254
+ const tag = typeof err?.data?.tag === 'string' ? err.data.tag : '';
255
+ return INVALID_SESSION_TAG.test(`${message} ${tag}`);
256
+ }
257
+
258
+ /** Which kind of generic 500 this is (the reason a log line and lastDisconnect carry). */
259
+ function transportReason(err) {
260
+ const message = typeof err?.message === 'string' ? err.message : '';
261
+ if (STREAM_ERROR.test(message)) return 'stream_error';
262
+ if (/^WebSocket Error \(/.test(message)) return 'websocket_error';
263
+ if (/^Connection Failure\b/.test(message)) return 'connection_failure';
264
+ return 'status_500';
265
+ }
266
+
238
267
  /**
239
268
  * Map a lastDisconnect error to what the host does next, by DisconnectReason
240
269
  * NAME (408 is both connectionLost and timedOut; the enum's reverse map says
241
- * timedOut).
242
- * @returns {{action:'reconnect_now'|'refresh_version'|'backoff'|'stop', state:string, wipe:boolean, reason:string, code:number|null}}
270
+ * timedOut). A saved session never gives up on its own: only 401 (logged out)
271
+ * and an explicitly invalid session stop it, and both wipe the credentials.
272
+ * 440 and 403 retry from the longest delay (`delay: 'long'`) and carry a
273
+ * `warning` the service shows (CONN_WARNINGS). While linking (no saved
274
+ * session) anything but 515, 405, 401 and 403 means the QR or code ran out.
275
+ * @returns {{action:'reconnect_now'|'refresh_version'|'backoff'|'stop', state:string, wipe:boolean, reason:string, code:number|null,
276
+ * delay?:'long', warning?:'replaced'|'forbidden'}}
243
277
  */
244
278
  export function classifyDisconnect(err, { linking = false, DisconnectReason = {} } = {}) {
245
279
  const code = disconnectCode(err);
246
280
  const name = code !== null && typeof DisconnectReason[code] === 'string' ? DisconnectReason[code] : null;
247
- const out = (action, state, wipe, reason) => ({ action, state, wipe, reason, code });
281
+ const out = (action, state, wipe, reason, extra = null) => ({ action, state, wipe, reason, code, ...(extra || {}) });
282
+ // 500: an explicit bad session, or noise (a generic stream / WebSocket / failure error).
283
+ const reason = name === 'badSession' ? (invalidSessionSignal(err) ? 'badSession' : transportReason(err)) : name;
248
284
  if (code === 405) return out('refresh_version', linking ? CONN.CONNECTING : CONN.RECONNECTING, false, 'outdated_version');
249
- switch (name) {
250
- case 'restartRequired':
251
- return out('reconnect_now', CONN.CONNECTING, false, name);
252
- case 'loggedOut':
253
- return out('stop', CONN.LOGGED_OUT, true, name);
254
- case 'connectionReplaced':
255
- return out('stop', CONN.REPLACED, false, name);
256
- case 'forbidden':
257
- return out('stop', CONN.FORBIDDEN, false, name);
258
- case 'badSession':
259
- case 'multideviceMismatch':
260
- return out('stop', CONN.ERROR, false, name);
261
- default:
262
- // timedOut / connectionLost / connectionClosed / unavailableService /
263
- // anything unknown. While linking that means the QR or code ran out:
264
- // stop instead of looping through fresh QR codes.
265
- if (linking) return out('stop', CONN.LINK_EXPIRED, true, name || 'link_failed');
266
- return out('backoff', CONN.RECONNECTING, false, name || (code === null ? 'closed' : `status_${code}`));
285
+ if (name === 'restartRequired') return out('reconnect_now', CONN.CONNECTING, false, name);
286
+ if (name === 'loggedOut') return out('stop', CONN.LOGGED_OUT, true, name);
287
+ if (linking) {
288
+ if (name === 'forbidden') return out('stop', CONN.FORBIDDEN, false, name);
289
+ // A timeout or a closed stream means the QR / code ran out: stop instead of looping through fresh QR codes.
290
+ return out('stop', CONN.LINK_EXPIRED, true, reason || 'link_failed');
291
+ }
292
+ if (reason === 'badSession') return out('stop', CONN.LOGGED_OUT, true, reason);
293
+ const warning = CONN_WARNINGS[code];
294
+ if (warning) {
295
+ // Two copies of one session would kick each other every few seconds, and a refused account is not hammered.
296
+ return out('backoff', CONN.RECONNECTING, false, name || `status_${code}`, { delay: 'long', warning });
267
297
  }
298
+ // timedOut / connectionLost / connectionClosed / unavailableService / multideviceMismatch /
299
+ // a generic 500 / anything unknown: keep the credentials and try again.
300
+ return out('backoff', CONN.RECONNECTING, false, reason || (code === null ? 'closed' : `status_${code}`));
268
301
  }
269
302
 
270
303
  /**
271
- * Reconnect delays 2/5/15/30/60/120/300 s ±20%; the ladder resets after 10
272
- * minutes connected; more than 10 reconnects inside 10 minutes gives up.
304
+ * Reconnect delays 2/4/8/16/32/64/128/256/300 s, each ±20% and never above
305
+ * 300 s; the ladder starts over after 10 minutes connected (noteStable). It
306
+ * never gives up. `next({ long: true })` (440, 403) goes to the top at once and
307
+ * stays there until that stable connection: after a session another copy took
308
+ * or an account WhatsApp refused, any drop waits the long delay (a refused
309
+ * account may fail in other ways too, and two copies must not trade quick
310
+ * retries), the same 10 minutes that end the warning Settings shows.
273
311
  */
274
312
  export function createReconnectPolicy({
275
- now = () => Date.now(),
276
313
  rng = Math.random,
277
- scheduleSec = [2, 5, 15, 30, 60, 120, 300],
314
+ scheduleSec = [2, 4, 8, 16, 32, 64, 128, 256, 300],
278
315
  jitter = 0.2,
279
- limit = 10,
280
- windowMs = 10 * 60_000,
316
+ maxMs = 300_000,
281
317
  } = {}) {
282
318
  let idx = 0;
283
- let attempts = [];
319
+ let attempts = 0;
284
320
  return {
285
- /** @returns {{delayMs:number} | {giveUp:true}} */
286
- next() {
287
- const t = now();
288
- attempts = attempts.filter((a) => t - a < windowMs);
289
- if (attempts.length >= limit) return { giveUp: true };
290
- attempts.push(t);
321
+ /** @returns {{delayMs:number}} */
322
+ next({ long = false } = {}) {
323
+ if (long) idx = Math.max(idx, scheduleSec.length - 1);
291
324
  const base = scheduleSec[Math.min(idx, scheduleSec.length - 1)] * 1000;
292
325
  idx += 1;
326
+ attempts += 1;
293
327
  const factor = 1 + (rng() * 2 - 1) * jitter;
294
- return { delayMs: Math.max(0, Math.round(base * factor)) };
328
+ return { delayMs: Math.min(maxMs, Math.max(0, Math.round(base * factor))) };
295
329
  },
296
330
  /** Connected long enough: start the ladder over. */
297
331
  noteStable() { idx = 0; },
298
- reset() { idx = 0; attempts = []; },
299
- get attempts() { return attempts.length; },
332
+ reset() { idx = 0; attempts = 0; },
333
+ /** Retries scheduled since the last reset (an explicit connect). */
334
+ get attempts() { return attempts; },
300
335
  };
301
336
  }
302
337
 
@@ -323,8 +358,8 @@ const DROP_TYPES = Object.freeze({
323
358
  stickerPackMessage: 'sticker',
324
359
  });
325
360
 
361
+ // audioMessage (voice notes and audio files) is kind 'audio': transcribed on this computer (transcription.js).
326
362
  const UNSUPPORTED = Object.freeze({
327
- audioMessage: 'audio',
328
363
  videoMessage: 'video',
329
364
  ptvMessage: 'video',
330
365
  documentMessage: 'document',
@@ -347,7 +382,7 @@ function captionOf(inner) {
347
382
 
348
383
  /**
349
384
  * Classify the content of one message without downloading anything.
350
- * @returns {{drop:string} | {type:string, kind:'text'|'image'|'unsupported', inner:object|null,
385
+ * @returns {{drop:string} | {type:string, kind:'text'|'image'|'audio'|'unsupported', inner:object|null,
351
386
  * contextInfo:object|null, text:string, unsupportedType:string|null}}
352
387
  */
353
388
  export function describeContent(msg, mod) {
@@ -370,6 +405,9 @@ export function describeContent(msg, mod) {
370
405
  if (type === 'imageMessage') {
371
406
  return { type, kind: 'image', inner, contextInfo, text: captionOf(inner), unsupportedType: null };
372
407
  }
408
+ if (type === 'audioMessage') {
409
+ return { type, kind: 'audio', inner, contextInfo, text: captionOf(inner), unsupportedType: null };
410
+ }
373
411
  return { type, kind: 'unsupported', inner, contextInfo, text: captionOf(inner), unsupportedType: UNSUPPORTED[type] || 'other' };
374
412
  }
375
413
 
@@ -401,13 +439,65 @@ function withTimeout(promise, ms, label) {
401
439
 
402
440
  const MSG_ID_RE = /^[A-Za-z0-9._:+/=-]{1,128}$/;
403
441
 
442
+ /** A size or duration a message declares about itself (a number, a protobuf Long, a string), or null when it says none. */
443
+ function declared(value) {
444
+ if (value === null || value === undefined || value === '') return null;
445
+ let n = NaN;
446
+ if (typeof value === 'number') n = value;
447
+ else if (typeof value === 'bigint' || typeof value === 'string') n = Number(value);
448
+ else if (typeof value?.toNumber === 'function') { try { n = value.toNumber(); } catch { n = NaN; } }
449
+ return Number.isFinite(n) && n >= 0 ? n : null;
450
+ }
451
+
452
+ /**
453
+ * A voice note or audio file (kind 'audio'), only for a message host-core already accepted:
454
+ * → { transcript, recorded } or { unsupported: { type: 'audio', reason } }. `recorded`: a voice note
455
+ * the sender recorded in WhatsApp (ptt), not an audio file attached to the chat. Nothing is downloaded while
456
+ * transcription is off or cannot run here, or when the message says it is over a limit.
457
+ * transcriber transcription.js createTranscriber() (enabled, available(), transcribe())
458
+ * stale sent so long ago that the bridge will skip it anyway: not worth transcribing
459
+ */
460
+ async function readAudio(msg, { mod, sock, logger, inner, transcriber, stale }) {
461
+ const refuse = (reason) => ({ unsupported: { type: 'audio', reason } });
462
+ if (!transcriber || !transcriber.enabled) return refuse('transcription_disabled');
463
+ let ready = false;
464
+ try { ready = transcriber.available() === true; } catch { ready = false; }
465
+ if (!ready) return refuse('transcription_unavailable');
466
+ if ((declared(inner?.fileLength) ?? 0) > LIMITS.MAX_AUDIO_BYTES) return refuse('too_large');
467
+ if ((declared(inner?.seconds) ?? 0) > LIMITS.MAX_AUDIO_SECONDS) return refuse('too_long');
468
+ if (stale) return refuse('stale');
469
+ let bytes;
470
+ try {
471
+ const buf = await withTimeout(
472
+ mod.downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock?.updateMediaMessage }),
473
+ DOWNLOAD_TIMEOUT_MS,
474
+ 'audio download',
475
+ );
476
+ bytes = Buffer.isBuffer(buf) ? buf : Buffer.from(buf || []);
477
+ } catch {
478
+ return refuse('transcription_failed');
479
+ }
480
+ if (bytes.length > LIMITS.MAX_AUDIO_BYTES) return refuse('too_large');
481
+ if (!bytes.length) return refuse('transcription_failed');
482
+ let result = null;
483
+ try { result = await transcriber.transcribe(bytes); } catch { result = null; }
484
+ if (result?.ok && typeof result.text === 'string' && result.text.trim()) return { transcript: result.text.trim(), recorded: inner?.ptt === true };
485
+ // Turned off while the note waited its turn: said as such, not as a failure.
486
+ return refuse(['too_long', 'too_large', 'transcription_disabled'].includes(result?.reason) ? result.reason : 'transcription_failed');
487
+ }
488
+
404
489
  /**
405
490
  * Turn an accepted message into the parts of an InboundMessage (never a JID).
406
- * @returns {Promise<{text:string, images:Array, unsupported:null|{type:string, reason?:string}, forwarded:boolean, quoted:null|{id:string,text:string,fromBot:boolean}}>}
491
+ * A transcribed voice note: `text` is VOICE_MARKER + the transcript, then "\n" + the caption when
492
+ * there is one (the transcript first when the text is cut), and `voice: true`; `audioFile: true` when
493
+ * it is an audio file rather than a note recorded in WhatsApp (its words may be anyone's: the bridge
494
+ * treats it like a picture).
495
+ * @returns {Promise<{text:string, images:Array, unsupported:null|{type:string, reason?:string}, forwarded:boolean,
496
+ * quoted:null|{id:string,text:string,fromBot:boolean}, voice:boolean, audioFile:boolean}>}
407
497
  */
408
- export async function extractInbound(msg, { mod, sock, logger, sentIds, described, text }) {
498
+ export async function extractInbound(msg, { mod, sock, logger, sentIds, described, text, transcriber = null, audioStale = false }) {
409
499
  const d = described;
410
- const body = String(text ?? d.text ?? '').slice(0, LIMITS.MAX_INBOUND_TEXT);
500
+ let body = String(text ?? d.text ?? '').slice(0, LIMITS.MAX_INBOUND_TEXT);
411
501
  const ctx = d.contextInfo;
412
502
  const forwarded = !!(ctx && (ctx.isForwarded || Number(ctx.forwardingScore) > 0));
413
503
  let quoted = null;
@@ -420,7 +510,21 @@ export async function extractInbound(msg, { mod, sock, logger, sentIds, describe
420
510
  }
421
511
  const images = [];
422
512
  let unsupported = null;
513
+ let voice = false;
514
+ let audioFile = false;
423
515
  if (d.kind === 'unsupported') unsupported = { type: d.unsupportedType };
516
+ if (d.kind === 'audio') {
517
+ const audio = await readAudio(msg, { mod, sock, logger, inner: d.inner, transcriber, stale: audioStale });
518
+ if (audio.transcript) {
519
+ const caption = String(text ?? d.text ?? '');
520
+ body = `${VOICE_MARKER} ${audio.transcript}${caption.trim() ? `\n${caption}` : ''}`.slice(0, LIMITS.MAX_INBOUND_TEXT);
521
+ voice = true;
522
+ audioFile = !audio.recorded;
523
+ } else {
524
+ // The same unsupported-audio shape as before, with why; a caption still gets through.
525
+ unsupported = audio.unsupported;
526
+ }
527
+ }
424
528
  if (d.kind === 'image') {
425
529
  const mime = String(d.inner?.mimetype || '').split(';')[0].trim().toLowerCase();
426
530
  const declared = Number(d.inner?.fileLength ?? 0);
@@ -443,7 +547,23 @@ export async function extractInbound(msg, { mod, sock, logger, sentIds, describe
443
547
  }
444
548
  }
445
549
  }
446
- return { text: body, images, unsupported, forwarded, quoted };
550
+ return { text: body, images, unsupported, forwarded, quoted, voice, audioFile };
551
+ }
552
+
553
+ /**
554
+ * The sendMessage content for a file prepared by local-file.js (prepareLocalFile): a document
555
+ * (mimetype + fileName, the caption when there is one), a photo or a video (the caption), or audio
556
+ * (never a caption, never a voice note). `jpegThumbnail: null` keeps Baileys from building a preview,
557
+ * which for a video means running FFmpeg through a shell: SynaBun never decodes the file it sends.
558
+ */
559
+ export function fileMessage(file) {
560
+ const caption = typeof file?.caption === 'string' && file.caption ? { caption: file.caption } : {};
561
+ switch (file?.type) {
562
+ case 'image': return { image: file.data, mimetype: file.mimetype, jpegThumbnail: null, ...caption };
563
+ case 'video': return { video: file.data, mimetype: file.mimetype, jpegThumbnail: null, ...caption };
564
+ case 'audio': return { audio: file.data, mimetype: file.mimetype, ptt: false };
565
+ default: return { document: file.data, mimetype: file.mimetype, fileName: file.fileName || 'file', ...caption };
566
+ }
447
567
  }
448
568
 
449
569
  /** The minimal message Baileys needs to quote an owner message in a reply. */
@@ -13,6 +13,14 @@
13
13
  // owner's messages; a synabun.mailbox event is RUNNING(mailbox), a turn the
14
14
  // CLI starts itself RUNNING(background), and a turn nobody here submitted (a
15
15
  // prompt typed on the desktop) RUNNING(external) — tracked, never forwarded.
16
+ // A task the router continues on another model (a synabun.continuation event:
17
+ // a route decided automatically, by a remembered route, or on a card) answers
18
+ // where its prompt came from: RUNNING(followup) for a prompt from the phone,
19
+ // whose answer is sent once as the reply (not an update, whatever "Forward
20
+ // background results" says), RUNNING(external) for any other. A follow-up is
21
+ // how a turn's answer is delivered, nothing more: what may run unasked or be
22
+ // approved is the runtime's (a continuation is its own turn, never the owner's
23
+ // message: computer use asks once in it) and the owner's typed answers.
16
24
  // The turn object exists before submit is called: brain events can arrive
17
25
  // before it resolves.
18
26
  //
@@ -23,8 +31,9 @@
23
31
  // closes WITHOUT being granted (denied, cancelled) and the message goes to the
24
32
  // Assistant as the next prompt. An approval is only ever granted by a "yes"
25
33
  // the bridge matched on a message the owner typed, aimed at that request while
26
- // it is open: never by the model, by a forwarded message, by a reply that
27
- // quotes something else or by anything that merely looks like an answer.
34
+ // it is open: never by the model, by a forwarded message, by a voice note (its
35
+ // transcript is an ordinary message, `voice: true`), by a reply that quotes
36
+ // something else or by anything that merely looks like an answer.
28
37
  // Messages that arrive while a turn runs wait as one merged prompt and go in
29
38
  // as soon as it ends; a queue watch lets go of a turn whose end never came.
30
39
  // A request that appears while such a message waits closes at once, unseen and
@@ -87,14 +96,28 @@ export const BRIDGE_LIMITS = Object.freeze({
87
96
  policyTickMs: 30_000,
88
97
  });
89
98
 
99
+ /**
100
+ * Why a voice note was not transcribed (the host's unsupported.reason), told to the phone once per
101
+ * reason every unsupportedEveryMs. Anything else unsupported gets the generic line.
102
+ */
103
+ export const AUDIO_NOT_TRANSCRIBED = Object.freeze({
104
+ transcription_disabled: 'Voice notes are not transcribed: that is turned off in Settings → WhatsApp on your computer. Type your message instead.',
105
+ transcription_unavailable: 'I can\'t transcribe voice notes yet: this computer needs whisper.cpp, its speech model and FFmpeg (Settings → WhatsApp says what is missing). Type your message instead.',
106
+ too_large: 'That voice note is too large for me to transcribe (20 MB at most). Send a shorter one, or type it.',
107
+ too_long: 'That voice note is too long for me to transcribe (10 minutes at most). Send a shorter one, or type it.',
108
+ stale: 'That voice note was sent a while ago, so I did not transcribe it. Send it again if you still need it.',
109
+ transcription_failed: 'I couldn\'t transcribe that voice note. Try again, or type your message.',
110
+ });
111
+
90
112
  /** Send refusals that mean "not now": the reply waits in the outbox for the next connect. */
91
113
  const RESEND_ON_CONNECT = /DISCONNECT|NOT_CONNECTED|OFFLINE|RECONNECT|CLOSED|TIMEOUT|HOST_UNAVAILABLE/i;
92
114
  /**
93
115
  * What happens to a reply the transport refused: 'connect' (it waits for the
94
116
  * next connect), 'later' (THROTTLED: sent again after its retryAfterMs) or
95
- * 'drop'. PAUSED (an owner anomaly paused sending) and HELD (the connector
96
- * kept crashing) drop it: the service records why in the activity log and the
97
- * Settings tab shows the state.
117
+ * 'drop'. PAUSED (an owner anomaly paused sending) and HELD (a connector
118
+ * process of another SynaBun version, down until a restart; a crash is not
119
+ * HELD: it reads HOST_UNAVAILABLE and the reply waits) drop it: the service
120
+ * records why in the activity log and the Settings tab shows the state.
98
121
  */
99
122
  export function outboxPlan(code) {
100
123
  const value = String(code || '');
@@ -104,7 +127,7 @@ export function outboxPlan(code) {
104
127
 
105
128
  const DEFAULT_CONFIG = Object.freeze({
106
129
  enabled: true, level: 'ask', progress: 'key', forwardBackground: true, replyLabel: null, maxMessages: 3,
107
- rotation: 'idle6h', strictWorkerApprovals: false, computerUse: false, mode: 'self', paused: false, pausedBy: null, autonomousUntil: null,
130
+ rotation: 'idle6h', strictWorkerApprovals: false, computerUse: false, mode: 'self', paused: false, pausedBy: null,
108
131
  });
109
132
  const ALREADY_ANSWERED = new Set(['CONTROL_UNKNOWN', 'ROUTE_ALREADY_DECIDED', 'ROUTE_NOT_FOUND', 'CLARIFY_ALREADY_ANSWERED', 'CLARIFY_NOT_FOUND', 'PERMISSION_ALREADY_RESOLVED', 'NO_PENDING_REQUEST', 'RUN_NOT_FOUND']);
110
133
  const ROUTE_DONE_PHASES = new Set(['decided', 'auto', 'expired', 'declined', 'cancelled']);
@@ -191,6 +214,8 @@ export function createWhatsAppBridge({
191
214
  outboxTimer: null, // a THROTTLED reply's retry
192
215
  staleSkipped: 0,
193
216
  staleTimer: null,
217
+ stoppedAt: 0, // when the owner's last /stop was sent (its message time)
218
+ stopLateNoted: false, // one note per /stop for what was sent before it and arrived after it
194
219
  unsupportedAt: new Map(),
195
220
  minuteWindow: { start: 0, count: 0, noticed: false },
196
221
  dayWindow: { start: 0, count: 0, noticed: false },
@@ -435,14 +460,16 @@ export function createWhatsAppBridge({
435
460
  state.brainApplied = { sessionId, signature };
436
461
  persistBridgeState();
437
462
  }
463
+ // Autonomous has no time limit on WhatsApp: the policy carries no autonomousUntil (lib/remote-policy.js effectiveLevel).
464
+ // WhatsApp turned off on this computer (`enabled: false`, which also stops the connector) ends computer use like the switch.
438
465
  function remoteFromConfig(cfg = readConfig()) {
439
- return { channel: 'whatsapp', level: cfg.level || 'ask', strictWorkerApprovals: cfg.strictWorkerApprovals === true, autonomousUntil: cfg.autonomousUntil || null, paused: state.paused, computerUse: cfg.computerUse === true && state.computerEnded !== true };
466
+ return { channel: 'whatsapp', level: cfg.level || 'ask', strictWorkerApprovals: cfg.strictWorkerApprovals === true, paused: state.paused, computerUse: cfg.computerUse === true && cfg.enabled !== false && state.computerEnded !== true };
440
467
  }
441
468
  /** The policy of one of our sessions: computer use is the current conversation's alone. */
442
469
  function remoteFor(id, base = remoteFromConfig()) {
443
470
  return id === state.sessionId ? base : { ...base, computerUse: false };
444
471
  }
445
- const policySignature = (remote) => JSON.stringify([remote.level, remote.strictWorkerApprovals, remote.autonomousUntil, remote.paused, remote.computerUse]);
472
+ const policySignature = (remote) => JSON.stringify([remote.level, remote.strictWorkerApprovals, remote.paused, remote.computerUse]);
446
473
  async function createSession() {
447
474
  const rt = runtime();
448
475
  if (!rt?.createSession) throw Object.assign(new Error('The Assistant is not available right now.'), { code: 'RUNTIME_UNAVAILABLE' });
@@ -500,9 +527,8 @@ export function createWhatsAppBridge({
500
527
  // ── policy ─────────────────────────────────────────────────────────────────
501
528
  /**
502
529
  * Keep the level of every WhatsApp session in line with the config (level,
503
- * strict approvals, the autonomous window, pause). A change — or an
504
- * autonomous window that just ran out — re-registers, which makes the
505
- * runtime re-apply the clamp and stop runs above it.
530
+ * strict approvals, pause, computer use). A change re-registers, which makes
531
+ * the runtime re-apply the clamp and stop runs above it.
506
532
  */
507
533
  function syncPolicies() {
508
534
  const cfg = readConfig();
@@ -652,7 +678,8 @@ export function createWhatsAppBridge({
652
678
  if (turn.kind === 'followup') state.followUp = null;
653
679
  else { presence('paused'); state.inflight = null; persistBridgeState(); react(turn.sourceIds, 'done'); }
654
680
  } else if (turn.kind === 'followup') {
655
- // The task the phone decided on a card: its answer is a reply, not an update.
681
+ // The task the phone decided on a card, or that its prompt carried on to another model: its answer
682
+ // is a reply, not an update. Said once: the turn is gone already, a repeated end finds nothing.
656
683
  state.followUp = null;
657
684
  const text = finalText(turn);
658
685
  if (outcome === 'failed') await say(`Something went wrong: ${String(message || turn.resultError || 'the turn failed').slice(0, 400)}`, { maxChunks: 1 });
@@ -767,6 +794,12 @@ export function createWhatsAppBridge({
767
794
  // Our own prompt (the WhatsApp turn exists already); any other is someone else's turn.
768
795
  if (ev.origin !== 'whatsapp' && !state.turns.has(sessionId)) makeTurn(sessionId, 'external');
769
796
  return;
797
+ case 'synabun.continuation':
798
+ // The router carries a task on (another model, the same conversation), before any of its output.
799
+ // A prompt from the phone: its answer is the reply to the phone. Anything else stays as before:
800
+ // someone else's turn, unless a card the phone saw was just decided (ensureTurn).
801
+ if (!state.turns.has(sessionId)) ensureTurn(sessionId, ev.origin === 'whatsapp' ? 'followup' : 'external');
802
+ return;
770
803
  case 'synabun.mailbox': {
771
804
  const items = Array.isArray(ev.items) ? ev.items : [];
772
805
  const turn = state.turns.get(sessionId);
@@ -1031,6 +1064,10 @@ export function createWhatsAppBridge({
1031
1064
  case 'help': await say(HELP_TEXT, { maxChunks: 2 }); return;
1032
1065
  case 'status': await say(statusText(statusSnapshot()), { maxChunks: 1 }); return;
1033
1066
  case 'stop': {
1067
+ // What was sent before this /stop and arrives after it (a voice note still being transcribed, and
1068
+ // what waited behind it) is not acted on, as /stop clears what already waits in line.
1069
+ state.stoppedAt = Math.max(state.stoppedAt, tsMs(message.ts, now()));
1070
+ state.stopLateNoted = false;
1034
1071
  const { stoppedRuns, clearedQueue } = await stopEverything({ reason: 'phone' });
1035
1072
  const n = stoppedRuns + clearedQueue;
1036
1073
  await say(`Stopped. Cancelled ${n} task${n === 1 ? '' : 's'}.`, { maxChunks: 1 });
@@ -1067,18 +1104,14 @@ export function createWhatsAppBridge({
1067
1104
  function statusSnapshot() {
1068
1105
  const view = state.sessionId ? sessionView(state.sessionId) : null;
1069
1106
  const turn = state.sessionId ? state.turns.get(state.sessionId) : null;
1070
- const rt = runtime();
1071
- let brainCapUsd = null;
1072
- try { brainCapUsd = rt?.brainCapUsd?.() ?? null; } catch { brainCapUsd = null; }
1073
1107
  const cfg = readConfig();
1074
1108
  const current = currentCard();
1075
1109
  return {
1076
1110
  connected: isConnected(), sessionId: state.sessionId, title: view?.title || null, brain: view?.brain || null,
1077
1111
  running: !!turn || !!view?.running, turnKind: turn?.kind || null, queued: state.queue.length,
1078
1112
  pendingCards: state.cards.size, currentCard: current ? (current.kind === 'route' ? 'where to run a task' : current.kind === 'computer' ? 'controlling your Mac' : current.kind) : null,
1079
- budget: view?.budget || null, brainUsd: view?.costUsd ?? null, brainCapUsd,
1113
+ budget: view?.budget || null, brainUsd: view?.costUsd ?? null,
1080
1114
  level: state.sessionId ? levelOf(state.sessionId) : cfg.level, paused: state.paused, pausedBy: state.pausedBy,
1081
- autonomousUntil: cfg.autonomousUntil || null,
1082
1115
  };
1083
1116
  }
1084
1117
  /** /stop and stopAll: the turn, the session's runs, open cards and the queue. */
@@ -1168,6 +1201,16 @@ export function createWhatsAppBridge({
1168
1201
  // `backlog`): never acted on, only counted for one note. Checked first, so a
1169
1202
  // backlog neither uses up the rate limits nor counts as activity.
1170
1203
  if (now() - tsMs(message.ts, now()) > L.staleMs) { noteStale(); return; }
1204
+ // Sent before the owner's last /stop, delivered after it (a typed /stop does not wait for a voice note
1205
+ // being transcribed): /stop already stopped it. A command still runs. What WhatsApp held while SynaBun was
1206
+ // offline arrives in order and is never overtaken (a backlog is never sent ahead), so it is not affected.
1207
+ if (state.stoppedAt && message.backlog !== true && tsMs(message.ts, now()) < state.stoppedAt && !parseCommand(message)) {
1208
+ if (!state.stopLateNoted) {
1209
+ state.stopLateNoted = true;
1210
+ await say('A message you sent before /stop arrived after it, so I did not act on it. Send it again if you still need it.', { maxChunks: 1 });
1211
+ }
1212
+ return;
1213
+ }
1171
1214
  if (rateLimited()) return;
1172
1215
  const activityBefore = state.lastActivityAt;
1173
1216
  state.lastActivityAt = now();
@@ -1178,10 +1221,12 @@ export function createWhatsAppBridge({
1178
1221
  const images = Array.isArray(message.images) ? message.images : [];
1179
1222
  if (message.unsupported && !text.trim() && !images.length) {
1180
1223
  const type = String(message.unsupported.type || 'this kind of');
1181
- const last = state.unsupportedAt.get(type) || 0;
1224
+ const audio = type === 'audio' && Object.hasOwn(AUDIO_NOT_TRANSCRIBED, String(message.unsupported.reason)) ? AUDIO_NOT_TRANSCRIBED[message.unsupported.reason] : null;
1225
+ const key = audio ? `audio:${message.unsupported.reason}` : type;
1226
+ const last = state.unsupportedAt.get(key) || 0;
1182
1227
  if (now() - last >= L.unsupportedEveryMs) {
1183
- state.unsupportedAt.set(type, now());
1184
- await say(`I can read text and pictures for now; ${type} messages are not supported yet.`, { maxChunks: 1 });
1228
+ state.unsupportedAt.set(key, now());
1229
+ await say(audio || `I can read text and pictures for now; ${type} messages are not supported yet.`, { maxChunks: 1 });
1185
1230
  }
1186
1231
  return;
1187
1232
  }
@@ -1198,8 +1243,8 @@ export function createWhatsAppBridge({
1198
1243
  }
1199
1244
  // Something is waiting for the owner: a clear answer answers it; anything else closes it
1200
1245
  // without a grant and carries on as the next prompt. Only words the owner typed can answer:
1201
- // never a forwarded message, a picture or "//text".
1202
- const answerable = !images.length && command?.kind !== 'literal' && message.forwarded !== true;
1246
+ // never a forwarded message, a picture, a voice note (its transcript) or "//text".
1247
+ const answerable = !images.length && command?.kind !== 'literal' && message.forwarded !== true && message.voice !== true;
1203
1248
  if (await replyToPending(message, { answerable })) return;
1204
1249
  if (rotationDue(activityBefore)) state.rotateOnNextSubmit = true;
1205
1250
  // A request raised between that look and here is older than this message too (onCard only sees
@@ -1271,7 +1316,7 @@ export function createWhatsAppBridge({
1271
1316
  if (!list.length) return;
1272
1317
  let composed = null;
1273
1318
  try { composed = inbound?.composePrompt ? inbound.composePrompt(list, { rng }) : null; } catch (error) { log('whatsapp:bridge', `compose: ${error?.message || error}`); }
1274
- if (!composed) composed = { text: list.map((part) => String(part.text || '')).filter(Boolean).join('\n\n'), images: list.flatMap((part) => part.images || []), sourceIds: list.map((part) => part.id).filter(Boolean), untrusted: list.some((part) => part.forwarded || part.quoted) };
1319
+ if (!composed) composed = { text: list.map((part) => String(part.text || '')).filter(Boolean).join('\n\n'), images: list.flatMap((part) => part.images || []), sourceIds: list.map((part) => part.id).filter(Boolean), untrusted: list.some((part) => part.forwarded || part.quoted || part.audioFile === true) };
1275
1320
  const images = Array.isArray(composed.images) ? composed.images.filter(Boolean) : [];
1276
1321
  const job = {
1277
1322
  text: String(composed.text || ''), images,
@@ -1402,22 +1447,21 @@ export function createWhatsAppBridge({
1402
1447
  }
1403
1448
  persistBridgeState();
1404
1449
  react(job.sourceIds, 'failed');
1405
- if (code === 'SESSION_BUDGET_EXCEEDED' || code === 'BRAIN_BUDGET_EXCEEDED') { await say(budgetText(code, sessionId, result.message), { maxChunks: 1 }); return; }
1450
+ if (code === 'SESSION_BUDGET_EXCEEDED' || code === 'BRAIN_SHARE_EXHAUSTED') { await say(budgetText(code, sessionId, result.message), { maxChunks: 1 }); return; }
1406
1451
  if (code === 'EMPTY_PROMPT') return;
1407
1452
  await say(`Could not start that: ${String(result.message || code).slice(0, 400)}`, { maxChunks: 1 });
1408
1453
  }
1409
- /** Spend against the cap, and the two ways on: /new or a higher cap. Never a silent new session. */
1454
+ /** Spend against the budget, and the two ways on: /new or a higher budget. Never a silent new session. */
1410
1455
  function budgetText(code, sessionId, fallback) {
1411
1456
  const view = sessionView(sessionId);
1457
+ const budget = view?.budget || null;
1412
1458
  let line = '';
1413
- if (code === 'SESSION_BUDGET_EXCEEDED' && view?.budget && Number.isFinite(Number(view.budget.hardUsd))) {
1414
- line = `This conversation spent ${usd(view.budget.totalUsd)} of its ${usd(view.budget.hardUsd)} cap.`;
1415
- } else if (code === 'BRAIN_BUDGET_EXCEEDED') {
1416
- let cap = null;
1417
- try { cap = runtime()?.brainCapUsd?.() ?? null; } catch { cap = null; }
1418
- line = cap !== null ? `The Assistant spent ${usd(view?.costUsd)} of its ${usd(cap)} cap in this conversation.` : '';
1459
+ if (code === 'SESSION_BUDGET_EXCEEDED' && budget && Number.isFinite(Number(budget.amountUsd))) {
1460
+ line = `This conversation spent ${usd(budget.totalUsd)} of its ${usd(budget.amountUsd)} budget.`;
1461
+ } else if (code === 'BRAIN_SHARE_EXHAUSTED' && budget && Number.isFinite(Number(budget.amountUsd))) {
1462
+ line = `The Assistant's share of this conversation's ${usd(budget.amountUsd)} budget is used up.`;
1419
1463
  }
1420
- return `${line || String(fallback || 'This conversation reached its budget cap.')} Send /new to start a fresh conversation, or raise the cap in SynaBun.`;
1464
+ return `${line || String(fallback || 'This conversation reached its budget.')} Send /new to start a fresh conversation, or raise the budget (or choose No limit) in SynaBun.`;
1421
1465
  }
1422
1466
 
1423
1467
  // ── lifecycle ──────────────────────────────────────────────────────────────
@@ -1484,14 +1528,14 @@ export function createWhatsAppBridge({
1484
1528
  persistBridgeState();
1485
1529
  }
1486
1530
 
1487
- // Keep the level in line with the config (Settings edits, the autonomous window running out).
1531
+ // Keep the level in line with the config (a change that reached it without a refresh).
1488
1532
  state.policyTimer = T.setInterval(() => { try { runtime(); syncPolicies(); } catch {} }, L.policyTickMs);
1489
1533
  state.policyTimer?.unref?.();
1490
1534
  runtime();
1491
1535
 
1492
1536
  return {
1493
1537
  onInbound, onConnection, status, newSession, pause, resume, stopAll, shutdown,
1494
- // Settings changed (level, strict approvals, autonomous window): re-apply now instead of at the next tick.
1538
+ // Settings changed (level, strict approvals, computer use): re-apply now instead of at the next tick.
1495
1539
  refresh: () => { state.policySeen.clear(); runtime(); syncPolicies(); return status(); },
1496
1540
  endComputerUse,
1497
1541
  _internals: { state, finalText, syncPolicies, drain, onPacket, brainTarget, moveOn },
@@ -2,10 +2,10 @@
2
2
  // SynaBun — WhatsApp Link: the owner's slash commands
3
3
  // ═══════════════════════════════════════════
4
4
  //
5
- // Pure parsing and fixed texts. A command is the owner's own text — never a
6
- // forwarded or quoted message — that starts with one of COMMANDS
7
- // (case-insensitive). Commands run at once, busy or paused. Any other "/x" is
8
- // a prompt; "//x" sends the literal "/x". No command raises the level or
5
+ // Pure parsing and fixed texts. A command is the owner's own typed text —
6
+ // never a forwarded or quoted message, never a voice note — that starts with
7
+ // one of COMMANDS (case-insensitive). Commands run at once, busy or paused.
8
+ // Any other "/x" is a prompt; "//x" sends the literal "/x". No command raises the level or
9
9
  // changes a setting: those names get a fixed refusal (they would otherwise
10
10
  // reach the brain's CLI as its own slash commands).
11
11
 
@@ -21,7 +21,7 @@ const REFUSED = new Set([
21
21
 
22
22
  export const HELP_TEXT = [
23
23
  '*SynaBun on WhatsApp*',
24
- 'Write to me like you would in SynaBun. Pictures work too.',
24
+ 'Write to me like you would in SynaBun. Pictures work too, and voice notes when this computer can transcribe them.',
25
25
  '',
26
26
  '/status — what I am doing, spend and level',
27
27
  '/stop — stop the current task, its agents and anything waiting for your answer',
@@ -31,7 +31,7 @@ export const HELP_TEXT = [
31
31
  '/cards — ask again what is still waiting for your answer',
32
32
  '/help — this list',
33
33
  '',
34
- 'When I ask something, just answer in your own words. An approval takes a plain yes or no; any other message approves nothing and carries on the conversation. Start with // to send text that begins with a slash.',
34
+ 'When I ask something, just answer in your own words. An approval takes a plain yes or no, typed; any other message (a voice note too) approves nothing and carries on the conversation. Start with // to send text that begins with a slash.',
35
35
  'Levels, models and other settings change only in SynaBun on your computer (Settings → WhatsApp).',
36
36
  ].join('\n');
37
37
 
@@ -40,10 +40,10 @@ export const SETTINGS_REFUSAL = 'Settings change only in SynaBun on your compute
40
40
  /**
41
41
  * An inbound message → { kind: 'command', name, args } | { kind: 'refused', name }
42
42
  * | { kind: 'literal', text } ("//x" → "/x") | null (not a command: a prompt).
43
- * Only the owner's own text counts: forwarded or quoted messages are prompts.
43
+ * Only the owner's own typed text counts: forwarded or quoted messages and voice notes are prompts.
44
44
  */
45
45
  export function parseCommand(message) {
46
- if (!message || message.owner === false || message.forwarded || message.quoted) return null;
46
+ if (!message || message.owner === false || message.forwarded || message.quoted || message.voice === true) return null;
47
47
  const text = String(message.text ?? '').trim();
48
48
  if (!text.startsWith('/')) return null;
49
49
  if (text.startsWith('//')) return { kind: 'literal', text: text.slice(1) };
@@ -66,8 +66,8 @@ function levelLabel(level) {
66
66
 
67
67
  /**
68
68
  * /status text from a snapshot: { connected, sessionId, title, brain:{provider,model},
69
- * running, turnKind, queued, pendingCards, currentCard, budget:{totalUsd, hardUsd},
70
- * brainUsd, brainCapUsd, level, paused, pausedBy, autonomousUntil }.
69
+ * running, turnKind, queued, pendingCards, currentCard, budget:{ unlimited, totalUsd, amountUsd, leftUsd },
70
+ * brainUsd, level, paused, pausedBy }. Autonomous has no time limit: it holds until it is turned off in Settings.
71
71
  */
72
72
  export function statusText(s = {}) {
73
73
  const lines = ['*SynaBun status*'];
@@ -83,10 +83,9 @@ export function statusText(s = {}) {
83
83
  : 'idle';
84
84
  lines.push(`Now: ${doing}${s.queued ? ` · ${s.queued} message${s.queued === 1 ? '' : 's'} queued` : ''}`);
85
85
  if (s.pendingCards) lines.push(`Waiting for you: ${s.pendingCards} question${s.pendingCards === 1 ? '' : 's'} or approval${s.pendingCards === 1 ? '' : 's'}${s.currentCard ? ` (${s.currentCard})` : ''} — /cards asks again`);
86
- if (s.budget && Number.isFinite(Number(s.budget.hardUsd))) lines.push(`Spend: ${usd(s.budget.totalUsd)} of ${usd(s.budget.hardUsd)} session cap${Number.isFinite(Number(s.brainCapUsd)) ? ` (brain ${usd(s.brainUsd)} of ${usd(s.brainCapUsd)})` : ''}`);
87
- let level = `Level: ${levelLabel(s.level)}`;
88
- if (s.level === 'autonomous' && s.autonomousUntil) level += ` until ${new Date(Number(s.autonomousUntil)).toTimeString().slice(0, 5)}`;
89
- lines.push(level);
86
+ if (s.budget?.unlimited === true) lines.push(`Spend: ${usd(s.budget.totalUsd)} (no limit)`);
87
+ else if (s.budget && Number.isFinite(Number(s.budget.amountUsd))) lines.push(`Spend: ${usd(s.budget.totalUsd)} of the ${usd(s.budget.amountUsd)} budget${Number.isFinite(Number(s.brainUsd)) ? ` (brain ${usd(s.brainUsd)})` : ''}`);
88
+ lines.push(`Level: ${levelLabel(s.level)}${s.level === 'autonomous' ? ' until turned off' : ''}`);
90
89
  if (s.paused) lines.push(`Paused${s.pausedBy === 'desktop' ? ' from your computer' : ''} — ${s.pausedBy === 'desktop' ? 'resume it in SynaBun' : 'send /resume'}`);
91
90
  return lines.join('\n');
92
91
  }