bloby-bot 0.70.12 → 0.71.0
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/bin/cli.js +234 -48
- package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
- package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
- package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
- package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +3 -4
- package/scripts/install +156 -41
- package/scripts/install.ps1 +146 -29
- package/scripts/install.sh +156 -41
- package/shared/config.ts +37 -2
- package/shared/relay.ts +3 -1
- package/supervisor/channels/manager.ts +84 -44
- package/supervisor/channels/telegram.ts +57 -16
- package/supervisor/channels/types.ts +4 -1
- package/supervisor/channels/whatsapp.ts +57 -10
- package/supervisor/chat/OnboardWizard.tsx +0 -15
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
- package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
- package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
- package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
- package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
- package/supervisor/chat/src/hooks/useChat.ts +52 -0
- package/supervisor/chat/src/lib/authedFile.ts +24 -12
- package/supervisor/file-saver.ts +92 -19
- package/supervisor/harnesses/attachment-policy.ts +111 -0
- package/supervisor/harnesses/claude.ts +62 -15
- package/supervisor/harnesses/codex.ts +69 -43
- package/supervisor/harnesses/pi/index.ts +367 -112
- package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
- package/supervisor/harnesses/pi/providers/retry.ts +31 -0
- package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
- package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
- package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
- package/supervisor/harnesses/pi/providers/types.ts +29 -1
- package/supervisor/harnesses/pi/session.ts +143 -3
- package/supervisor/harnesses/pi/test-completion.ts +56 -0
- package/supervisor/harnesses/pi/tools/bash.ts +198 -22
- package/supervisor/harnesses/pi/tools/glob.ts +79 -0
- package/supervisor/harnesses/pi/tools/grep.ts +0 -0
- package/supervisor/harnesses/pi/tools/registry.ts +18 -6
- package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
- package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
- package/supervisor/index.ts +93 -18
- package/supervisor/widget.js +19 -5
- package/worker/db.ts +2 -0
- package/worker/index.ts +18 -1
- package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
- package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
- package/worker/prompts/bloby-system-prompt.txt +1 -1
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
- package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
- package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
- package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
- package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
- package/workspace/skills/mac/SKILL.md +13 -4
- package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
- package/supervisor/public/headphones_spritesheet.webp +0 -0
- package/supervisor/public/spritesheet.webp +0 -0
package/scripts/install.sh
CHANGED
|
@@ -7,6 +7,23 @@ set -e
|
|
|
7
7
|
# Downloads Node.js + Bloby into ~/.bloby — no system dependencies needed.
|
|
8
8
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
|
+
# Repair $HOME before deriving any install path. Under `sudo`, cron, CI, or a
|
|
11
|
+
# minimal shell, $HOME can be empty or "/" — without this we'd install into the
|
|
12
|
+
# wrong place (or /). Colors aren't defined yet, so this early error is plain.
|
|
13
|
+
if [ -z "${HOME:-}" ] || [ ! -d "${HOME:-}" ]; then
|
|
14
|
+
_whoami=$(id -un 2>/dev/null || echo "")
|
|
15
|
+
if [ -n "$_whoami" ] && command -v getent >/dev/null 2>&1; then
|
|
16
|
+
HOME=$(getent passwd "$_whoami" 2>/dev/null | cut -d: -f6)
|
|
17
|
+
elif [ -n "$_whoami" ] && command -v dscl >/dev/null 2>&1; then
|
|
18
|
+
HOME=$(dscl . -read "/Users/$_whoami" NFSHomeDirectory 2>/dev/null | awk '{print $2}')
|
|
19
|
+
fi
|
|
20
|
+
export HOME
|
|
21
|
+
fi
|
|
22
|
+
if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then
|
|
23
|
+
printf 'Error: could not determine your home directory ($HOME is unset).\n' >&2
|
|
24
|
+
exit 1
|
|
25
|
+
fi
|
|
26
|
+
|
|
10
27
|
MIN_NODE_MAJOR=18
|
|
11
28
|
NODE_VERSION="22.14.0"
|
|
12
29
|
BLOBY_HOME="$HOME/.bloby"
|
|
@@ -14,6 +31,11 @@ TOOLS_DIR="$BLOBY_HOME/tools"
|
|
|
14
31
|
NODE_DIR="$TOOLS_DIR/node"
|
|
15
32
|
BIN_DIR="$BLOBY_HOME/bin"
|
|
16
33
|
USE_SYSTEM_NODE=false
|
|
34
|
+
LIBC=""
|
|
35
|
+
# Temp paths the cleanup trap removes — kept empty until we own them so an early
|
|
36
|
+
# exit can never `rm -rf` an inherited value (e.g. the system $TMPDIR).
|
|
37
|
+
WORK_DIR=""
|
|
38
|
+
TMPFILE=""
|
|
17
39
|
|
|
18
40
|
# Brand colors: #00ADFE (light) and #0158FB (deep) -- Morphy palette, 24-bit truecolor
|
|
19
41
|
BLUE='\033[38;2;0;173;254m'
|
|
@@ -37,15 +59,47 @@ if [ ! -t 1 ]; then
|
|
|
37
59
|
BLUE='' PINK='' YELLOW='' RED='' DIM='' BOLD='' RESET='' G1='' G2='' G3='' G4='' G5='' G6='' G7=''
|
|
38
60
|
fi
|
|
39
61
|
|
|
40
|
-
# Cleanup on exit (restore cursor, reset colors, remove temp files)
|
|
62
|
+
# Cleanup on exit (restore cursor, reset colors, remove temp files).
|
|
63
|
+
# Only remove paths we actually created — never an inherited/unset value.
|
|
41
64
|
cleanup() {
|
|
42
65
|
printf '\033[?25h' # show cursor
|
|
43
66
|
printf "${RESET}"
|
|
44
|
-
rm -f "$TMPFILE" 2>/dev/null
|
|
45
|
-
rm -rf "$
|
|
67
|
+
[ -n "$TMPFILE" ] && rm -f "$TMPFILE" 2>/dev/null
|
|
68
|
+
[ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" 2>/dev/null
|
|
69
|
+
return 0
|
|
46
70
|
}
|
|
47
71
|
trap cleanup EXIT INT TERM
|
|
48
72
|
|
|
73
|
+
# ─── Download + integrity helpers ───────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# download_file <url> <dest> — curl/wget with HTTPS floor, timeout, and retry.
|
|
76
|
+
download_file() {
|
|
77
|
+
if command -v curl >/dev/null 2>&1; then
|
|
78
|
+
curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 20 --retry 3 --retry-delay 2 -o "$2" "$1"
|
|
79
|
+
elif command -v wget >/dev/null 2>&1; then
|
|
80
|
+
wget -q --https-only --tries=3 --timeout=20 -O "$2" "$1"
|
|
81
|
+
else
|
|
82
|
+
printf " ${RED}✗${RESET} curl or wget is required to install Bloby\n"
|
|
83
|
+
exit 1
|
|
84
|
+
fi
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# verify_sha256 <file> <expected-hex> — 0 match, 1 mismatch, 2 no tool available.
|
|
88
|
+
verify_sha256() {
|
|
89
|
+
_actual=""
|
|
90
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
91
|
+
_actual=$(sha256sum "$1" 2>/dev/null | awk '{print $1}')
|
|
92
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
93
|
+
_actual=$(shasum -a 256 "$1" 2>/dev/null | awk '{print $1}')
|
|
94
|
+
else
|
|
95
|
+
return 2
|
|
96
|
+
fi
|
|
97
|
+
# No hash computed (tool failed / file vanished) → "skip" (2), not "mismatch" (1),
|
|
98
|
+
# so we warn rather than aborting with a misleading "tampered download".
|
|
99
|
+
[ -n "$_actual" ] || return 2
|
|
100
|
+
[ "$_actual" = "$2" ]
|
|
101
|
+
}
|
|
102
|
+
|
|
49
103
|
printf "\n"
|
|
50
104
|
printf "${G1}${BOLD} █▄ ${RESET}\n"
|
|
51
105
|
printf "${G2}${BOLD} ▄ ▄ ██ ${RESET}\n"
|
|
@@ -83,6 +137,14 @@ detect_platform() {
|
|
|
83
137
|
;;
|
|
84
138
|
esac
|
|
85
139
|
|
|
140
|
+
# Detect musl (Alpine): nodejs.org ships glibc builds only, so a bundled Node
|
|
141
|
+
# would segfault at runtime. Flag it now and require a system Node instead.
|
|
142
|
+
if [ "$PLATFORM" = "linux" ]; then
|
|
143
|
+
if [ -f /etc/alpine-release ] || (ldd --version 2>&1 | grep -qi musl); then
|
|
144
|
+
LIBC="musl"
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
|
|
86
148
|
printf " ${DIM}Platform: ${PLATFORM}/${NODEARCH}${RESET}\n"
|
|
87
149
|
}
|
|
88
150
|
|
|
@@ -115,35 +177,68 @@ install_node() {
|
|
|
115
177
|
fi
|
|
116
178
|
fi
|
|
117
179
|
|
|
118
|
-
|
|
180
|
+
# nodejs.org ships glibc-linked builds; on musl they segfault at runtime.
|
|
181
|
+
if [ "$LIBC" = "musl" ]; then
|
|
182
|
+
printf " ${RED}✗${RESET} Alpine/musl detected — Bloby's bundled Node.js requires glibc.\n"
|
|
183
|
+
printf " ${DIM}Install Node.js >= ${MIN_NODE_MAJOR} (e.g. ${BOLD}apk add nodejs npm${RESET}${DIM}) and re-run this installer.${RESET}\n"
|
|
184
|
+
exit 1
|
|
185
|
+
fi
|
|
119
186
|
|
|
120
|
-
|
|
121
|
-
TMPFILE=$(mktemp /tmp/node-XXXXXX.tar.xz)
|
|
187
|
+
printf " ${BLUE}↓${RESET} Downloading Node.js v${NODE_VERSION}...\n"
|
|
122
188
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
189
|
+
NODE_FILE="node-v${NODE_VERSION}-${PLATFORM}-${NODEARCH}.tar.xz"
|
|
190
|
+
NODE_URL="https://nodejs.org/dist/v${NODE_VERSION}/${NODE_FILE}"
|
|
191
|
+
# Trailing X's, NO suffix: BSD mktemp (macOS) only substitutes trailing X's,
|
|
192
|
+
# so a ".tar.xz" suffix would create a literal, predictable, non-unique file.
|
|
193
|
+
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bloby-node-XXXXXX")
|
|
194
|
+
download_file "$NODE_URL" "$TMPFILE"
|
|
195
|
+
|
|
196
|
+
# Integrity: verify against nodejs.org SHASUMS256.txt before extracting. A
|
|
197
|
+
# mismatch is fatal; an unreachable sums file or missing hash tool degrades to
|
|
198
|
+
# a warning so installs still proceed (the download itself is TLS-protected).
|
|
199
|
+
printf " ${DIM}Verifying download...${RESET}\n"
|
|
200
|
+
EXPECTED_SHA=""
|
|
201
|
+
SUMS=$(curl -fsSL --proto '=https' --tlsv1.2 --connect-timeout 20 "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" 2>/dev/null \
|
|
202
|
+
|| wget -q --https-only --timeout=20 -O- "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" 2>/dev/null \
|
|
203
|
+
|| echo "")
|
|
204
|
+
if [ -n "$SUMS" ]; then
|
|
205
|
+
EXPECTED_SHA=$(printf '%s\n' "$SUMS" | awk -v f="$NODE_FILE" '$2==f {print $1; exit}')
|
|
206
|
+
fi
|
|
207
|
+
if [ -n "$EXPECTED_SHA" ]; then
|
|
208
|
+
vrc=0
|
|
209
|
+
verify_sha256 "$TMPFILE" "$EXPECTED_SHA" || vrc=$?
|
|
210
|
+
if [ "$vrc" = "0" ]; then
|
|
211
|
+
printf " ${BLUE}✔${RESET} Checksum verified\n"
|
|
212
|
+
elif [ "$vrc" = "2" ]; then
|
|
213
|
+
printf " ${YELLOW}!${RESET} Could not compute checksum — skipping verification\n"
|
|
214
|
+
else
|
|
215
|
+
printf " ${RED}✗${RESET} Node.js checksum mismatch — aborting (corrupt or tampered download)\n"
|
|
216
|
+
exit 1
|
|
217
|
+
fi
|
|
128
218
|
else
|
|
129
|
-
printf " ${
|
|
130
|
-
exit 1
|
|
219
|
+
printf " ${YELLOW}!${RESET} Could not fetch checksums — skipping verification\n"
|
|
131
220
|
fi
|
|
132
221
|
|
|
133
|
-
# Extract
|
|
222
|
+
# Extract into a staging dir, verify it runs, then atomically swap in. An
|
|
223
|
+
# interrupt mid-extract no longer wipes the existing (working) node.
|
|
134
224
|
mkdir -p "$TOOLS_DIR"
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
tar xf "$TMPFILE" -C "$
|
|
139
|
-
rm -f "$TMPFILE"
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
225
|
+
NODE_NEW="$NODE_DIR.new"
|
|
226
|
+
rm -rf "$NODE_NEW"
|
|
227
|
+
mkdir -p "$NODE_NEW"
|
|
228
|
+
tar xf "$TMPFILE" -C "$NODE_NEW" --strip-components=1
|
|
229
|
+
rm -f "$TMPFILE"; TMPFILE=""
|
|
230
|
+
|
|
231
|
+
if ! "$NODE_NEW/bin/node" -v >/dev/null 2>&1; then
|
|
232
|
+
printf " ${RED}✗${RESET} Node.js download failed (extracted binary does not run)\n"
|
|
233
|
+
rm -rf "$NODE_NEW"
|
|
144
234
|
exit 1
|
|
145
235
|
fi
|
|
146
236
|
|
|
237
|
+
rm -rf "$NODE_DIR.old"
|
|
238
|
+
[ -e "$NODE_DIR" ] && mv "$NODE_DIR" "$NODE_DIR.old"
|
|
239
|
+
mv "$NODE_NEW" "$NODE_DIR"
|
|
240
|
+
rm -rf "$NODE_DIR.old"
|
|
241
|
+
|
|
147
242
|
printf " ${BLUE}✔${RESET} Node.js v${NODE_VERSION} installed\n"
|
|
148
243
|
}
|
|
149
244
|
|
|
@@ -174,19 +269,15 @@ install_bloby() {
|
|
|
174
269
|
exit 1
|
|
175
270
|
fi
|
|
176
271
|
|
|
177
|
-
# Download and extract tarball
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
curl -fsSL -o "$TMPDIR/bloby.tgz" "$TARBALL_URL"
|
|
181
|
-
elif command -v wget >/dev/null 2>&1; then
|
|
182
|
-
wget -qO "$TMPDIR/bloby.tgz" "$TARBALL_URL"
|
|
183
|
-
fi
|
|
272
|
+
# Download and extract tarball (download_file guards on missing curl/wget)
|
|
273
|
+
WORK_DIR=$(mktemp -d)
|
|
274
|
+
download_file "$TARBALL_URL" "$WORK_DIR/bloby.tgz"
|
|
184
275
|
|
|
185
|
-
tar xzf "$
|
|
186
|
-
EXTRACTED="$
|
|
276
|
+
tar xzf "$WORK_DIR/bloby.tgz" -C "$WORK_DIR"
|
|
277
|
+
EXTRACTED="$WORK_DIR/package"
|
|
187
278
|
|
|
188
279
|
if [ ! -d "$EXTRACTED" ]; then
|
|
189
|
-
rm -rf "$
|
|
280
|
+
rm -rf "$WORK_DIR"; WORK_DIR=""
|
|
190
281
|
printf " ${RED}✗${RESET} Installation failed\n"
|
|
191
282
|
exit 1
|
|
192
283
|
fi
|
|
@@ -219,7 +310,7 @@ install_bloby() {
|
|
|
219
310
|
fi
|
|
220
311
|
fi
|
|
221
312
|
|
|
222
|
-
rm -rf "$
|
|
313
|
+
rm -rf "$WORK_DIR"; WORK_DIR=""
|
|
223
314
|
|
|
224
315
|
# Install dependencies inside ~/.bloby/
|
|
225
316
|
# claude-agent-sdk 0.3.x moved @anthropic-ai/sdk + @modelcontextprotocol/sdk to
|
|
@@ -245,6 +336,15 @@ install_bloby() {
|
|
|
245
336
|
WS_INSTALL_LOG=$(mktemp)
|
|
246
337
|
if ! (cd "$BLOBY_HOME/workspace" && "$NPM" install --omit=dev > "$WS_INSTALL_LOG" 2>&1); then
|
|
247
338
|
printf " ${RED}✗${RESET} Workspace dependency install failed:\n"
|
|
339
|
+
# Native modules (better-sqlite3 etc.) need a compiler toolchain — detect
|
|
340
|
+
# the common "missing build tools" failure and point at the exact fix.
|
|
341
|
+
if grep -qiE 'make: .*command not found|gyp ERR! find Python|no developer tools|xcode-select|command not found: make|g\+\+: not found' "$WS_INSTALL_LOG"; then
|
|
342
|
+
if [ "$PLATFORM" = "darwin" ]; then
|
|
343
|
+
printf " ${YELLOW}!${RESET} Native build tools missing. Run ${BOLD}xcode-select --install${RESET} then re-run this installer.\n"
|
|
344
|
+
else
|
|
345
|
+
printf " ${YELLOW}!${RESET} Native build tools missing. On Debian/Ubuntu: ${BOLD}sudo apt-get install -y build-essential python3${RESET}, then re-run.\n"
|
|
346
|
+
fi
|
|
347
|
+
fi
|
|
248
348
|
cat "$WS_INSTALL_LOG"
|
|
249
349
|
rm -f "$WS_INSTALL_LOG"
|
|
250
350
|
exit 1
|
|
@@ -348,19 +448,34 @@ install_bloby
|
|
|
348
448
|
create_wrapper
|
|
349
449
|
setup_path
|
|
350
450
|
|
|
451
|
+
# Smoke-test: the wrapper + node + cli must actually run, not just exist on disk.
|
|
452
|
+
if ! "$BIN_DIR/bloby" --version >/dev/null 2>&1; then
|
|
453
|
+
printf "\n ${RED}✗${RESET} Bloby installed but failed to run (\`bloby --version\`).\n"
|
|
454
|
+
printf " ${DIM}Open a NEW terminal and run ${BOLD}bloby --version${RESET}${DIM}; if it still fails, re-run this installer.${RESET}\n\n"
|
|
455
|
+
exit 1
|
|
456
|
+
fi
|
|
457
|
+
|
|
351
458
|
printf "\n"
|
|
352
|
-
printf " ${PINK}${BOLD}✔ Bloby is
|
|
459
|
+
printf " ${PINK}${BOLD}✔ Bloby is installed!${RESET}\n"
|
|
353
460
|
printf "\n"
|
|
354
|
-
printf " ${
|
|
355
|
-
printf " ${BOLD}
|
|
461
|
+
printf " ${BLUE}${BOLD}╭───────────────────────────────────────────────────────╮${RESET}\n"
|
|
462
|
+
printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
|
|
463
|
+
printf " ${BLUE}${BOLD}│${RESET}${BOLD} NEXT STEP - type this, then press Enter:${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
464
|
+
printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
|
|
465
|
+
printf " ${BLUE}${BOLD}│${RESET}${BLUE}${BOLD} > bloby init${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
466
|
+
printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
|
|
467
|
+
printf " ${BLUE}${BOLD}│${RESET}${DIM} ─────────────────────────────────────────────────${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
468
|
+
printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
|
|
469
|
+
printf " ${BLUE}${BOLD}│${RESET}${YELLOW}${BOLD} Not working? (\"command not found\")${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
470
|
+
printf " ${BLUE}${BOLD}│${RESET} Just open a NEW terminal window and run${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
471
|
+
printf " ${BLUE}${BOLD}│${RESET} bloby init again.${RESET} ${BLUE}${BOLD}│${RESET}\n"
|
|
472
|
+
printf " ${BLUE}${BOLD}│${RESET} ${RESET}${BLUE}${BOLD}│${RESET}\n"
|
|
473
|
+
printf " ${BLUE}${BOLD}╰───────────────────────────────────────────────────────╯${RESET}\n"
|
|
356
474
|
printf "\n"
|
|
357
|
-
printf "
|
|
475
|
+
printf " ${DIM}Other commands:${RESET}\n"
|
|
358
476
|
printf " ${BLUE}bloby start${RESET} Start your bot\n"
|
|
359
477
|
printf " ${BLUE}bloby status${RESET} Check if it's running\n"
|
|
360
478
|
printf " ${BLUE}bloby help${RESET} All commands\n"
|
|
361
479
|
printf "\n"
|
|
362
|
-
printf " ${PINK}>${RESET} Run ${BLUE}bloby init${RESET} to begin.\n"
|
|
363
|
-
printf " ${DIM}(Open a new terminal if 'bloby' isn't found yet)${RESET}\n"
|
|
364
|
-
printf "\n"
|
|
365
480
|
printf " ${DIM}https://bloby.bot${RESET}\n"
|
|
366
481
|
printf "\n"
|
package/shared/config.ts
CHANGED
|
@@ -97,7 +97,20 @@ const MODEL_MIGRATIONS: Record<string, string> = {
|
|
|
97
97
|
|
|
98
98
|
export function loadConfig(): BotConfig {
|
|
99
99
|
if (!fs.existsSync(paths.config)) throw new Error('No config. Run `bloby init`.');
|
|
100
|
-
|
|
100
|
+
let config: BotConfig;
|
|
101
|
+
try {
|
|
102
|
+
config = JSON.parse(fs.readFileSync(paths.config, 'utf-8'));
|
|
103
|
+
} catch {
|
|
104
|
+
// Torn/truncated write — recover from the .bak mirror rather than crashing
|
|
105
|
+
// the supervisor (or, worse, letting the CLI regenerate a fresh wallet).
|
|
106
|
+
const bak = `${paths.config}.bak`;
|
|
107
|
+
if (fs.existsSync(bak)) {
|
|
108
|
+
config = JSON.parse(fs.readFileSync(bak, 'utf-8'));
|
|
109
|
+
try { saveConfig(config); } catch {}
|
|
110
|
+
} else {
|
|
111
|
+
throw new Error('config.json is corrupt and no backup (.bak) was found.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
101
114
|
let dirty = false;
|
|
102
115
|
|
|
103
116
|
// Backward compat: migrate old { enabled: boolean } → { mode }
|
|
@@ -119,5 +132,27 @@ export function loadConfig(): BotConfig {
|
|
|
119
132
|
|
|
120
133
|
export function saveConfig(config: BotConfig): void {
|
|
121
134
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
122
|
-
|
|
135
|
+
// Atomic write: temp file + rename, then mirror to .bak. config.json holds the
|
|
136
|
+
// funded wallet and is written by BOTH this supervisor and the CLI, so a plain
|
|
137
|
+
// writeFileSync can be observed half-written (the Jun-9 0-byte-creds class of
|
|
138
|
+
// bug, here applied to the one file that must never be lost).
|
|
139
|
+
const json = JSON.stringify(config, null, 2);
|
|
140
|
+
const tmp = `${paths.config}.${process.pid}.tmp`;
|
|
141
|
+
try {
|
|
142
|
+
fs.writeFileSync(tmp, json);
|
|
143
|
+
try {
|
|
144
|
+
fs.renameSync(tmp, paths.config);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
// Windows: rename over a file another process holds open can EPERM/EEXIST.
|
|
147
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
148
|
+
if (process.platform === 'win32' && (code === 'EPERM' || code === 'EEXIST')) {
|
|
149
|
+
fs.copyFileSync(tmp, paths.config);
|
|
150
|
+
fs.unlinkSync(tmp);
|
|
151
|
+
} else throw err;
|
|
152
|
+
}
|
|
153
|
+
try { fs.copyFileSync(paths.config, `${paths.config}.bak`); } catch {}
|
|
154
|
+
} catch (err) {
|
|
155
|
+
try { fs.unlinkSync(tmp); } catch {}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
123
158
|
}
|
package/shared/relay.ts
CHANGED
|
@@ -122,7 +122,9 @@ export function startHeartbeat(token: string, tunnelUrl?: string): void {
|
|
|
122
122
|
};
|
|
123
123
|
|
|
124
124
|
beat(); // immediate first beat
|
|
125
|
-
|
|
125
|
+
// Every 120s. The relay's HEARTBEAT_TIMEOUT_MS must stay well above this (currently
|
|
126
|
+
// 360s = 3 missed beats of grace) so a single dropped beat never flaps a healthy bot.
|
|
127
|
+
heartbeatTimer = setInterval(beat, 120_000);
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
export function stopHeartbeat(): void {
|
|
@@ -29,7 +29,7 @@ import { AlexaChannel } from './alexa.js';
|
|
|
29
29
|
import { TelegramChannel, type TelegramInbound } from './telegram.js';
|
|
30
30
|
import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, RoutingTarget, SenderRole } from './types.js';
|
|
31
31
|
import type { AgentAttachment } from '../bloby-agent.js';
|
|
32
|
-
import { saveAttachment, type SavedFile } from '../file-saver.js';
|
|
32
|
+
import { saveAttachment, MAX_ATTACHMENTS_PER_MESSAGE, MAX_TOTAL_ATTACHMENT_BYTES, type SavedFile } from '../file-saver.js';
|
|
33
33
|
import type { WAMessageKey } from '@whiskeysockets/baileys';
|
|
34
34
|
|
|
35
35
|
const MAX_CONCURRENT_AGENTS = 5;
|
|
@@ -37,16 +37,34 @@ const MAX_BUFFER_MESSAGES = 30;
|
|
|
37
37
|
const DEBOUNCE_MS = 4000; // 4s — wait for the user to finish typing
|
|
38
38
|
|
|
39
39
|
/** Persist channel-inbound attachments to disk so harnesses that consume file
|
|
40
|
-
* paths (Codex's `localImage`) can see them.
|
|
41
|
-
* attachment is dropped —
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
* paths (Codex's `localImage`) can see them. Per-file failures are logged and that
|
|
41
|
+
* attachment is dropped — one oversize/corrupt file can't abort the whole message,
|
|
42
|
+
* and text-only delivery still goes through. Bounded by MAX_ATTACHMENTS_PER_MESSAGE
|
|
43
|
+
* (count) and MAX_TOTAL_ATTACHMENT_BYTES (decoded bytes) so a single message can't
|
|
44
|
+
* flood the disk; saveAttachment itself caps each file's size. */
|
|
45
|
+
function saveInboundAttachments(attachments?: AgentAttachment[]): { saved: SavedFile[]; accepted: AgentAttachment[] } {
|
|
46
|
+
if (!attachments?.length) return { saved: [], accepted: [] };
|
|
47
|
+
const capped = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
|
|
48
|
+
if (attachments.length > capped.length) {
|
|
49
|
+
log.warn(`[channels] Dropping ${attachments.length - capped.length} inbound attachment(s) over the per-message cap (${MAX_ATTACHMENTS_PER_MESSAGE})`);
|
|
50
|
+
}
|
|
44
51
|
const saved: SavedFile[] = [];
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
// The raw attachments that actually saved within budget — handed to the harness so the
|
|
53
|
+
// model inlines exactly what got persisted + shown in chat (no over-cap divergence).
|
|
54
|
+
const accepted: AgentAttachment[] = [];
|
|
55
|
+
let totalBytes = 0;
|
|
56
|
+
for (const att of capped) {
|
|
57
|
+
// Estimate decoded size from the base64 length (×3/4) before writing so a burst of
|
|
58
|
+
// mid-size files can't blow the per-message byte budget in aggregate.
|
|
59
|
+
totalBytes += Math.floor((att.data?.length || 0) * 0.75);
|
|
60
|
+
if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
|
|
61
|
+
log.warn('[channels] Per-message attachment byte budget exceeded — dropping remaining inbound attachments');
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
try { saved.push(saveAttachment(att)); accepted.push(att); }
|
|
47
65
|
catch (err: any) { log.warn(`[channels] Failed to save inbound attachment: ${err.message}`); }
|
|
48
66
|
}
|
|
49
|
-
return saved;
|
|
67
|
+
return { saved, accepted };
|
|
50
68
|
}
|
|
51
69
|
|
|
52
70
|
interface ChannelManagerOpts {
|
|
@@ -68,6 +86,17 @@ interface BufferedMessage {
|
|
|
68
86
|
content: string;
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/** Cap a rolling per-customer history buffer AND keep the window starting on a
|
|
90
|
+
* user message. A blind length-splice can leave the window assistant-first,
|
|
91
|
+
* which the Anthropic Messages API rejects outright once the pi harness sends
|
|
92
|
+
* the buffer as structured chat history — every reply in that thread then
|
|
93
|
+
* silently fails (audit C-7). Trimming at the source fixes it for every
|
|
94
|
+
* provider flavor at once. */
|
|
95
|
+
function trimCustomerBuffer(buf: BufferedMessage[]): void {
|
|
96
|
+
if (buf.length > MAX_BUFFER_MESSAGES) buf.splice(0, buf.length - MAX_BUFFER_MESSAGES);
|
|
97
|
+
while (buf.length > 0 && buf[0].role !== 'user') buf.shift();
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
interface DebounceEntry {
|
|
72
101
|
messages: string[];
|
|
73
102
|
attachments: InboundMessageAttachment[];
|
|
@@ -133,8 +162,8 @@ export class ChannelManager {
|
|
|
133
162
|
if (channelConfigs?.whatsapp?.enabled && !this.providers.has('whatsapp')) {
|
|
134
163
|
log.info('[channels] Initializing WhatsApp channel...');
|
|
135
164
|
const whatsapp = new WhatsAppChannel(
|
|
136
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup,
|
|
137
|
-
const attachments =
|
|
165
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => {
|
|
166
|
+
const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
|
|
138
167
|
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
139
168
|
},
|
|
140
169
|
(status) => this.handleStatusChange(status),
|
|
@@ -200,7 +229,7 @@ export class ChannelManager {
|
|
|
200
229
|
}
|
|
201
230
|
}
|
|
202
231
|
const isOwner = !!ownerUserId && msg.fromUserId === String(ownerUserId);
|
|
203
|
-
const attachments = msg.
|
|
232
|
+
const attachments = msg.attachments?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
|
|
204
233
|
// Sanitize the attacker-controlled display name so it can't fake a `[Telegram | … | admin]`
|
|
205
234
|
// context tag or inject newlines into the agent's context.
|
|
206
235
|
const safeName = msg.senderName ? msg.senderName.replace(/[\[\]|\r\n]/g, ' ').slice(0, 64).trim() || undefined : undefined;
|
|
@@ -225,8 +254,8 @@ export class ChannelManager {
|
|
|
225
254
|
if (provider?.getStatus().connected) return;
|
|
226
255
|
if (!provider) {
|
|
227
256
|
const whatsapp = new WhatsAppChannel(
|
|
228
|
-
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup,
|
|
229
|
-
const attachments =
|
|
257
|
+
(sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, media, inboundKey) => {
|
|
258
|
+
const attachments = media?.map((att) => ({ type: att.type, mediaType: att.mediaType, data: att.data, name: att.name }));
|
|
230
259
|
this.handleInboundMessage('whatsapp', sender, senderName, text, fromMe, isSelfChat, chatJid, isGroup, attachments, inboundKey);
|
|
231
260
|
},
|
|
232
261
|
(status) => this.handleStatusChange(status),
|
|
@@ -600,7 +629,7 @@ export class ChannelManager {
|
|
|
600
629
|
const buf = this.customerBuffers.get(target.assistantBufferKey);
|
|
601
630
|
if (buf) {
|
|
602
631
|
buf.push({ role: 'assistant', content: eventData.content });
|
|
603
|
-
|
|
632
|
+
trimCustomerBuffer(buf);
|
|
604
633
|
}
|
|
605
634
|
}
|
|
606
635
|
return;
|
|
@@ -981,12 +1010,34 @@ export class ChannelManager {
|
|
|
981
1010
|
const channelTag = `[${this.channelLabel(msg.channel)} | ${msg.sender} | ${earlyRoleTag}]\n`;
|
|
982
1011
|
const displayContent = channelTag + rawDisplay;
|
|
983
1012
|
|
|
1013
|
+
// Convert inbound attachments to agent format and persist them to disk BEFORE the
|
|
1014
|
+
// user-message persist/broadcast — so the StoredAttachment array (filePath-based, served
|
|
1015
|
+
// at /api/files/<relPath>) can ride along in meta.attachments + chat:sync. Without this
|
|
1016
|
+
// the agent sees the media but the chat shows nothing (live or after refresh). An image
|
|
1017
|
+
// keeps an auto-generated name; a file uses the channel-provided filename. (Mirrors the
|
|
1018
|
+
// PWA path in supervisor/index.ts.)
|
|
1019
|
+
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
1020
|
+
type: att.type,
|
|
1021
|
+
name: att.type === 'image'
|
|
1022
|
+
? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}`
|
|
1023
|
+
: (att.name || `${msg.channel}_file`),
|
|
1024
|
+
mediaType: att.mediaType,
|
|
1025
|
+
data: att.data,
|
|
1026
|
+
}));
|
|
1027
|
+
// Save to disk so providers that consume file paths (Codex → localImage) can see the
|
|
1028
|
+
// attachment. Claude consumes raw base64 from `agentAttachments` directly, but the
|
|
1029
|
+
// on-disk copy is what the chat UI renders (filePath → /api/files/<relPath>).
|
|
1030
|
+
const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments);
|
|
1031
|
+
const storedAtts = savedFiles.map((f) => ({ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath }));
|
|
1032
|
+
|
|
984
1033
|
// Save user message to DB
|
|
985
1034
|
try {
|
|
1035
|
+
const userMeta: any = { model, channel: msg.channel };
|
|
1036
|
+
if (storedAtts.length) userMeta.attachments = JSON.stringify(storedAtts);
|
|
986
1037
|
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
987
1038
|
role: 'user',
|
|
988
1039
|
content: displayContent,
|
|
989
|
-
meta:
|
|
1040
|
+
meta: userMeta,
|
|
990
1041
|
});
|
|
991
1042
|
} catch (err: any) {
|
|
992
1043
|
log.warn(`[channels] DB persist error: ${err.message}`);
|
|
@@ -995,7 +1046,12 @@ export class ChannelManager {
|
|
|
995
1046
|
// Broadcast to chat clients (mirroring)
|
|
996
1047
|
broadcastBloby('chat:sync', {
|
|
997
1048
|
conversationId: convId,
|
|
998
|
-
message: {
|
|
1049
|
+
message: {
|
|
1050
|
+
role: 'user',
|
|
1051
|
+
content: displayContent,
|
|
1052
|
+
timestamp: new Date().toISOString(),
|
|
1053
|
+
attachments: storedAtts.length ? storedAtts : undefined,
|
|
1054
|
+
},
|
|
999
1055
|
});
|
|
1000
1056
|
|
|
1001
1057
|
// Fetch names and recent messages
|
|
@@ -1022,18 +1078,6 @@ export class ChannelManager {
|
|
|
1022
1078
|
// Channel context — same tag we already prepended to the stored display content
|
|
1023
1079
|
const channelContext = channelTag;
|
|
1024
1080
|
|
|
1025
|
-
// Convert inbound attachments to agent format
|
|
1026
|
-
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
1027
|
-
type: 'image' as const,
|
|
1028
|
-
name: `whatsapp_image.${att.mediaType.split('/')[1] || 'jpg'}`,
|
|
1029
|
-
mediaType: att.mediaType,
|
|
1030
|
-
data: att.data,
|
|
1031
|
-
}));
|
|
1032
|
-
// Save to disk so providers that consume file paths (Codex → localImage)
|
|
1033
|
-
// can see the attachment. Claude consumes raw base64 from `agentAttachments`
|
|
1034
|
-
// directly, but the on-disk copy is still useful for the path mention.
|
|
1035
|
-
const savedFiles = saveInboundAttachments(agentAttachments);
|
|
1036
|
-
|
|
1037
1081
|
// Show "typing..." in the correct chat
|
|
1038
1082
|
this.startTyping(msg.channel, msg.rawSender);
|
|
1039
1083
|
|
|
@@ -1116,7 +1160,7 @@ export class ChannelManager {
|
|
|
1116
1160
|
assistantBufferKey: msg.role === 'assistant' ? `${msg.channel}:${msg.sender}` : undefined,
|
|
1117
1161
|
inboundKey: msg.inboundKey,
|
|
1118
1162
|
};
|
|
1119
|
-
this.pushWithRouting(convId, target, channelContent,
|
|
1163
|
+
this.pushWithRouting(convId, target, channelContent, acceptedAttachments, savedFiles);
|
|
1120
1164
|
}
|
|
1121
1165
|
|
|
1122
1166
|
/** Synchronously handle an Alexa utterance: push into the shared conversation,
|
|
@@ -1305,10 +1349,8 @@ export class ChannelManager {
|
|
|
1305
1349
|
// Add the new user message to the buffer
|
|
1306
1350
|
buffer.push({ role: 'user', content: msg.text });
|
|
1307
1351
|
|
|
1308
|
-
// Trim buffer to max size
|
|
1309
|
-
|
|
1310
|
-
buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
|
|
1311
|
-
}
|
|
1352
|
+
// Trim buffer to max size (user-first — see trimCustomerBuffer)
|
|
1353
|
+
trimCustomerBuffer(buffer);
|
|
1312
1354
|
|
|
1313
1355
|
// Build recent messages for context (everything except the last one, which is the current message)
|
|
1314
1356
|
const recentMessages: RecentMessage[] = buffer.length > 1
|
|
@@ -1329,14 +1371,16 @@ export class ChannelManager {
|
|
|
1329
1371
|
|
|
1330
1372
|
const channelContext = `[${this.channelLabel(msg.channel)} | ${msg.sender} | customer${msg.senderName ? ` | ${msg.senderName}` : ''}]\n`;
|
|
1331
1373
|
|
|
1332
|
-
// Convert inbound attachments to agent format
|
|
1374
|
+
// Convert inbound attachments to agent format (image → auto-name; file → channel filename)
|
|
1333
1375
|
const agentAttachments: AgentAttachment[] | undefined = msg.attachments?.map((att) => ({
|
|
1334
|
-
type:
|
|
1335
|
-
name:
|
|
1376
|
+
type: att.type,
|
|
1377
|
+
name: att.type === 'image'
|
|
1378
|
+
? `${msg.channel}_image.${att.mediaType.split('/')[1] || 'jpg'}`
|
|
1379
|
+
: (att.name || `${msg.channel}_file`),
|
|
1336
1380
|
mediaType: att.mediaType,
|
|
1337
1381
|
data: att.data,
|
|
1338
1382
|
}));
|
|
1339
|
-
const savedFiles = saveInboundAttachments(agentAttachments);
|
|
1383
|
+
const { saved: savedFiles, accepted: acceptedAttachments } = saveInboundAttachments(agentAttachments);
|
|
1340
1384
|
|
|
1341
1385
|
// Stable convId per customer (not per message)
|
|
1342
1386
|
const convId = `channel-${agentKey}`;
|
|
@@ -1376,9 +1420,7 @@ export class ChannelManager {
|
|
|
1376
1420
|
if (type === 'bot:response' && eventData.content) {
|
|
1377
1421
|
// Add full response to the conversation buffer
|
|
1378
1422
|
buffer!.push({ role: 'assistant', content: eventData.content });
|
|
1379
|
-
|
|
1380
|
-
buffer!.splice(0, buffer!.length - MAX_BUFFER_MESSAGES);
|
|
1381
|
-
}
|
|
1423
|
+
trimCustomerBuffer(buffer!);
|
|
1382
1424
|
|
|
1383
1425
|
// Send remaining text after the last tool use (or full response if no tools were used)
|
|
1384
1426
|
const remaining = waChunkBuf.trim();
|
|
@@ -1397,7 +1439,7 @@ export class ChannelManager {
|
|
|
1397
1439
|
this.processQueue();
|
|
1398
1440
|
}
|
|
1399
1441
|
},
|
|
1400
|
-
|
|
1442
|
+
acceptedAttachments,
|
|
1401
1443
|
savedFiles,
|
|
1402
1444
|
{ botName, humanName },
|
|
1403
1445
|
recentMessages,
|
|
@@ -1424,9 +1466,7 @@ export class ChannelManager {
|
|
|
1424
1466
|
}
|
|
1425
1467
|
const label = fromMe ? 'me' : (senderName || chatId);
|
|
1426
1468
|
buffer.push({ role: 'user', content: `[${label}]: ${text}` });
|
|
1427
|
-
|
|
1428
|
-
buffer.splice(0, buffer.length - MAX_BUFFER_MESSAGES);
|
|
1429
|
-
}
|
|
1469
|
+
trimCustomerBuffer(buffer);
|
|
1430
1470
|
log.info(`[channels] Assistant context stored: ${bufferKey} | ${buffer.length} msgs | [${label}]: "${text.slice(0, 60)}"`);
|
|
1431
1471
|
}
|
|
1432
1472
|
|