Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5919becab9 | |||
| 287d9bdc17 | |||
| f900d7079e | |||
| 5a5b5a4402 | |||
| 06f65fb711 | |||
| 46d4b13c22 | |||
| 8fcbb6eb2d | |||
| ce22eba7d0 | |||
| 7ba4d06e78 | |||
| dc12d03876 | |||
| 3151604b04 | |||
| c5fcda06ad | |||
| 51a52dcadb |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -7924,7 +7924,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclaw"
|
||||
version = "0.1.9"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-imap",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(¬ify_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(
|
||||
|
||||
@@ -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)| {
|
||||
|
||||
@@ -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
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user