Compare commits

...

13 Commits

Author SHA1 Message Date
guitaripod 5919becab9 fix(telegram): route image-extension Documents through vision pipeline (#3457)
Documents with image extensions (jpg, png, etc.) are routed to
[Document: name] /path instead of [IMAGE:/path], bypassing the
multimodal pipeline entirely. This causes the model to have no vision
input for images sent as Telegram Documents.

Re-applies fix from merged dev PR #1631 which was lost during the
master branch migration.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 09:54:41 -04:00
guitaripod 287d9bdc17 fix(telegram): handle brackets in attachment filenames (#3458)
parse_attachment_markers uses .find(']') which matches the first ]
in the content. Filenames containing brackets (e.g. yt-dlp output
'Video [G4PvTrTp7Tc].mp4') get truncated at the inner bracket,
causing the send to fail with 'path not found'.

Uses depth-tracking bracket matching instead.

Re-applies fix from merged dev PR #1632 which was lost during the
master branch migration.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 09:52:37 -04:00
guitaripod f900d7079e feat(channels): add show_tool_calls config to suppress tool notifications (#3480)
* feat(channels): add show_tool_calls config to suppress tool notifications

When show_tool_calls is false, the ChannelNotifyObserver drains tool
events silently instead of forwarding them as individual messages to
the channel. Server-side logs remain unaffected.

Defaults to true for backwards compatibility.

* docs: add before/after screenshots for show_tool_calls PR

* docs(config): add doc comment on show_tool_calls field

---------

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 09:49:35 -04:00
Argenis 5a5b5a4402 fix(tweet): prevent duplicates, show contributor count, add hashtags (#3481)
- Only tweet when there are NEW feat() commits since the previous
  release (including betas) — skips if no new features to announce
- Feature diff uses previous release tag (beta-to-beta) to prevent
  listing the same features across consecutive releases
- Contributor count uses stable-to-HEAD range for full cycle credit
- Shows count instead of names to avoid 280 char clipping
- Added #zeroclaw #rust #ai #opensource hashtags

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 09:41:18 -04:00
Argenis 06f65fb711 feat(release): bump to v0.2.0 with auto-tweet and contributor notes (#3475)
* feat(release): bump to v0.2.0, auto-tweet with features + contributors

- Bump version from 0.1.9 to 0.2.0
- Release notes now auto-generate from feat() commits only (no bug
  fixes) keeping them clean and concise
- Contributors are always featured in release notes, pulled from
  git log authors and Co-Authored-By trailers
- Tweet workflow now fires on all releases (beta + stable), auto-
  generates punchy feature-focused tweets with contributor shoutouts
- Stable release workflow gets the same release notes treatment
- Tweet gracefully skips if Twitter secrets aren't configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(release): credit all contributors, wider range, filter bots

- Use wider git range for contributor collection — skips same-version
  betas to capture everyone who contributed to the release, not just
  the last incremental push
- Filter out bot accounts (dependabot, github-actions, copilot, etc.)
  from contributor lists
- Tweet shows up to 6 contributors with "+ N more" when there are
  extras, ensuring everyone gets credit
- Deduplicate contributors across git authors and Co-Authored-By
- Deduplicate features with sort -uf for cleaner notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(release): use last stable tag for contributor range, not last beta

Ensures the tweet and release notes capture ALL contributors since the
last stable release (e.g. v0.1.9a), not just since the last beta tag.
This gives proper credit to everyone who contributed across the full
release cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 07:59:04 -04:00
Argenis 46d4b13c22 feat(install): branded one-click installer with secure pairing flow (#3471)
* feat(install): consolidate one-click installer with branded output and inline onboarding

- Add blue color scheme with 🦀 crab emoji branding throughout installer
- Add structured [1/3] [2/3] [3/3] step output with ✓/·/✗ indicators
- Consolidate onboarding into install.sh: inline provider selection menu,
  API key prompt, and model override — no separate wizard step needed
- Replace --onboard/--interactive-onboard with --skip-onboard (opt-out)
- Add OS detection display, install method, version detection, upgrade vs
  fresh install logic
- Add post-install gateway service install/restart, doctor health check
- Add dashboard URL (port 42617) with clipboard copy and browser auto-open
- Add docs link (https://www.zeroclawlabs.ai/docs) to success output
- Display pairing code after onboarding in Rust CLI (src/main.rs)
- Remove --interactive flag from `zeroclaw onboard` CLI command
- Remove redundant scripts/install-release.sh legacy redirect
- Update all --interactive references across codebase

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(onboard): auto-pair and include bearer token in dashboard URL

After onboarding, the CLI now auto-pairs using the generated pairing
code to produce a bearer token, then displays the dashboard URL with
the token embedded (e.g. http://127.0.0.1:42617?token=zc_...) so
users can access the dashboard immediately without a separate pairing
step. The token is also persisted to config for gateway restarts.

The install script captures this token-bearing URL from the onboard
output and uses it for clipboard copy and browser auto-open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* security(onboard): revert token-in-URL, keep pairing code terminal-only

Removes the auto-pair + token-in-URL approach in favor of the original
secure pairing flow. Bearer tokens should never appear in URLs where
they can leak via browser history, Referer headers, clipboard, or
proxy logs. The pairing code stays in the terminal and the user enters
it in the dashboard to complete the handshake securely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply cargo fmt formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 07:33:14 -04:00
Jacobinwwey 8fcbb6eb2d fix(channels): harden slack threading and utf8 truncation (#3461)
* fix(channels): harden slack threading and utf8 truncation

* refactor(channel): collapse interrupt flags to satisfy clippy

---------

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 07:31:10 -04:00
Argenis ce22eba7d0 fix(channels): proactively trim conversation history before provider call (#3473)
Conversation history on long-running channel sessions (e.g. Feishu) grew
unbounded until the provider returned a context-window-exceeded error.
The existing reactive compaction only kicked in *after* the error,
causing the user's message to be lost and requiring a resend.

Add proactive_trim_turns() which estimates total character count and
drops the oldest turns before the request reaches the provider.  The
budget (400 k chars ≈ 100 k tokens) leaves headroom for system prompt,
memory context, and model output.

Closes #3460
2026-03-14 07:15:34 -04:00
Argenis 7ba4d06e78 fix(docs): use absolute URL for install.sh One-Click Setup link (#3470)
The relative href="install.sh" in README nav headers resolves to
zeroclawlabs.ai/install.sh when viewed on the website, which returns
404 since the website does not serve repo-root files. Replace with
the raw.githubusercontent.com URL used elsewhere in the docs.

Fixes #3463
2026-03-14 07:14:47 -04:00
lilstaz dc12d03876 feat(gateway): enable multi-turn chat for WebSocket connections (#3467)
Replace single-turn chat with persistent Agent to maintain conversation
history across WebSocket turns within the same connection.

Co-authored-by: staz <starzwan2333@gmail.com>
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 07:04:59 -04:00
Argenis 3151604b04 fix(ci): include install.sh in release assets and website dispatch (#3469)
The website at zeroclawlabs.ai shows a copy button with
curl -fsSL https://zeroclawlabs.ai/install.sh | bash but the website
does not serve the script, returning 404.

Add install.sh as a GitHub release asset in both beta and stable
workflows so it is always available at a stable URL. Pass the raw
GitHub URL in the website redeploy dispatch payload so the website
can configure a redirect or proxy for /install.sh.

Closes #3463
2026-03-14 07:04:17 -04:00
guitaripod c5fcda06ad fix(agent): add channel media markers to system prompt (#3459)
The system prompt has no documentation of channel media markers
([Voice], [IMAGE:], [Document:]), causing the LLM to misinterpret
transcribed voice messages as unprocessable audio attachments instead
of responding to the transcribed text content.

Re-applies fix from merged dev PR #1697 which was lost during the
master branch migration.

Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 06:58:40 -04:00
Ericsunsk 51a52dcadb fix(memory): pass embedding_routes in gateway and agent loop (#3462)
Co-authored-by: Argenis <theonlyhennygod@gmail.com>
2026-03-14 06:56:55 -04:00
27 changed files with 1288 additions and 390 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+94 -3
View File
@@ -37,6 +37,96 @@ jobs:
echo "tag=${beta_tag}" >> "$GITHUB_OUTPUT"
echo "Beta release: ${beta_tag}"
release-notes:
name: Generate Release Notes
runs-on: ubuntu-latest
outputs:
notes: ${{ steps.notes.outputs.body }}
features: ${{ steps.notes.outputs.features }}
contributors: ${{ steps.notes.outputs.contributors }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Build release notes
id: notes
shell: bash
run: |
set -euo pipefail
# Use a wider range — find the previous stable tag to capture all
# contributors across the full release cycle, not just one beta bump
PREV_TAG=$(git tag --sort=-creatordate \
| grep -vE '\-beta\.' \
| head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
RANGE="HEAD"
else
RANGE="${PREV_TAG}..HEAD"
fi
# Extract features only (feat commits) — skip bug fixes for clean notes
FEATURES=$(git log "$RANGE" --pretty=format:"%s" --no-merges \
| grep -iE '^feat(\(|:)' \
| sed 's/^feat(\([^)]*\)): /\1: /' \
| sed 's/^feat: //' \
| sed 's/ (#[0-9]*)$//' \
| sort -uf \
| while IFS= read -r line; do echo "- ${line}"; done || true)
if [ -z "$FEATURES" ]; then
FEATURES="- Incremental improvements and polish"
fi
# Collect ALL unique contributors: git authors + Co-Authored-By
GIT_AUTHORS=$(git log "$RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
CO_AUTHORS=$(git log "$RANGE" --pretty=format:"%b" --no-merges \
| grep -ioE 'Co-Authored-By: *[^<]+' \
| sed 's/Co-Authored-By: *//i' \
| sed 's/ *$//' \
| sort -uf || true)
# Merge, deduplicate, and filter out bots
ALL_CONTRIBUTORS=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
| sort -uf \
| grep -v '^$' \
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
| while IFS= read -r name; do echo "- ${name}"; done || true)
# Build release body
BODY=$(cat <<NOTES_EOF
## What's New
${FEATURES}
## Contributors
${ALL_CONTRIBUTORS}
---
*Full changelog: ${PREV_TAG}...HEAD*
NOTES_EOF
)
# Output multiline values
{
echo "body<<BODY_EOF"
echo "$BODY"
echo "BODY_EOF"
} >> "$GITHUB_OUTPUT"
{
echo "features<<FEAT_EOF"
echo "$FEATURES"
echo "FEAT_EOF"
} >> "$GITHUB_OUTPUT"
{
echo "contributors<<CONTRIB_EOF"
echo "$ALL_CONTRIBUTORS"
echo "CONTRIB_EOF"
} >> "$GITHUB_OUTPUT"
web:
name: Build Web Dashboard
runs-on: ubuntu-latest
@@ -132,7 +222,7 @@ jobs:
publish:
name: Publish Beta Release
needs: [version, build]
needs: [version, release-notes, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -154,9 +244,10 @@ jobs:
tag_name: ${{ needs.version.outputs.tag }}
name: ${{ needs.version.outputs.tag }}
prerelease: true
generate_release_notes: true
body: ${{ needs.release-notes.outputs.notes }}
files: |
artifacts/**/*
install.sh
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
@@ -168,7 +259,7 @@ jobs:
-H "Authorization: token $PAT" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/zeroclaw-labs/zeroclaw-website/dispatches \
-d '{"event_type":"new-release"}'
-d '{"event_type":"new-release","client_payload":{"install_script_url":"https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh"}}'
docker:
name: Push Docker Image
+77 -3
View File
@@ -74,6 +74,79 @@ jobs:
path: web/dist/
retention-days: 1
release-notes:
name: Generate Release Notes
runs-on: ubuntu-latest
outputs:
notes: ${{ steps.notes.outputs.body }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Build release notes
id: notes
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# Find the previous stable tag (exclude beta tags)
PREV_TAG=$(git tag --sort=-creatordate | grep -vE '\-beta\.' | grep -v "^v${INPUT_VERSION}$" | head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
RANGE="HEAD"
else
RANGE="${PREV_TAG}..HEAD"
fi
# Extract features only — skip bug fixes for clean release notes
FEATURES=$(git log "$RANGE" --pretty=format:"%s" --no-merges \
| grep -iE '^feat(\(|:)' \
| sed 's/^feat(\([^)]*\)): /\1: /' \
| sed 's/^feat: //' \
| sed 's/ (#[0-9]*)$//' \
| sort -uf \
| while IFS= read -r line; do echo "- ${line}"; done || true)
if [ -z "$FEATURES" ]; then
FEATURES="- Incremental improvements and polish"
fi
# Collect ALL unique contributors: git authors + Co-Authored-By
GIT_AUTHORS=$(git log "$RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
CO_AUTHORS=$(git log "$RANGE" --pretty=format:"%b" --no-merges \
| grep -ioE 'Co-Authored-By: *[^<]+' \
| sed 's/Co-Authored-By: *//i' \
| sed 's/ *$//' \
| sort -uf || true)
# Merge, deduplicate, and filter out bots
ALL_CONTRIBUTORS=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
| sort -uf \
| grep -v '^$' \
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
| while IFS= read -r name; do echo "- ${name}"; done || true)
BODY=$(cat <<NOTES_EOF
## What's New
${FEATURES}
## Contributors
${ALL_CONTRIBUTORS}
---
*Full changelog: ${PREV_TAG}...v${INPUT_VERSION}*
NOTES_EOF
)
{
echo "body<<BODY_EOF"
echo "$BODY"
echo "BODY_EOF"
} >> "$GITHUB_OUTPUT"
build:
name: Build ${{ matrix.target }}
needs: [validate, web]
@@ -150,7 +223,7 @@ jobs:
publish:
name: Publish Stable Release
needs: [validate, build]
needs: [validate, release-notes, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -172,9 +245,10 @@ jobs:
tag_name: ${{ needs.validate.outputs.tag }}
name: ${{ needs.validate.outputs.tag }}
prerelease: false
generate_release_notes: true
body: ${{ needs.release-notes.outputs.notes }}
files: |
artifacts/**/*
install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -186,7 +260,7 @@ jobs:
-H "Authorization: token $PAT" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/zeroclaw-labs/zeroclaw-website/dispatches \
-d '{"event_type":"new-release"}'
-d '{"event_type":"new-release","client_payload":{"install_script_url":"https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh"}}'
docker:
name: Push Docker Image
+99 -24
View File
@@ -16,43 +16,108 @@ on:
jobs:
tweet:
# Skip beta pre-releases on auto trigger
if: >-
github.event_name == 'workflow_dispatch' ||
!github.event.release.prerelease
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- name: Check for new features
id: check
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
MANUAL_TEXT: ${{ inputs.tweet_text || '' }}
run: |
# Manual dispatch always proceeds
if [ -n "$MANUAL_TEXT" ]; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Find the PREVIOUS release tag (including betas) to check for new features
PREV_TAG=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count new feat() commits since the previous release
NEW_FEATS=$(git log "${PREV_TAG}..${RELEASE_TAG}" --pretty=format:"%s" --no-merges \
| grep -ciE '^feat(\(|:)' || echo "0")
if [ "$NEW_FEATS" -eq 0 ]; then
echo "No new features since ${PREV_TAG} — skipping tweet"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "${NEW_FEATS} new feature(s) since ${PREV_TAG} — tweeting"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Build tweet text
id: tweet
if: steps.check.outputs.skip != 'true'
shell: bash
env:
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
RELEASE_URL: ${{ github.event.release.html_url || '' }}
RELEASE_BODY: ${{ github.event.release.body || '' }}
MANUAL_TEXT: ${{ inputs.tweet_text || '' }}
run: |
set -euo pipefail
if [ -n "$MANUAL_TEXT" ]; then
# Manual dispatch — use the custom text as-is
TWEET="$MANUAL_TEXT"
else
# Auto trigger — look for <!-- tweet --> block in release body
# Format in your release notes:
# <!-- tweet -->
# ZeroClaw v0.2.0 🐾
#
# 🚀 feature one
# 🔧 feature two
#
# the claw strikes
# <!-- /tweet -->
TWEET_BLOCK=$(echo "$RELEASE_BODY" | sed -n '/<!-- tweet -->/,/<!-- \/tweet -->/p' | sed '1d;$d' || true)
# For features: diff against the PREVIOUS release (including betas)
# This prevents duplicate feature lists across consecutive betas
PREV_RELEASE=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| head -1 || echo "")
if [ -n "$TWEET_BLOCK" ]; then
TWEET="$TWEET_BLOCK"
else
# Fallback: auto-generate a simple announcement
TWEET=$(printf "ZeroClaw %s 🐾\n\nFull release notes 👇\n%s" "$RELEASE_TAG" "$RELEASE_URL")
# For contributors: diff against the last STABLE release
# This captures everyone across the full release cycle
PREV_STABLE=$(git tag --sort=-creatordate \
| grep -v "^${RELEASE_TAG}$" \
| grep -vE '\-beta\.' \
| head -1 || echo "")
FEAT_RANGE="${PREV_RELEASE:+${PREV_RELEASE}..}${RELEASE_TAG}"
CONTRIB_RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
# Extract NEW features only since the last release
FEATURES=$(git log "$FEAT_RANGE" --pretty=format:"%s" --no-merges \
| grep -iE '^feat(\(|:)' \
| sed 's/^feat(\([^)]*\)): /\1: /' \
| sed 's/^feat: //' \
| sed 's/ (#[0-9]*)$//' \
| sort -uf \
| head -4 \
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
if [ -z "$FEATURES" ]; then
FEATURES="🚀 Incremental improvements and polish"
fi
# Count ALL contributors across the full release cycle
GIT_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
CO_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%b" --no-merges \
| grep -ioE 'Co-Authored-By: *[^<]+' \
| sed 's/Co-Authored-By: *//i' \
| sed 's/ *$//' \
| sort -uf || true)
TOTAL_COUNT=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
| sort -uf \
| grep -v '^$' \
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
| grep -c . || echo "0")
# Build tweet — new features, contributor count, hashtags
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\n🙌 %s contributors\n\n%s\n\n#zeroclaw #rust #ai #opensource" \
"$RELEASE_TAG" "$FEATURES" "$TOTAL_COUNT" "$RELEASE_URL")
fi
# Append release URL if not already present and we have one
@@ -60,6 +125,11 @@ jobs:
TWEET=$(printf "%s\n\n%s" "$TWEET" "$RELEASE_URL")
fi
# Truncate to 280 chars if needed
if [ ${#TWEET} -gt 280 ]; then
TWEET="${TWEET:0:277}..."
fi
echo "--- Tweet preview ---"
echo "$TWEET"
echo "--- ${#TWEET} chars ---"
@@ -71,6 +141,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Post to X
if: steps.check.outputs.skip != 'true'
shell: bash
env:
TWITTER_CONSUMER_KEY: ${{ secrets.TWITTER_CONSUMER_API_KEY }}
@@ -82,6 +153,12 @@ jobs:
run: |
set -euo pipefail
# Skip if Twitter secrets are not configured
if [ -z "$TWITTER_CONSUMER_KEY" ] || [ -z "$TWITTER_ACCESS_TOKEN" ]; then
echo "::warning::Twitter secrets not configured — skipping tweet"
exit 0
fi
pip install requests requests-oauthlib --quiet
python3 - <<'PYEOF'
@@ -112,7 +189,6 @@ jobs:
img_resp.raise_for_status()
content_type = img_resp.headers.get("content-type", "image/png")
# X media upload (v1.1 chunked INIT/APPEND/FINALIZE)
init_resp = oauth.post(
"https://upload.twitter.com/1.1/media/upload.json",
data={
@@ -144,7 +220,6 @@ jobs:
print(f"Media FINALIZE failed: {fin_resp.status_code} {fin_resp.text}", file=sys.stderr)
sys.exit(1)
# Wait for processing if needed
state = fin_resp.json().get("processing_info", {}).get("state")
while state == "pending" or state == "in_progress":
wait = fin_resp.json().get("processing_info", {}).get("check_after_secs", 2)
Generated
+1 -1
View File
@@ -7924,7 +7924,7 @@ dependencies = [
[[package]]
name = "zeroclaw"
version = "0.1.9"
version = "0.2.0"
dependencies = [
"anyhow",
"async-imap",
+1 -1
View File
@@ -4,7 +4,7 @@ resolver = "2"
[package]
name = "zeroclaw"
version = "0.1.9"
version = "0.2.0"
edition = "2021"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
+1 -1
View File
@@ -58,7 +58,7 @@ Construit par des étudiants et membres des communautés Harvard, MIT et Sundai.
<p align="center">
<a href="#démarrage-rapide">Démarrage</a> |
<a href="install.sh">Configuration en un clic</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">Configuration en un clic</a> |
<a href="docs/README.md">Hub Documentation</a> |
<a href="docs/SUMMARY.md">Table des matières Documentation</a>
</p>
+1 -1
View File
@@ -53,7 +53,7 @@
</p>
<p align="center">
<a href="install.sh">ワンクリック導入</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">ワンクリック導入</a> |
<a href="docs/setup-guides/README.md">導入ガイド</a> |
<a href="docs/README.ja.md">ドキュメントハブ</a> |
<a href="docs/SUMMARY.md">Docs TOC</a>
+1 -1
View File
@@ -58,7 +58,7 @@ Built by students and members of the Harvard, MIT, and Sundai.Club communities.
<p align="center">
<a href="#quick-start">Getting Started</a> |
<a href="install.sh">One-Click Setup</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">One-Click Setup</a> |
<a href="docs/README.md">Docs Hub</a> |
<a href="docs/SUMMARY.md">Docs TOC</a>
</p>
+1 -1
View File
@@ -53,7 +53,7 @@
</p>
<p align="center">
<a href="install.sh">Установка в 1 клик</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">Установка в 1 клик</a> |
<a href="docs/setup-guides/README.md">Быстрый старт</a> |
<a href="docs/README.ru.md">Хаб документации</a> |
<a href="docs/SUMMARY.md">TOC docs</a>
+1 -1
View File
@@ -58,7 +58,7 @@
<p align="center">
<a href="#quick-start">Bắt đầu</a> |
<a href="install.sh">Cài đặt một lần bấm</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">Cài đặt một lần bấm</a> |
<a href="docs/i18n/vi/README.md">Trung tâm tài liệu</a> |
<a href="docs/SUMMARY.md">Mục lục tài liệu</a>
</p>
+1 -1
View File
@@ -53,7 +53,7 @@
</p>
<p align="center">
<a href="install.sh">一键部署</a> |
<a href="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh">一键部署</a> |
<a href="docs/i18n/zh-CN/setup-guides/README.zh-CN.md">安装入门</a> |
<a href="docs/README.zh-CN.md">文档总览</a> |
<a href="docs/SUMMARY.zh-CN.md">文档目录</a>
+426 -210
View File
@@ -47,61 +47,89 @@ fi
# --- From here on, we are running under bash ---
set -euo pipefail
# --- Color and styling ---
if [[ -t 1 ]]; then
BLUE='\033[0;34m'
BOLD_BLUE='\033[1;34m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
BLUE='' BOLD_BLUE='' GREEN='' YELLOW='' RED='' BOLD='' DIM='' RESET=''
fi
CRAB="🦀"
info() {
echo "==> $*"
echo -e "${BLUE}${CRAB}${RESET} ${BOLD}$*${RESET}"
}
step_ok() {
echo -e " ${GREEN}${RESET} $*"
}
step_dot() {
echo -e " ${DIM}·${RESET} $*"
}
step_fail() {
echo -e " ${RED}${RESET} $*"
}
warn() {
echo "warning: $*" >&2
echo -e "${YELLOW}!${RESET} $*" >&2
}
error() {
echo "error: $*" >&2
echo -e "${RED}${RESET} ${RED}$*${RESET}" >&2
}
usage() {
cat <<'USAGE'
ZeroClaw installer
ZeroClaw installer — one-click bootstrap
Usage:
./install.sh [options]
Modes:
Default mode installs/builds ZeroClaw only (requires existing Rust toolchain).
Guided mode asks setup questions and configures options interactively.
Optional bootstrap mode can also install system dependencies and Rust.
The installer builds ZeroClaw, configures your provider and API key,
starts the gateway service, and opens the dashboard — all in one step.
Options:
--guided Run interactive guided installer
--guided Run interactive guided installer (default on Linux TTY)
--no-guided Disable guided installer
--docker Run install in Docker-compatible mode and launch onboarding inside the container
--docker Run install in Docker-compatible mode
--install-system-deps Install build dependencies (Linux/macOS)
--install-rust Install Rust via rustup if missing
--prefer-prebuilt Try latest release binary first; fallback to source build on miss
--prebuilt-only Install only from latest release binary (no source build fallback)
--force-source-build Disable prebuilt flow and always build from source
--onboard Run onboarding after install
--interactive-onboard Run interactive onboarding (implies --onboard)
--api-key <key> API key for non-interactive onboarding
--provider <id> Provider for non-interactive onboarding (default: openrouter)
--model <id> Model for non-interactive onboarding (optional)
--api-key <key> API key (skips interactive prompt)
--provider <id> Provider (default: openrouter)
--model <id> Model (optional)
--skip-onboard Skip provider/API key configuration
--skip-build Skip build step
--skip-install Skip cargo install step
--build-first Alias for explicitly enabling separate `cargo build --release --locked`
--skip-build Skip build step (`cargo build --release --locked` or Docker image build)
--skip-install Skip `cargo install --path . --force --locked`
-h, --help Show help
Examples:
./install.sh
./install.sh --guided
./install.sh --install-system-deps --install-rust
./install.sh --prefer-prebuilt
./install.sh --prebuilt-only
./install.sh --onboard --api-key "sk-..." --provider openrouter [--model "openrouter/auto"]
./install.sh --interactive-onboard
# One-click install (interactive)
curl -fsSL https://zeroclawlabs.ai/install.sh | bash
# Non-interactive with API key
./install.sh --api-key "sk-..." --provider openrouter
# Prebuilt binary (fastest)
./install.sh --prefer-prebuilt --api-key "sk-..."
# Docker deploy
./install.sh --docker
# Remote one-liner
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
# Build only, configure later
./install.sh --skip-onboard
Environment:
ZEROCLAW_CONTAINER_CLI Container CLI command (default: docker; auto-fallback: podman)
@@ -268,7 +296,7 @@ install_prebuilt_binary() {
temp_dir="$(mktemp -d -t zeroclaw-prebuilt-XXXXXX)"
archive_path="$temp_dir/${asset_name}"
info "Attempting pre-built binary install for target: $target"
step_dot "Attempting pre-built binary install for target: $target"
if ! curl -fsSL "$archive_url" -o "$archive_path"; then
warn "Could not download release asset: $archive_url"
rm -rf "$temp_dir"
@@ -296,7 +324,7 @@ install_prebuilt_binary() {
install -m 0755 "$extracted_bin" "$install_dir/zeroclaw"
rm -rf "$temp_dir"
info "Installed pre-built binary to $install_dir/zeroclaw"
step_ok "Installed pre-built binary to $install_dir/zeroclaw"
if [[ ":$PATH:" != *":$install_dir:"* ]]; then
warn "$install_dir is not in PATH for this shell."
warn "Run: export PATH=\"$install_dir:\$PATH\""
@@ -463,16 +491,16 @@ prompt_yes_no() {
}
install_system_deps() {
info "Installing system dependencies"
step_dot "Installing system dependencies"
case "$(uname -s)" in
Linux)
if have_cmd apk; then
find_missing_alpine_prereqs
if [[ ${#ALPINE_MISSING_PKGS[@]} -eq 0 ]]; then
info "Alpine prerequisites already installed"
step_ok "Alpine prerequisites already installed"
else
info "Installing Alpine prerequisites: ${ALPINE_MISSING_PKGS[*]}"
step_dot "Installing Alpine prerequisites: ${ALPINE_MISSING_PKGS[*]}"
run_privileged apk add --no-cache "${ALPINE_MISSING_PKGS[@]}"
fi
elif have_cmd apt-get; then
@@ -505,7 +533,7 @@ install_system_deps() {
;;
Darwin)
if ! xcode-select -p >/dev/null 2>&1; then
info "Installing Xcode Command Line Tools"
step_dot "Installing Xcode Command Line Tools"
xcode-select --install || true
cat <<'MSG'
Please complete the Xcode Command Line Tools installation dialog,
@@ -525,7 +553,7 @@ MSG
install_rust_toolchain() {
if have_cmd cargo && have_cmd rustc; then
info "Rust already installed: $(rustc --version)"
step_ok "Rust already installed: $(rustc --version)"
return
fi
@@ -534,7 +562,7 @@ install_rust_toolchain() {
exit 1
fi
info "Installing Rust via rustup"
step_dot "Installing Rust via rustup"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
if [[ -f "$HOME/.cargo/env" ]]; then
@@ -549,11 +577,98 @@ install_rust_toolchain() {
fi
}
prompt_provider() {
local provider_input=""
echo
echo -e " ${BOLD}Select your AI provider${RESET}"
echo -e " ${DIM}(press Enter for default: ${PROVIDER})${RESET}"
echo
echo -e " ${BOLD_BLUE}1)${RESET} OpenRouter ${DIM}(recommended — multi-model gateway)${RESET}"
echo -e " ${BOLD_BLUE}2)${RESET} Anthropic ${DIM}(Claude)${RESET}"
echo -e " ${BOLD_BLUE}3)${RESET} OpenAI ${DIM}(GPT)${RESET}"
echo -e " ${BOLD_BLUE}4)${RESET} Gemini ${DIM}(Google)${RESET}"
echo -e " ${BOLD_BLUE}5)${RESET} Ollama ${DIM}(local, no API key needed)${RESET}"
echo -e " ${BOLD_BLUE}6)${RESET} Groq ${DIM}(fast inference)${RESET}"
echo -e " ${BOLD_BLUE}7)${RESET} Venice ${DIM}(privacy-focused)${RESET}"
echo -e " ${BOLD_BLUE}8)${RESET} Other ${DIM}(enter provider ID manually)${RESET}"
echo
if ! guided_read provider_input " Provider [1]: "; then
error "input was interrupted."
exit 1
fi
case "${provider_input:-1}" in
1|"") PROVIDER="openrouter" ;;
2) PROVIDER="anthropic" ;;
3) PROVIDER="openai" ;;
4) PROVIDER="gemini" ;;
5) PROVIDER="ollama" ;;
6) PROVIDER="groq" ;;
7) PROVIDER="venice" ;;
8)
if ! guided_read provider_input " Provider ID: "; then
error "input was interrupted."
exit 1
fi
if [[ -n "$provider_input" ]]; then
PROVIDER="$provider_input"
fi
;;
*) PROVIDER="openrouter" ;;
esac
}
prompt_api_key() {
local api_key_input=""
if [[ "$PROVIDER" == "ollama" ]]; then
step_ok "Ollama selected — no API key required"
return 0
fi
echo
if [[ -n "$API_KEY" ]]; then
step_ok "API key provided via environment/flag"
return 0
fi
echo -e " ${BOLD}Enter your ${PROVIDER} API key${RESET}"
echo -e " ${DIM}(input is hidden; leave empty to configure later)${RESET}"
echo
if ! guided_read api_key_input " API key: " true; then
echo
error "input was interrupted."
exit 1
fi
echo
if [[ -n "$api_key_input" ]]; then
API_KEY="$api_key_input"
step_ok "API key set"
else
warn "No API key entered — you can configure it later with zeroclaw onboard"
SKIP_ONBOARD=true
fi
}
prompt_model() {
local model_input=""
echo -e " ${DIM}Model (press Enter for provider default):${RESET}"
if ! guided_read model_input " Model [default]: "; then
error "input was interrupted."
exit 1
fi
if [[ -n "$model_input" ]]; then
MODEL="$model_input"
fi
}
run_guided_installer() {
local os_name="$1"
local provider_input=""
local model_input=""
local api_key_input=""
if ! guided_input_stream >/dev/null; then
error "guided installer requires an interactive terminal."
@@ -562,10 +677,11 @@ run_guided_installer() {
fi
echo
echo "ZeroClaw guided installer"
echo "Answer a few questions, then the installer will run automatically."
echo -e " ${BOLD_BLUE}${CRAB} ZeroClaw Guided Installer${RESET}"
echo -e " ${DIM}Answer a few questions, then the installer will handle everything.${RESET}"
echo
# --- System dependencies ---
if [[ "$os_name" == "Linux" ]]; then
if prompt_yes_no "Install Linux build dependencies (toolchain/pkg-config/git/curl)?" "yes"; then
INSTALL_SYSTEM_DEPS=true
@@ -576,89 +692,34 @@ run_guided_installer() {
fi
fi
# --- Rust toolchain ---
if have_cmd cargo && have_cmd rustc; then
info "Detected Rust toolchain: $(rustc --version)"
step_ok "Detected Rust toolchain: $(rustc --version)"
else
if prompt_yes_no "Rust toolchain not found. Install Rust via rustup now?" "yes"; then
INSTALL_RUST=true
fi
fi
if prompt_yes_no "Run a separate prebuild before install?" "yes"; then
SKIP_BUILD=false
else
SKIP_BUILD=true
fi
if prompt_yes_no "Install zeroclaw into cargo bin now?" "yes"; then
SKIP_INSTALL=false
else
SKIP_INSTALL=true
fi
if prompt_yes_no "Run onboarding after install?" "no"; then
RUN_ONBOARD=true
if prompt_yes_no "Use interactive onboarding?" "yes"; then
INTERACTIVE_ONBOARD=true
else
INTERACTIVE_ONBOARD=false
if ! guided_read provider_input "Provider [$PROVIDER]: "; then
error "guided installer input was interrupted."
exit 1
fi
if [[ -n "$provider_input" ]]; then
PROVIDER="$provider_input"
fi
if ! guided_read model_input "Model [${MODEL:-leave empty}]: "; then
error "guided installer input was interrupted."
exit 1
fi
if [[ -n "$model_input" ]]; then
MODEL="$model_input"
fi
if [[ -z "$API_KEY" ]]; then
if ! guided_read api_key_input "API key (hidden, leave empty to switch to interactive onboarding): " true; then
echo
error "guided installer input was interrupted."
exit 1
fi
echo
if [[ -n "$api_key_input" ]]; then
API_KEY="$api_key_input"
else
warn "No API key entered. Using interactive onboarding instead."
INTERACTIVE_ONBOARD=true
fi
fi
fi
fi
# --- Provider + API key (inline onboarding) ---
prompt_provider
prompt_api_key
prompt_model
# --- Install plan summary ---
echo
info "Installer plan"
local install_binary=true
local build_first=false
if [[ "$SKIP_INSTALL" == true ]]; then
install_binary=false
echo -e "${BOLD}Install plan${RESET}"
step_dot "OS: $(echo "$os_name" | tr '[:upper:]' '[:lower:]')"
step_dot "Install system deps: $(bool_to_word "$INSTALL_SYSTEM_DEPS")"
step_dot "Install Rust: $(bool_to_word "$INSTALL_RUST")"
step_dot "Provider: ${PROVIDER}"
if [[ -n "$MODEL" ]]; then
step_dot "Model: ${MODEL}"
fi
if [[ "$SKIP_BUILD" == false ]]; then
build_first=true
fi
echo " docker-mode: $(bool_to_word "$DOCKER_MODE")"
echo " install-system-deps: $(bool_to_word "$INSTALL_SYSTEM_DEPS")"
echo " install-rust: $(bool_to_word "$INSTALL_RUST")"
echo " build-first: $(bool_to_word "$build_first")"
echo " install-binary: $(bool_to_word "$install_binary")"
echo " onboard: $(bool_to_word "$RUN_ONBOARD")"
if [[ "$RUN_ONBOARD" == true ]]; then
echo " interactive-onboard: $(bool_to_word "$INTERACTIVE_ONBOARD")"
if [[ "$INTERACTIVE_ONBOARD" == false ]]; then
echo " provider: $PROVIDER"
if [[ -n "$MODEL" ]]; then
echo " model: $MODEL"
fi
fi
if [[ -n "$API_KEY" ]]; then
step_ok "API key: configured"
else
step_dot "API key: not set (configure later)"
fi
echo
@@ -758,42 +819,37 @@ run_docker_bootstrap() {
info "Container CLI: $CONTAINER_CLI"
local onboard_cmd=()
if [[ "$INTERACTIVE_ONBOARD" == true ]]; then
info "Launching interactive onboarding in container"
onboard_cmd=(onboard --interactive)
else
if [[ -z "$API_KEY" ]]; then
cat <<'MSG'
==> Onboarding requested, but API key not provided.
Use either:
--api-key "sk-..."
or:
ZEROCLAW_API_KEY="sk-..." ./install.sh --docker
or run interactive:
./install.sh --docker --interactive-onboard
MSG
exit 1
fi
if [[ "$SKIP_ONBOARD" == true ]]; then
info "Skipping onboarding in container"
onboard_cmd=()
elif [[ -n "$API_KEY" ]]; then
if [[ -n "$MODEL" ]]; then
info "Launching quick onboarding in container (provider: $PROVIDER, model: $MODEL)"
info "Configuring provider in container (provider: $PROVIDER, model: $MODEL)"
else
info "Launching quick onboarding in container (provider: $PROVIDER)"
info "Configuring provider in container (provider: $PROVIDER)"
fi
onboard_cmd=(onboard --api-key "$API_KEY" --provider "$PROVIDER")
if [[ -n "$MODEL" ]]; then
onboard_cmd+=(--model "$MODEL")
fi
else
info "Launching setup in container"
onboard_cmd=(onboard --provider "$PROVIDER")
fi
"$CONTAINER_CLI" run --rm -it \
"${container_run_namespace_args[@]+"${container_run_namespace_args[@]}"}" \
"${container_run_user_args[@]}" \
-e HOME=/zeroclaw-data \
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
-v "$config_mount" \
-v "$workspace_mount" \
"$docker_image" \
"${onboard_cmd[@]}"
if [[ ${#onboard_cmd[@]} -gt 0 ]]; then
"$CONTAINER_CLI" run --rm -it \
"${container_run_namespace_args[@]+"${container_run_namespace_args[@]}"}" \
"${container_run_user_args[@]}" \
-e HOME=/zeroclaw-data \
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
-v "$config_mount" \
-v "$workspace_mount" \
"$docker_image" \
"${onboard_cmd[@]}"
else
info "Docker image ready. Run zeroclaw onboard inside the container to configure."
fi
}
SCRIPT_PATH="${BASH_SOURCE[0]:-$0}"
@@ -809,8 +865,7 @@ INSTALL_RUST=false
PREFER_PREBUILT=false
PREBUILT_ONLY=false
FORCE_SOURCE_BUILD=false
RUN_ONBOARD=false
INTERACTIVE_ONBOARD=false
SKIP_ONBOARD=false
SKIP_BUILD=false
SKIP_INSTALL=false
PREBUILT_INSTALLED=false
@@ -853,13 +908,8 @@ while [[ $# -gt 0 ]]; do
FORCE_SOURCE_BUILD=true
shift
;;
--onboard)
RUN_ONBOARD=true
shift
;;
--interactive-onboard)
RUN_ONBOARD=true
INTERACTIVE_ONBOARD=true
--skip-onboard)
SKIP_ONBOARD=true
shift
;;
--api-key)
@@ -990,8 +1040,51 @@ if [[ ! -f "$WORK_DIR/Cargo.toml" ]]; then
fi
fi
info "ZeroClaw installer"
echo " workspace: $WORK_DIR"
echo
echo -e " ${BOLD_BLUE}${CRAB} ZeroClaw Installer${RESET}"
echo -e " ${DIM}Build it, run it, trust it.${RESET}"
echo
step_ok "Detected: ${BOLD}$(echo "$OS_NAME" | tr '[:upper:]' '[:lower:]')${RESET}"
# --- Detect existing installation and version ---
EXISTING_VERSION=""
INSTALL_MODE="fresh"
if have_cmd zeroclaw; then
EXISTING_VERSION="$(zeroclaw --version 2>/dev/null | awk '{print $NF}' || true)"
INSTALL_MODE="upgrade"
elif [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
EXISTING_VERSION="$("$HOME/.cargo/bin/zeroclaw" --version 2>/dev/null | awk '{print $NF}' || true)"
INSTALL_MODE="upgrade"
fi
# Determine install method
if [[ "$DOCKER_MODE" == true ]]; then
INSTALL_METHOD="docker"
elif [[ "$PREBUILT_ONLY" == true || "$PREFER_PREBUILT" == true ]]; then
INSTALL_METHOD="prebuilt binary"
else
INSTALL_METHOD="source (cargo)"
fi
# Determine target version from Cargo.toml
TARGET_VERSION=""
if [[ -f "$WORK_DIR/Cargo.toml" ]]; then
TARGET_VERSION="$(grep -m1 '^version' "$WORK_DIR/Cargo.toml" | sed 's/.*"\(.*\)".*/\1/' || true)"
fi
echo
echo -e "${BOLD}Install plan${RESET}"
step_dot "OS: $(echo "$OS_NAME" | tr '[:upper:]' '[:lower:]')"
step_dot "Install method: ${INSTALL_METHOD}"
if [[ -n "$TARGET_VERSION" ]]; then
step_dot "Requested version: v${TARGET_VERSION}"
fi
step_dot "Workspace: $WORK_DIR"
if [[ "$INSTALL_MODE" == "upgrade" && -n "$EXISTING_VERSION" ]]; then
step_dot "Existing ZeroClaw installation detected, upgrading from v${EXISTING_VERSION}"
elif [[ "$INSTALL_MODE" == "upgrade" ]]; then
step_dot "Existing ZeroClaw installation detected, upgrading"
fi
cd "$WORK_DIR"
@@ -1006,26 +1099,21 @@ fi
if [[ "$DOCKER_MODE" == true ]]; then
ensure_docker_ready
if [[ "$RUN_ONBOARD" == false ]]; then
RUN_ONBOARD=true
if [[ -z "$API_KEY" ]]; then
INTERACTIVE_ONBOARD=true
fi
fi
run_docker_bootstrap
cat <<'DONE'
✅ Docker bootstrap complete.
Your containerized ZeroClaw data is persisted under:
DONE
echo " $DOCKER_DATA_DIR"
cat <<'DONE'
Next steps:
./install.sh --docker --interactive-onboard
./install.sh --docker --api-key "sk-..." --provider openrouter
DONE
echo
echo -e "${BOLD_BLUE}${CRAB} Docker bootstrap complete!${RESET}"
echo
echo -e "${BOLD}Your containerized ZeroClaw data is persisted under:${RESET}"
echo -e " ${DIM}$DOCKER_DATA_DIR${RESET}"
echo
echo -e "${BOLD}Dashboard URL:${RESET} ${BLUE}http://127.0.0.1:42617${RESET}"
echo
echo -e "${BOLD}Next steps:${RESET}"
echo -e " ${DIM}zeroclaw status${RESET}"
echo -e " ${DIM}zeroclaw agent -m \"Hello, ZeroClaw!\"${RESET}"
echo -e " ${DIM}zeroclaw gateway${RESET}"
echo
echo -e "${BOLD}Docs:${RESET} ${BLUE}https://www.zeroclawlabs.ai/docs${RESET}"
exit 0
fi
@@ -1062,18 +1150,45 @@ MSG
exit 1
fi
if [[ "$SKIP_BUILD" == false ]]; then
info "Building release binary"
cargo build --release --locked
echo
echo -e "${BOLD_BLUE}[1/3]${RESET} ${BOLD}Preparing environment${RESET}"
if [[ "$INSTALL_SYSTEM_DEPS" == true ]]; then
step_ok "System dependencies installed"
else
info "Skipping build"
step_ok "System dependencies satisfied"
fi
if have_cmd cargo && have_cmd rustc; then
step_ok "Rust $(rustc --version | awk '{print $2}') found"
step_dot "Active Rust: $(rustc --version) ($(command -v rustc))"
step_dot "Active cargo: $(cargo --version | awk '{print $2}') ($(command -v cargo))"
else
step_dot "Rust not detected"
fi
if have_cmd git; then
step_ok "Git already installed"
else
step_dot "Git not found"
fi
echo
echo -e "${BOLD_BLUE}[2/3]${RESET} ${BOLD}Installing ZeroClaw${RESET}"
if [[ -n "$TARGET_VERSION" ]]; then
step_dot "Installing ZeroClaw v${TARGET_VERSION}"
fi
if [[ "$SKIP_BUILD" == false ]]; then
step_dot "Building release binary"
cargo build --release --locked
step_ok "Release binary built"
else
step_dot "Skipping build"
fi
if [[ "$SKIP_INSTALL" == false ]]; then
info "Installing zeroclaw to cargo bin"
step_dot "Installing zeroclaw to cargo bin"
cargo install --path "$WORK_DIR" --force --locked
step_ok "ZeroClaw installed"
else
info "Skipping install"
step_dot "Skipping install"
fi
ZEROCLAW_BIN=""
@@ -1085,48 +1200,149 @@ elif [[ -x "$WORK_DIR/target/release/zeroclaw" ]]; then
ZEROCLAW_BIN="$WORK_DIR/target/release/zeroclaw"
fi
if [[ "$RUN_ONBOARD" == true ]]; then
if [[ -z "$ZEROCLAW_BIN" ]]; then
error "onboarding requested but zeroclaw binary is not available."
error "Run without --skip-install, or ensure zeroclaw is in PATH."
exit 1
fi
echo
echo -e "${BOLD_BLUE}[3/3]${RESET} ${BOLD}Finalizing setup${RESET}"
if [[ "$INTERACTIVE_ONBOARD" == true ]]; then
info "Running interactive onboarding"
"$ZEROCLAW_BIN" onboard --interactive
else
if [[ -z "$API_KEY" ]]; then
cat <<'MSG'
==> Onboarding requested, but API key not provided.
Use either:
--api-key "sk-..."
or:
ZEROCLAW_API_KEY="sk-..." ./install.sh --onboard
or run interactive:
./install.sh --interactive-onboard
MSG
exit 1
fi
if [[ -n "$MODEL" ]]; then
info "Running quick onboarding (provider: $PROVIDER, model: $MODEL)"
else
info "Running quick onboarding (provider: $PROVIDER)"
fi
# --- Inline onboarding (provider + API key configuration) ---
if [[ "$SKIP_ONBOARD" == false && -n "$ZEROCLAW_BIN" ]]; then
if [[ -n "$API_KEY" ]]; then
step_dot "Configuring provider: ${PROVIDER}"
ONBOARD_CMD=("$ZEROCLAW_BIN" onboard --api-key "$API_KEY" --provider "$PROVIDER")
if [[ -n "$MODEL" ]]; then
ONBOARD_CMD+=(--model "$MODEL")
fi
"${ONBOARD_CMD[@]}"
if "${ONBOARD_CMD[@]}" 2>/dev/null; then
step_ok "Provider configured"
else
step_fail "Provider configuration failed — run zeroclaw onboard to retry"
fi
elif [[ "$PROVIDER" == "ollama" ]]; then
step_dot "Configuring Ollama (no API key needed)"
if "$ZEROCLAW_BIN" onboard --provider ollama 2>/dev/null; then
step_ok "Ollama configured"
else
step_fail "Ollama configuration failed — run zeroclaw onboard to retry"
fi
else
# No API key and not ollama — prompt inline if interactive, skip otherwise
if [[ -t 0 && -t 1 ]]; then
prompt_provider
prompt_api_key
if [[ -n "$API_KEY" ]]; then
ONBOARD_CMD=("$ZEROCLAW_BIN" onboard --api-key "$API_KEY" --provider "$PROVIDER")
if [[ -n "$MODEL" ]]; then
ONBOARD_CMD+=(--model "$MODEL")
fi
if "${ONBOARD_CMD[@]}" 2>/dev/null; then
step_ok "Provider configured"
else
step_fail "Provider configuration failed — run zeroclaw onboard to retry"
fi
fi
else
step_dot "No API key provided — run zeroclaw onboard to configure"
fi
fi
elif [[ "$SKIP_ONBOARD" == true ]]; then
step_dot "Skipping configuration (run zeroclaw onboard later)"
elif [[ -z "$ZEROCLAW_BIN" ]]; then
warn "ZeroClaw binary not found — cannot configure provider"
fi
# --- Gateway service management ---
if [[ -n "$ZEROCLAW_BIN" ]]; then
# Try to install and start the gateway service
step_dot "Checking gateway service"
if "$ZEROCLAW_BIN" service install 2>/dev/null; then
step_ok "Gateway service installed"
if "$ZEROCLAW_BIN" service restart 2>/dev/null; then
step_ok "Gateway service restarted"
else
step_fail "Gateway service restart failed — re-run with zeroclaw service start"
fi
else
step_dot "Gateway service not installed (run zeroclaw service install later)"
fi
# --- Post-install doctor check ---
step_dot "Running doctor to validate installation"
if "$ZEROCLAW_BIN" doctor 2>/dev/null; then
step_ok "Doctor complete"
else
warn "Doctor reported issues — run zeroclaw doctor --fix to resolve"
fi
fi
cat <<'DONE'
# --- Determine installed version ---
INSTALLED_VERSION=""
if [[ -n "$ZEROCLAW_BIN" ]]; then
INSTALLED_VERSION="$("$ZEROCLAW_BIN" --version 2>/dev/null | awk '{print $NF}' || true)"
fi
✅ Bootstrap complete.
# --- Success banner ---
echo
if [[ -n "$INSTALLED_VERSION" ]]; then
echo -e "${BOLD_BLUE}${CRAB} ZeroClaw installed successfully (ZeroClaw ${INSTALLED_VERSION})!${RESET}"
else
echo -e "${BOLD_BLUE}${CRAB} ZeroClaw installed successfully!${RESET}"
fi
Next steps:
zeroclaw status
zeroclaw agent -m "Hello, ZeroClaw!"
zeroclaw gateway
DONE
if [[ "$INSTALL_MODE" == "upgrade" ]]; then
step_dot "Upgrade complete"
fi
# --- Dashboard URL ---
GATEWAY_PORT=42617
DASHBOARD_URL="http://127.0.0.1:${GATEWAY_PORT}"
echo
echo -e "${BOLD}Dashboard URL:${RESET} ${BLUE}${DASHBOARD_URL}${RESET}"
echo -e "${DIM} Enter the pairing code shown above to connect.${RESET}"
# --- Copy to clipboard ---
COPIED_TO_CLIPBOARD=false
if [[ -t 1 ]]; then
case "$OS_NAME" in
Darwin)
if have_cmd pbcopy; then
printf '%s' "$DASHBOARD_URL" | pbcopy 2>/dev/null && COPIED_TO_CLIPBOARD=true
fi
;;
Linux)
if have_cmd xclip; then
printf '%s' "$DASHBOARD_URL" | xclip -selection clipboard 2>/dev/null && COPIED_TO_CLIPBOARD=true
elif have_cmd xsel; then
printf '%s' "$DASHBOARD_URL" | xsel --clipboard 2>/dev/null && COPIED_TO_CLIPBOARD=true
elif have_cmd wl-copy; then
printf '%s' "$DASHBOARD_URL" | wl-copy 2>/dev/null && COPIED_TO_CLIPBOARD=true
fi
;;
esac
fi
if [[ "$COPIED_TO_CLIPBOARD" == true ]]; then
step_ok "Copied to clipboard"
fi
# --- Open in browser ---
if [[ -t 1 ]]; then
case "$OS_NAME" in
Darwin)
if have_cmd open; then
open "$DASHBOARD_URL" 2>/dev/null && step_ok "Opened in your browser"
fi
;;
Linux)
if have_cmd xdg-open; then
xdg-open "$DASHBOARD_URL" 2>/dev/null && step_ok "Opened in your browser"
fi
;;
esac
fi
echo
echo -e "${BOLD}Next steps:${RESET}"
echo -e " ${DIM}zeroclaw status${RESET}"
echo -e " ${DIM}zeroclaw agent -m \"Hello, ZeroClaw!\"${RESET}"
echo -e " ${DIM}zeroclaw gateway${RESET}"
echo
echo -e "${BOLD}Docs:${RESET} ${BLUE}https://www.zeroclawlabs.ai/docs${RESET}"
echo
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env sh
# Backward-compatible entrypoint for older install docs that still fetch
# scripts/install-release.sh directly.
set -eu
repo="${ZEROCLAW_INSTALL_REPO:-zeroclaw-labs/zeroclaw}"
ref="${ZEROCLAW_INSTALL_REF:-main}"
script_url="${ZEROCLAW_INSTALL_SH_URL:-https://raw.githubusercontent.com/${repo}/${ref}/install.sh}"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$script_url" | bash -s -- "$@"
exit $?
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$script_url" | bash -s -- "$@"
exit $?
fi
echo "error: curl or wget is required to download $script_url" >&2
exit 1
+25 -10
View File
@@ -2112,14 +2112,7 @@ async fn execute_one_tool(
observer: &dyn Observer,
cancellation_token: Option<&CancellationToken>,
) -> Result<ToolExecutionOutcome> {
let args_summary = {
let raw = call_arguments.to_string();
if raw.len() > 300 {
format!("{}", &raw[..300])
} else {
raw
}
};
let args_summary = truncate_with_ellipsis(&call_arguments.to_string(), 300);
observer.record_event(&ObserverEvent::ToolCallStart {
tool: call_name.to_string(),
arguments: Some(args_summary),
@@ -2961,8 +2954,9 @@ pub async fn run(
));
// ── Memory (the brain) ────────────────────────────────────────
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage(
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
@@ -3554,8 +3548,9 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
&config.autonomy,
&config.workspace_dir,
));
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage(
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
@@ -3843,6 +3838,26 @@ mod tests {
assert!(scrubbed.contains("\"api_key\": \"sk-1*[REDACTED]\""));
assert!(scrubbed.contains("public"));
}
#[tokio::test]
async fn execute_one_tool_does_not_panic_on_utf8_boundary() {
let call_arguments = (0..600)
.map(|n| serde_json::json!({ "content": format!("{}tail", "a".repeat(n)) }))
.find(|args| {
let raw = args.to_string();
raw.len() > 300 && !raw.is_char_boundary(300)
})
.expect("should produce a sample whose byte index 300 is not a char boundary");
let observer = NoopObserver;
let result = execute_one_tool("unknown_tool", call_arguments, &[], &observer, None).await;
assert!(result.is_ok(), "execute_one_tool should not panic or error");
let outcome = result.unwrap();
assert!(!outcome.success);
assert!(outcome.output.contains("Unknown tool: unknown_tool"));
}
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use crate::observability::NoopObserver;
use crate::providers::traits::ProviderCapabilities;
+17
View File
@@ -40,6 +40,7 @@ impl SystemPromptBuilder {
Box::new(WorkspaceSection),
Box::new(DateTimeSection),
Box::new(RuntimeSection),
Box::new(ChannelMediaSection),
],
}
}
@@ -70,6 +71,7 @@ pub struct SkillsSection;
pub struct WorkspaceSection;
pub struct RuntimeSection;
pub struct DateTimeSection;
pub struct ChannelMediaSection;
impl PromptSection for IdentitySection {
fn name(&self) -> &str {
@@ -206,6 +208,21 @@ impl PromptSection for DateTimeSection {
}
}
impl PromptSection for ChannelMediaSection {
fn name(&self) -> &str {
"channel_media"
}
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
Ok("## Channel Media Markers\n\n\
Messages from channels may contain media markers:\n\
- `[Voice] <text>` — The user sent a voice/audio message that has already been transcribed to text. Respond to the transcribed content directly.\n\
- `[IMAGE:<path>]` — An image attachment, processed by the vision pipeline.\n\
- `[Document: <name>] <path>` — A file attachment saved to the workspace."
.into())
}
}
fn inject_workspace_file(prompt: &mut String, workspace_dir: &Path, filename: &str) {
let path = workspace_dir.join(filename);
match std::fs::read_to_string(&path) {
+448 -44
View File
@@ -113,28 +113,20 @@ impl Observer for ChannelNotifyObserver {
Some(args) if !args.is_empty() => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
format!(": `{}`", if cmd.len() > 200 { &cmd[..200] } else { cmd })
format!(": `{}`", truncate_with_ellipsis(cmd, 200))
} else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
format!(": {}", if q.len() > 200 { &q[..200] } else { q })
format!(": {}", truncate_with_ellipsis(q, 200))
} else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
format!(": {p}")
} else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
format!(": {u}")
} else {
let s = args.to_string();
if s.len() > 120 {
format!(": {}", &s[..120])
} else {
format!(": {s}")
}
format!(": {}", truncate_with_ellipsis(&s, 120))
}
} else {
let s = args.to_string();
if s.len() > 120 {
format!(": {}", &s[..120])
} else {
format!(": {s}")
}
format!(": {}", truncate_with_ellipsis(&s, 120))
}
}
_ => String::new(),
@@ -189,6 +181,13 @@ const MEMORY_CONTEXT_ENTRY_MAX_CHARS: usize = 800;
const MEMORY_CONTEXT_MAX_CHARS: usize = 4_000;
const CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES: usize = 12;
const CHANNEL_HISTORY_COMPACT_CONTENT_CHARS: usize = 600;
/// Proactive context-window budget in estimated characters (~4 chars/token).
/// When the total character count of conversation history exceeds this limit,
/// older turns are dropped before the request is sent to the provider,
/// preventing context-window-exceeded errors. Set conservatively below
/// common context windows (128 k tokens ≈ 512 k chars) to leave room for
/// system prompt, memory context, and model output.
const PROACTIVE_CONTEXT_BUDGET_CHARS: usize = 400_000;
/// Guardrail for hook-modified outbound channel content.
const CHANNEL_HOOK_MAX_OUTBOUND_CHARS: usize = 20_000;
@@ -266,6 +265,22 @@ const SYSTEMD_RESTART_ARGS: [&str; 3] = ["--user", "restart", "zeroclaw.service"
const OPENRC_STATUS_ARGS: [&str; 2] = ["zeroclaw", "status"];
const OPENRC_RESTART_ARGS: [&str; 2] = ["zeroclaw", "restart"];
#[derive(Clone, Copy)]
struct InterruptOnNewMessageConfig {
telegram: bool,
slack: bool,
}
impl InterruptOnNewMessageConfig {
fn enabled_for_channel(self, channel: &str) -> bool {
match channel {
"telegram" => self.telegram,
"slack" => self.slack,
_ => false,
}
}
}
#[derive(Clone)]
struct ChannelRuntimeContext {
channels_by_name: Arc<HashMap<String, Arc<dyn Channel>>>,
@@ -289,13 +304,14 @@ struct ChannelRuntimeContext {
provider_runtime_options: providers::ProviderRuntimeOptions,
workspace_dir: Arc<PathBuf>,
message_timeout_secs: u64,
interrupt_on_new_message: bool,
interrupt_on_new_message: InterruptOnNewMessageConfig,
multimodal: crate::config::MultimodalConfig,
hooks: Option<Arc<crate::hooks::HookRunner>>,
non_cli_excluded_tools: Arc<Vec<String>>,
tool_call_dedup_exempt: Arc<Vec<String>>,
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
ack_reactions: bool,
show_tool_calls: bool,
}
#[derive(Clone)]
@@ -347,6 +363,10 @@ fn conversation_history_key(msg: &traits::ChannelMessage) -> String {
}
}
fn followup_thread_id(msg: &traits::ChannelMessage) -> Option<String> {
msg.thread_ts.clone().or_else(|| Some(msg.id.clone()))
}
fn interruption_scope_key(msg: &traits::ChannelMessage) -> String {
format!("{}_{}_{}", msg.channel, msg.reply_target, msg.sender)
}
@@ -919,6 +939,31 @@ fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool
true
}
/// Proactively trim conversation turns so that the total estimated character
/// count stays within [`PROACTIVE_CONTEXT_BUDGET_CHARS`]. Drops the oldest
/// turns first, but always preserves the most recent turn (the current user
/// message). Returns the number of turns dropped.
fn proactive_trim_turns(turns: &mut Vec<ChatMessage>, budget: usize) -> usize {
let total_chars: usize = turns.iter().map(|t| t.content.chars().count()).sum();
if total_chars <= budget || turns.len() <= 1 {
return 0;
}
let mut excess = total_chars.saturating_sub(budget);
let mut drop_count = 0;
// Walk from the oldest turn forward, but never drop the very last turn.
while excess > 0 && drop_count < turns.len().saturating_sub(1) {
excess = excess.saturating_sub(turns[drop_count].content.chars().count());
drop_count += 1;
}
if drop_count > 0 {
turns.drain(..drop_count);
}
drop_count
}
fn append_sender_turn(ctx: &ChannelRuntimeContext, sender_key: &str, turn: ChatMessage) {
let mut histories = ctx
.conversation_histories
@@ -1798,6 +1843,19 @@ async fn process_channel_message(
}
}
// Proactively trim conversation history before sending to the provider
// to prevent context-window-exceeded errors (bug #3460).
let dropped = proactive_trim_turns(&mut prior_turns, PROACTIVE_CONTEXT_BUDGET_CHARS);
if dropped > 0 {
tracing::info!(
channel = %msg.channel,
sender = %msg.sender,
dropped_turns = dropped,
remaining_turns = prior_turns.len(),
"Proactively trimmed conversation history to fit context budget"
);
}
// Only enrich with memory context when there is no prior conversation
// history. Follow-up turns already include context from previous messages.
if !had_prior_history {
@@ -1914,14 +1972,14 @@ async fn process_channel_message(
let notify_observer_flag = Arc::clone(&notify_observer);
let notify_channel = target_channel.clone();
let notify_reply_target = msg.reply_target.clone();
let notify_thread_root = msg.id.clone();
let notify_task = if msg.channel == "cli" {
let notify_thread_root = followup_thread_id(&msg);
let notify_task = if msg.channel == "cli" || !ctx.show_tool_calls {
Some(tokio::spawn(async move {
while notify_rx.recv().await.is_some() {}
}))
} else {
Some(tokio::spawn(async move {
let thread_ts = Some(notify_thread_root);
let thread_ts = notify_thread_root;
while let Some(text) = notify_rx.recv().await {
if let Some(ref ch) = notify_channel {
let _ = ch
@@ -1981,7 +2039,7 @@ async fn process_channel_message(
// Thread the final reply only if tools were used (multi-message response)
if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
msg.thread_ts = Some(msg.id.clone());
msg.thread_ts = followup_thread_id(&msg);
}
// Drop the notify sender so the forwarder task finishes
drop(notify_observer);
@@ -2365,8 +2423,9 @@ async fn run_message_dispatch_loop(
let task_sequence = Arc::clone(&task_sequence);
workers.spawn(async move {
let _permit = permit;
let interrupt_enabled =
worker_ctx.interrupt_on_new_message && msg.channel == "telegram";
let interrupt_enabled = worker_ctx
.interrupt_on_new_message
.enabled_for_channel(msg.channel.as_str());
let sender_scope_key = interruption_scope_key(&msg);
let cancellation_token = CancellationToken::new();
let completion = Arc::new(InFlightTaskCompletion::new());
@@ -3694,6 +3753,11 @@ pub async fn start_channels(config: Config) -> Result<()> {
.telegram
.as_ref()
.is_some_and(|tg| tg.interrupt_on_new_message);
let interrupt_on_new_message_slack = config
.channels_config
.slack
.as_ref()
.is_some_and(|sl| sl.interrupt_on_new_message);
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name,
@@ -3717,7 +3781,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
provider_runtime_options,
workspace_dir: Arc::new(config.workspace_dir.clone()),
message_timeout_secs,
interrupt_on_new_message,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: interrupt_on_new_message,
slack: interrupt_on_new_message_slack,
},
multimodal: config.multimodal.clone(),
hooks: if config.hooks.enabled {
let mut runner = crate::hooks::HookRunner::new();
@@ -3737,6 +3804,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
model_routes: Arc::new(config.model_routes.clone()),
ack_reactions: config.channels_config.ack_reactions,
show_tool_calls: config.channels_config.show_tool_calls,
});
run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await;
@@ -3757,7 +3825,7 @@ mod tests {
use crate::providers::{ChatMessage, Provider};
use crate::tools::{Tool, ToolResult};
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use tempfile::TempDir;
@@ -3990,7 +4058,10 @@ mod tests {
api_key: None,
api_url: None,
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
@@ -4000,6 +4071,7 @@ mod tests {
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
};
assert!(compact_sender_history(&ctx, &sender));
@@ -4020,6 +4092,53 @@ mod tests {
}));
}
#[test]
fn proactive_trim_drops_oldest_turns_when_over_budget() {
// Each message is 100 chars; 10 messages = 1000 chars total.
let mut turns: Vec<ChatMessage> = (0..10)
.map(|i| {
let content = format!("m{i}-{}", "a".repeat(96));
if i % 2 == 0 {
ChatMessage::user(content)
} else {
ChatMessage::assistant(content)
}
})
.collect();
// Budget of 500 should drop roughly half (oldest turns).
let dropped = proactive_trim_turns(&mut turns, 500);
assert!(dropped > 0, "should have dropped some turns");
assert!(turns.len() < 10, "should have fewer turns after trimming");
// Last turn should always be preserved.
assert!(
turns.last().unwrap().content.starts_with("m9-"),
"most recent turn must be preserved"
);
// Total chars should now be within budget.
let total: usize = turns.iter().map(|t| t.content.chars().count()).sum();
assert!(total <= 500, "total chars {total} should be within budget");
}
#[test]
fn proactive_trim_noop_when_within_budget() {
let mut turns = vec![
ChatMessage::user("hello".to_string()),
ChatMessage::assistant("hi there".to_string()),
];
let dropped = proactive_trim_turns(&mut turns, 10_000);
assert_eq!(dropped, 0);
assert_eq!(turns.len(), 2);
}
#[test]
fn proactive_trim_preserves_last_turn_even_when_over_budget() {
let mut turns = vec![ChatMessage::user("x".repeat(2000))];
let dropped = proactive_trim_turns(&mut turns, 100);
assert_eq!(dropped, 0, "single turn must never be dropped");
assert_eq!(turns.len(), 1);
}
#[test]
fn append_sender_turn_stores_single_turn_per_call() {
let sender = "telegram_u2".to_string();
@@ -4042,7 +4161,10 @@ mod tests {
api_key: None,
api_url: None,
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
@@ -4052,6 +4174,7 @@ mod tests {
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
};
append_sender_turn(&ctx, &sender, ChatMessage::user("hello"));
@@ -4097,7 +4220,10 @@ mod tests {
api_key: None,
api_url: None,
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
@@ -4107,6 +4233,7 @@ mod tests {
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
};
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
@@ -4152,6 +4279,11 @@ mod tests {
sent_messages: tokio::sync::Mutex<Vec<String>>,
}
#[derive(Default)]
struct SlackRecordingChannel {
sent_messages: tokio::sync::Mutex<Vec<String>>,
}
#[async_trait::async_trait]
impl Channel for TelegramRecordingChannel {
fn name(&self) -> &str {
@@ -4182,6 +4314,36 @@ mod tests {
}
}
#[async_trait::async_trait]
impl Channel for SlackRecordingChannel {
fn name(&self) -> &str {
"slack"
}
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
self.sent_messages
.lock()
.await
.push(format!("{}:{}", message.recipient, message.content));
Ok(())
}
async fn listen(
&self,
_tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
) -> anyhow::Result<()> {
Ok(())
}
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[async_trait::async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
@@ -4578,13 +4740,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -4641,13 +4807,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -4718,13 +4888,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -4780,13 +4954,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -4852,13 +5030,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -4944,13 +5126,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5018,13 +5204,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5107,13 +5297,17 @@ BTC is currently around $65,000 based on latest tool output."#
},
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5181,13 +5375,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5245,13 +5443,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5420,13 +5622,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(4);
@@ -5503,13 +5709,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: true,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: true,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@@ -5566,6 +5776,108 @@ BTC is currently around $65,000 based on latest tool output."#
);
}
#[tokio::test]
async fn message_dispatch_interrupts_in_flight_slack_request_and_preserves_context() {
let channel_impl = Arc::new(SlackRecordingChannel::default());
let channel: Arc<dyn Channel> = channel_impl.clone();
let mut channels_by_name = HashMap::new();
channels_by_name.insert(channel.name().to_string(), channel);
let provider_impl = Arc::new(DelayedHistoryCaptureProvider {
delay: Duration::from_millis(250),
calls: std::sync::Mutex::new(Vec::new()),
});
let runtime_ctx = Arc::new(ChannelRuntimeContext {
channels_by_name: Arc::new(channels_by_name),
provider: provider_impl.clone(),
default_provider: Arc::new("test-provider".to_string()),
memory: Arc::new(NoopMemory),
tools_registry: Arc::new(vec![]),
observer: Arc::new(NoopObserver),
system_prompt: Arc::new("test-system-prompt".to_string()),
model: Arc::new("test-model".to_string()),
temperature: 0.0,
auto_save_memory: false,
max_tool_iterations: 10,
min_relevance_score: 0.0,
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
provider_cache: Arc::new(Mutex::new(HashMap::new())),
route_overrides: Arc::new(Mutex::new(HashMap::new())),
api_key: None,
api_url: None,
reliability: Arc::new(crate::config::ReliabilityConfig::default()),
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: true,
},
ack_reactions: true,
show_tool_calls: true,
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
let send_task = tokio::spawn(async move {
tx.send(traits::ChannelMessage {
id: "msg-1".to_string(),
sender: "U123".to_string(),
reply_target: "C123".to_string(),
content: "first question".to_string(),
channel: "slack".to_string(),
timestamp: 1,
thread_ts: Some("1741234567.100001".to_string()),
})
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(40)).await;
tx.send(traits::ChannelMessage {
id: "msg-2".to_string(),
sender: "U123".to_string(),
reply_target: "C123".to_string(),
content: "second question".to_string(),
channel: "slack".to_string(),
timestamp: 2,
thread_ts: Some("1741234567.100001".to_string()),
})
.await
.unwrap();
});
run_message_dispatch_loop(rx, runtime_ctx, 4).await;
send_task.await.unwrap();
let sent_messages = channel_impl.sent_messages.lock().await;
assert_eq!(sent_messages.len(), 1);
assert!(sent_messages[0].starts_with("C123:"));
assert!(sent_messages[0].contains("response-2"));
drop(sent_messages);
let calls = provider_impl
.calls
.lock()
.unwrap_or_else(|e| e.into_inner());
assert_eq!(calls.len(), 2);
let second_call = &calls[1];
assert!(second_call
.iter()
.any(|(role, content)| { role == "user" && content.contains("first question") }));
assert!(second_call
.iter()
.any(|(role, content)| { role == "user" && content.contains("second question") }));
assert!(
!second_call.iter().any(|(role, _)| role == "assistant"),
"cancelled turn should not persist an assistant response"
);
}
#[tokio::test]
async fn message_dispatch_interrupt_scope_is_same_sender_same_chat() {
let channel_impl = Arc::new(TelegramRecordingChannel::default());
@@ -5598,13 +5910,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: true,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: true,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
let (tx, rx) = tokio::sync::mpsc::channel::<traits::ChannelMessage>(8);
@@ -5675,13 +5991,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -5737,13 +6057,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -6122,6 +6446,33 @@ BTC is currently around $65,000 based on latest tool output."#
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
}
#[test]
fn channel_notify_observer_truncates_utf8_arguments_safely() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let observer = ChannelNotifyObserver {
inner: Arc::new(NoopObserver),
tx,
tools_used: AtomicBool::new(false),
};
let payload = (0..300)
.map(|n| serde_json::json!({ "content": format!("{}置tail", "a".repeat(n)) }))
.map(|v| v.to_string())
.find(|raw| raw.len() > 120 && !raw.is_char_boundary(120))
.expect("should produce non-char-boundary data at byte index 120");
observer.record_event(
&crate::observability::traits::ObserverEvent::ToolCallStart {
tool: "file_write".to_string(),
arguments: Some(payload),
},
);
let emitted = rx.try_recv().expect("observer should emit notify message");
assert!(emitted.contains("`file_write`"));
assert!(emitted.is_char_boundary(emitted.len()));
}
#[test]
fn conversation_memory_key_uses_message_id() {
let msg = traits::ChannelMessage {
@@ -6137,6 +6488,39 @@ BTC is currently around $65,000 based on latest tool output."#
assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
}
#[test]
fn followup_thread_id_prefers_thread_ts() {
let msg = traits::ChannelMessage {
id: "slack_C123_1741234567.123456".into(),
sender: "U123".into(),
reply_target: "C123".into(),
content: "hello".into(),
channel: "slack".into(),
timestamp: 1,
thread_ts: Some("1741234567.123456".into()),
};
assert_eq!(
followup_thread_id(&msg).as_deref(),
Some("1741234567.123456")
);
}
#[test]
fn followup_thread_id_falls_back_to_message_id() {
let msg = traits::ChannelMessage {
id: "msg_abc123".into(),
sender: "U123".into(),
reply_target: "C456".into(),
content: "hello".into(),
channel: "cli".into(),
timestamp: 1,
thread_ts: None,
};
assert_eq!(followup_thread_id(&msg).as_deref(), Some("msg_abc123"));
}
#[test]
fn conversation_memory_key_is_unique_per_message() {
let msg1 = traits::ChannelMessage {
@@ -6297,13 +6681,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -6385,13 +6773,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -6473,13 +6865,17 @@ BTC is currently around $65,000 based on latest tool output."#
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
@@ -7025,13 +7421,17 @@ This is an example JSON object for profile settings."#;
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
// Simulate a photo attachment message with [IMAGE:] marker.
@@ -7094,13 +7494,17 @@ This is an example JSON object for profile settings."#;
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
workspace_dir: Arc::new(std::env::temp_dir()),
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
interrupt_on_new_message: false,
interrupt_on_new_message: InterruptOnNewMessageConfig {
telegram: false,
slack: false,
},
multimodal: crate::config::MultimodalConfig::default(),
hooks: None,
non_cli_excluded_tools: Arc::new(Vec::new()),
tool_call_dedup_exempt: Arc::new(Vec::new()),
model_routes: Arc::new(Vec::new()),
ack_reactions: true,
show_tool_calls: true,
});
process_channel_message(
+22 -3
View File
@@ -179,7 +179,9 @@ fn format_attachment_content(
local_path: &Path,
) -> String {
match kind {
IncomingAttachmentKind::Photo if is_image_extension(local_path) => {
IncomingAttachmentKind::Photo | IncomingAttachmentKind::Document
if is_image_extension(local_path) =>
{
format!("[IMAGE:{}]", local_path.display())
}
_ => {
@@ -246,6 +248,23 @@ fn strip_tool_call_tags(message: &str) -> String {
super::strip_tool_call_tags(message)
}
fn find_matching_close(s: &str) -> Option<usize> {
let mut depth = 1usize;
for (i, ch) in s.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
let mut cleaned = String::with_capacity(message.len());
let mut attachments = Vec::new();
@@ -260,12 +279,12 @@ fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>)
let open = cursor + open_rel;
cleaned.push_str(&message[cursor..open]);
let Some(close_rel) = message[open..].find(']') else {
let Some(close_rel) = find_matching_close(&message[open + 1..]) else {
cleaned.push_str(&message[open..]);
break;
};
let close = open + close_rel;
let close = open + 1 + close_rel;
let marker = &message[open + 1..close];
let parsed = marker.split_once(':').and_then(|(kind, target)| {
+23
View File
@@ -3097,6 +3097,11 @@ pub struct ChannelsConfig {
/// completion) to incoming channel messages. Default: `true`.
#[serde(default = "default_true")]
pub ack_reactions: bool,
/// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
/// to channel users. When `false`, tool calls are still logged server-side but
/// not forwarded as individual channel messages. Default: `true`.
#[serde(default = "default_true")]
pub show_tool_calls: bool,
}
impl ChannelsConfig {
@@ -3230,6 +3235,7 @@ impl Default for ChannelsConfig {
clawdtalk: None,
message_timeout_secs: default_channel_message_timeout_secs(),
ack_reactions: true,
show_tool_calls: true,
}
}
}
@@ -3323,6 +3329,10 @@ pub struct SlackConfig {
/// Allowed Slack user IDs. Empty = deny all.
#[serde(default)]
pub allowed_users: Vec<String>,
/// When true, a newer Slack message from the same sender in the same channel
/// cancels the in-flight request and starts a fresh response with preserved history.
#[serde(default)]
pub interrupt_on_new_message: bool,
}
impl ChannelConfig for SlackConfig {
@@ -6245,6 +6255,7 @@ default_temperature = 0.7
clawdtalk: None,
message_timeout_secs: 300,
ack_reactions: true,
show_tool_calls: true,
},
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
@@ -6958,6 +6969,7 @@ allowed_users = ["@ops:matrix.org"]
clawdtalk: None,
message_timeout_secs: 300,
ack_reactions: true,
show_tool_calls: true,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
@@ -6996,6 +7008,7 @@ allowed_users = ["@ops:matrix.org"]
let json = r#"{"bot_token":"xoxb-tok"}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
}
#[test]
@@ -7003,6 +7016,14 @@ allowed_users = ["@ops:matrix.org"]
let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
assert!(!parsed.interrupt_on_new_message);
}
#[test]
async fn slack_config_deserializes_interrupt_on_new_message() {
let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.interrupt_on_new_message);
}
#[test]
@@ -7024,6 +7045,7 @@ channel_id = "C123"
"#;
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.interrupt_on_new_message);
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
@@ -7174,6 +7196,7 @@ channel_id = "C123"
clawdtalk: None,
message_timeout_secs: 300,
ack_reactions: true,
show_tool_calls: true,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
+2 -1
View File
@@ -364,8 +364,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
.clone()
.unwrap_or_else(|| "anthropic/claude-sonnet-4".into());
let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage(
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
&config.memory,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
config.api_key.as_deref(),
+13 -39
View File
@@ -119,6 +119,17 @@ pub async fn handle_ws_chat(
async fn handle_socket(socket: WebSocket, state: AppState, _session_id: Option<String>) {
let (mut sender, mut receiver) = socket.split();
// Build a persistent Agent for this connection so history is maintained across turns.
let config = state.config.lock().clone();
let mut agent = match crate::agent::Agent::from_config(&config) {
Ok(a) => a,
Err(e) => {
let err = serde_json::json!({"type": "error", "message": format!("Failed to initialise agent: {e}")});
let _ = sender.send(Message::Text(err.to_string().into())).await;
return;
}
};
while let Some(msg) = receiver.next().await {
let msg = match msg {
Ok(Message::Text(text)) => text,
@@ -161,45 +172,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, _session_id: Option<S
"model": state.model,
}));
// Simple single-turn chat (no streaming for now — use provider.chat_with_system)
let system_prompt = {
let config_guard = state.config.lock();
crate::channels::build_system_prompt(
&config_guard.workspace_dir,
&state.model,
&[],
&[],
Some(&config_guard.identity),
None,
)
};
let messages = vec![
crate::providers::ChatMessage::system(system_prompt),
crate::providers::ChatMessage::user(&content),
];
let multimodal_config = state.config.lock().multimodal.clone();
let prepared =
match crate::multimodal::prepare_messages_for_provider(&messages, &multimodal_config)
.await
{
Ok(p) => p,
Err(e) => {
let err = serde_json::json!({
"type": "error",
"message": format!("Multimodal prep failed: {e}")
});
let _ = sender.send(Message::Text(err.to_string().into())).await;
continue;
}
};
match state
.provider
.chat_with_history(&prepared.messages, &state.model, state.temperature)
.await
{
// Multi-turn chat via persistent Agent (history is maintained across turns)
match agent.turn(&content).await {
Ok(response) => {
// Send the full response as a done message
let done = serde_json::json!({
+1 -1
View File
@@ -79,7 +79,7 @@ fn show_integration_info(config: &Config, name: &str) -> Result<()> {
let Some(entry) = entries.iter().find(|e| e.name.to_lowercase() == name_lower) else {
anyhow::bail!(
"Unknown integration: {name}. Check README for supported integrations or run `zeroclaw onboard --interactive` to configure channels/providers."
"Unknown integration: {name}. Check README for supported integrations or run `zeroclaw onboard` to configure channels/providers."
);
};
+30 -19
View File
@@ -48,7 +48,7 @@ fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
fn print_no_command_help() -> Result<()> {
println!("No command provided.");
println!("Try `zeroclaw onboard --interactive` to initialize your workspace.");
println!("Try `zeroclaw onboard` to initialize your workspace.");
println!();
let mut cmd = Cli::command();
@@ -157,10 +157,6 @@ struct Cli {
enum Commands {
/// Initialize your workspace and configuration
Onboard {
/// Run the full interactive wizard (default is quick setup)
#[arg(long)]
interactive: bool,
/// Overwrite existing config without confirmation
#[arg(long)]
force: bool,
@@ -173,7 +169,7 @@ enum Commands {
#[arg(long)]
channels_only: bool,
/// API key (used in quick mode, ignored with --interactive)
/// API key for provider configuration
#[arg(long)]
api_key: Option<String>,
@@ -726,7 +722,6 @@ async fn main() -> Result<()> {
// Tokio runtime. To avoid "Cannot drop a runtime in a context where blocking is
// not allowed", we run the wizard on a blocking thread via spawn_blocking.
if let Commands::Onboard {
interactive,
force,
reinit,
channels_only,
@@ -736,7 +731,6 @@ async fn main() -> Result<()> {
memory,
} = &cli.command
{
let interactive = *interactive;
let force = *force;
let reinit = *reinit;
let channels_only = *channels_only;
@@ -745,14 +739,8 @@ async fn main() -> Result<()> {
let model = model.clone();
let memory = memory.clone();
if interactive && channels_only {
bail!("Use either --interactive or --channels-only, not both");
}
if reinit && channels_only {
bail!("Use either --reinit or --channels-only, not both");
}
if reinit && !interactive {
bail!("--reinit requires --interactive mode");
bail!("--reinit and --channels-only cannot be used together");
}
if channels_only
&& (api_key.is_some() || provider.is_some() || model.is_some() || memory.is_some())
@@ -806,8 +794,6 @@ async fn main() -> Result<()> {
let config = if channels_only {
Box::pin(onboard::run_channels_repair_wizard()).await
} else if interactive {
Box::pin(onboard::run_wizard(force)).await
} else {
onboard::run_quick_setup(
api_key.as_deref(),
@@ -818,6 +804,33 @@ async fn main() -> Result<()> {
)
.await
}?;
// Display pairing code — user enters it in the dashboard to pair securely.
// The code is one-time use and brute-force protected (5 attempts → lockout).
// No auth material is placed in URLs to prevent leakage via browser history,
// Referer headers, clipboard, or proxy logs.
if config.gateway.require_pairing {
let pairing = security::PairingGuard::new(true, &config.gateway.paired_tokens);
if let Some(code) = pairing.pairing_code() {
println!();
println!(" \x1b[1;34m🦀 Gateway Pairing Code\x1b[0m");
println!();
println!(" \x1b[1;34m┌──────────────┐\x1b[0m");
println!(" \x1b[1;34m│\x1b[0m \x1b[1m{code}\x1b[0m \x1b[1;34m│\x1b[0m");
println!(" \x1b[1;34m└──────────────┘\x1b[0m");
println!();
println!(" Enter this code in the dashboard to pair your device.");
println!(" The code is single-use and expires after pairing.");
println!();
println!(
" \x1b[2mDashboard: http://127.0.0.1:{}\x1b[0m",
config.gateway.port
);
println!(" \x1b[2mDocs: https://www.zeroclawlabs.ai/docs\x1b[0m");
println!();
}
}
// Auto-start channels if user said yes during wizard
if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
channels::start_channels(config).await?;
@@ -2140,7 +2153,6 @@ mod tests {
match cli.command {
Commands::Onboard {
interactive,
force,
channels_only,
api_key,
@@ -2148,7 +2160,6 @@ mod tests {
model,
..
} => {
assert!(!interactive);
assert!(!force);
assert!(!channels_only);
assert_eq!(provider.as_deref(), Some("openrouter"));
+1 -2
View File
@@ -4,7 +4,7 @@ pub mod wizard;
#[allow(unused_imports)]
pub use wizard::{
run_channels_repair_wizard, run_models_list, run_models_refresh, run_models_refresh_all,
run_models_set, run_models_status, run_quick_setup, run_wizard,
run_models_set, run_models_status, run_quick_setup,
};
#[cfg(test)]
@@ -15,7 +15,6 @@ mod tests {
#[test]
fn wizard_functions_are_reexported() {
assert_reexport_exists(run_wizard);
assert_reexport_exists(run_channels_repair_wizard);
assert_reexport_exists(run_quick_setup);
assert_reexport_exists(run_models_refresh);
+1 -1
View File
@@ -360,7 +360,6 @@ fn apply_provider_update(
/// Non-interactive setup: generates a sensible default config instantly.
/// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`.
/// Use `zeroclaw onboard --interactive` for the full wizard.
fn backend_key_from_choice(choice: usize) -> &'static str {
selectable_memory_backends()
.get(choice)
@@ -3877,6 +3876,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
Some(channel)
},
allowed_users,
interrupt_on_new_message: false,
});
}
ChannelMenuChoice::IMessage => {
+1 -1
View File
@@ -1367,7 +1367,7 @@ fn create_provider_with_url_and_options(
}
_ => anyhow::bail!(
"Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard --interactive` to reconfigure.\n\
"Unknown provider: {name}. Check README for supported providers or run `zeroclaw onboard` to reconfigure.\n\
Tip: Use \"custom:https://your-api.com\" for OpenAI-compatible endpoints.\n\
Tip: Use \"anthropic-custom:https://your-api.com\" for Anthropic-compatible endpoints."
),