Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6e8a76aab | ||
|
|
a3c191006e | ||
|
|
dd35ed97b8 |
No files matched your search
@@ -1,26 +0,0 @@
|
|||||||
# detect-secrets exclusion patterns (regex)
|
|
||||||
#
|
|
||||||
# Note: detect-secrets does not read this file by default. If you want these
|
|
||||||
# applied, wire them into your scan command (e.g. translate to --exclude-files
|
|
||||||
# / --exclude-lines) or into a baseline's filters_used.
|
|
||||||
|
|
||||||
[exclude-files]
|
|
||||||
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
|
|
||||||
pattern = (^|/)pnpm-lock\.yaml$
|
|
||||||
|
|
||||||
[exclude-lines]
|
|
||||||
# Fastlane checks for private key marker; not a real key.
|
|
||||||
pattern = key_content\.include\?\("BEGIN PRIVATE KEY"\)
|
|
||||||
# UI label string for Anthropic auth mode.
|
|
||||||
pattern = case \.apiKeyEnv: "API key \(env var\)"
|
|
||||||
# CodingKeys mapping uses apiKey literal.
|
|
||||||
pattern = case apikey = "apiKey"
|
|
||||||
# Schema labels referencing password fields (not actual secrets).
|
|
||||||
pattern = "gateway\.remote\.password"
|
|
||||||
pattern = "gateway\.auth\.password"
|
|
||||||
# Schema label for talk API key (label text only).
|
|
||||||
pattern = "talk\.apiKey"
|
|
||||||
# checking for typeof is not something we care about.
|
|
||||||
pattern = === "string"
|
|
||||||
# specific optional-chaining password check that didn't match the line above.
|
|
||||||
pattern = typeof remote\?\.password === "string"
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
.git
|
|
||||||
.worktrees
|
|
||||||
.bun-cache
|
|
||||||
.bun
|
|
||||||
.tmp
|
|
||||||
**/.tmp
|
|
||||||
.DS_Store
|
|
||||||
**/.DS_Store
|
|
||||||
*.png
|
|
||||||
*.jpg
|
|
||||||
*.jpeg
|
|
||||||
*.webp
|
|
||||||
*.gif
|
|
||||||
*.mp4
|
|
||||||
*.mov
|
|
||||||
*.wav
|
|
||||||
*.mp3
|
|
||||||
node_modules
|
|
||||||
**/node_modules
|
|
||||||
.pnpm-store
|
|
||||||
**/.pnpm-store
|
|
||||||
.turbo
|
|
||||||
**/.turbo
|
|
||||||
.cache
|
|
||||||
**/.cache
|
|
||||||
.next
|
|
||||||
**/.next
|
|
||||||
coverage
|
|
||||||
**/coverage
|
|
||||||
*.log
|
|
||||||
tmp
|
|
||||||
**/tmp
|
|
||||||
|
|
||||||
# build artifacts
|
|
||||||
dist
|
|
||||||
**/dist
|
|
||||||
apps/macos/.build
|
|
||||||
apps/ios/build
|
|
||||||
**/*.trace
|
|
||||||
|
|
||||||
# large app trees not needed for CLI build
|
|
||||||
apps/
|
|
||||||
assets/
|
|
||||||
Peekaboo/
|
|
||||||
Swabble/
|
|
||||||
Core/
|
|
||||||
Users/
|
|
||||||
vendor/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Report a problem or unexpected behavior in Clawdbot.
|
|
||||||
title: "[Bug]: "
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
What went wrong?
|
|
||||||
|
|
||||||
## Steps to reproduce
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
## Expected behavior
|
|
||||||
What did you expect to happen?
|
|
||||||
|
|
||||||
## Actual behavior
|
|
||||||
What actually happened?
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
- Clawdbot version:
|
|
||||||
- OS:
|
|
||||||
- Install method (pnpm/npx/docker/etc):
|
|
||||||
|
|
||||||
## Logs or screenshots
|
|
||||||
Paste relevant logs or add screenshots (redact secrets).
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
blank_issues_enabled: true
|
|
||||||
contact_links:
|
|
||||||
- name: Onboarding
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: New to Clawdbot? Join Discord for setup guidance from Krill in #help.
|
|
||||||
- name: Support
|
|
||||||
url: https://discord.gg/clawd
|
|
||||||
about: Get help from Krill and the community on Discord in #help.
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea or improvement for Clawdbot.
|
|
||||||
title: "[Feature]: "
|
|
||||||
labels: enhancement
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Describe the problem you are trying to solve or the opportunity you see.
|
|
||||||
|
|
||||||
## Proposed solution
|
|
||||||
What would you like Clawdbot to do?
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
Any other approaches you have considered?
|
|
||||||
|
|
||||||
## Additional context
|
|
||||||
Links, screenshots, or related issues.
|
|
||||||
@@ -5,84 +5,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
install-check:
|
build:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies (frozen)
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
checks:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
runtime: [node, bun]
|
||||||
- runtime: node
|
|
||||||
task: lint
|
|
||||||
command: pnpm lint
|
|
||||||
- runtime: node
|
|
||||||
task: test
|
|
||||||
command: pnpm test
|
|
||||||
- runtime: node
|
|
||||||
task: build
|
|
||||||
command: pnpm build
|
|
||||||
- runtime: node
|
|
||||||
task: protocol
|
|
||||||
command: pnpm protocol:check
|
|
||||||
- runtime: node
|
|
||||||
task: format
|
|
||||||
command: pnpm format
|
|
||||||
- runtime: bun
|
|
||||||
task: test
|
|
||||||
command: bunx vitest run
|
|
||||||
- runtime: bun
|
|
||||||
task: build
|
|
||||||
command: bunx tsc -p tsconfig.json
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -103,186 +31,32 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
|
if: matrix.runtime == 'node'
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
if: matrix.runtime == 'bun'
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
# bun.sh downloads currently fail with:
|
||||||
|
# "Failed to list releases from GitHub: 401" -> "Unexpected HTTP response: 400"
|
||||||
|
bun-download-url: "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-x64.zip"
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Setup Node.js (tooling for bun)
|
||||||
run: |
|
if: matrix.runtime == 'bun'
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Install detect-secrets
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install detect-secrets==1.5.0
|
|
||||||
|
|
||||||
- name: Detect secrets
|
|
||||||
run: |
|
|
||||||
if ! detect-secrets scan --baseline .secrets.baseline; then
|
|
||||||
echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
checks-windows:
|
|
||||||
runs-on: blacksmith-4vcpu-windows-2025
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- runtime: node
|
|
||||||
task: lint
|
|
||||||
command: pnpm lint
|
|
||||||
- runtime: node
|
|
||||||
task: test
|
|
||||||
command: pnpm test
|
|
||||||
- runtime: node
|
|
||||||
task: build
|
|
||||||
command: pnpm build
|
|
||||||
- runtime: node
|
|
||||||
task: protocol
|
|
||||||
command: pnpm protocol:check
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22
|
||||||
check-latest: true
|
|
||||||
|
|
||||||
- name: Setup Bun
|
|
||||||
uses: oven-sh/setup-bun@v2
|
|
||||||
with:
|
|
||||||
bun-version: latest
|
|
||||||
|
|
||||||
- name: Runtime versions
|
|
||||||
run: |
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
bun -v
|
|
||||||
|
|
||||||
- name: Capture node path
|
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
|
||||||
|
|
||||||
- name: Enable corepack and pin pnpm
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.23.0 --activate
|
|
||||||
pnpm -v
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
env:
|
|
||||||
CI: true
|
|
||||||
run: |
|
|
||||||
export PATH="$NODE_BIN:$PATH"
|
|
||||||
which node
|
|
||||||
node -v
|
|
||||||
pnpm -v
|
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
||||||
run: ${{ matrix.command }}
|
|
||||||
|
|
||||||
checks-macos:
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: macos-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- task: test
|
|
||||||
command: pnpm test
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
- name: Checkout submodules (retry)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git submodule sync --recursive
|
|
||||||
for attempt in 1 2 3 4 5; do
|
|
||||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.x
|
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Runtime versions
|
- name: Runtime versions
|
||||||
run: |
|
run: |
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
|
if [ "${{ matrix.runtime }}" = "bun" ]; then bun -v; fi
|
||||||
|
|
||||||
- name: Capture node path
|
- name: Capture node path
|
||||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||||
@@ -301,44 +75,38 @@ jobs:
|
|||||||
which node
|
which node
|
||||||
node -v
|
node -v
|
||||||
pnpm -v
|
pnpm -v
|
||||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
- name: Lint (node)
|
||||||
run: ${{ matrix.command }}
|
if: matrix.runtime == 'node'
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Test (node)
|
||||||
|
if: matrix.runtime == 'node'
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Build (node)
|
||||||
|
if: matrix.runtime == 'node'
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Protocol check (node)
|
||||||
|
if: matrix.runtime == 'node'
|
||||||
|
run: pnpm protocol:check
|
||||||
|
|
||||||
|
- name: Lint (bun)
|
||||||
|
if: matrix.runtime == 'bun'
|
||||||
|
run: bunx biome check src
|
||||||
|
|
||||||
|
- name: Test (bun)
|
||||||
|
if: matrix.runtime == 'bun'
|
||||||
|
run: bunx vitest run
|
||||||
|
|
||||||
|
- name: Build (bun)
|
||||||
|
if: matrix.runtime == 'bun'
|
||||||
|
run: bunx tsc -p tsconfig.json
|
||||||
|
|
||||||
macos-app:
|
macos-app:
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- task: lint
|
|
||||||
command: |
|
|
||||||
swiftlint --config .swiftlint.yml
|
|
||||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
|
||||||
- task: build
|
|
||||||
command: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if swift build --package-path apps/macos --configuration release; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
|
||||||
sleep $((attempt * 20))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
- task: test
|
|
||||||
command: |
|
|
||||||
set -euo pipefail
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
|
||||||
sleep $((attempt * 20))
|
|
||||||
done
|
|
||||||
exit 1
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -373,10 +141,36 @@ jobs:
|
|||||||
xcodebuild -version
|
xcodebuild -version
|
||||||
swift --version
|
swift --version
|
||||||
|
|
||||||
- name: Run ${{ matrix.task }}
|
- name: SwiftLint
|
||||||
run: ${{ matrix.command }}
|
run: swiftlint --config .swiftlint.yml
|
||||||
|
|
||||||
|
- name: SwiftFormat (lint mode)
|
||||||
|
run: swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||||
|
|
||||||
|
- name: Swift build (release)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if swift build --package-path apps/macos --configuration release; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Swift tests (coverage)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||||
|
sleep $((attempt * 20))
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
ios:
|
ios:
|
||||||
if: false # ignore iOS in CI for now
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -549,15 +343,7 @@ jobs:
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
android:
|
android:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- task: test
|
|
||||||
command: ./gradlew --no-daemon :app:testDebugUnitTest
|
|
||||||
- task: build
|
|
||||||
command: ./gradlew --no-daemon :app:assembleDebug
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -585,8 +371,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
with:
|
|
||||||
accept-android-sdk-licenses: false
|
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v4
|
||||||
@@ -599,6 +383,6 @@ jobs:
|
|||||||
"platforms;android-36" \
|
"platforms;android-36" \
|
||||||
"build-tools;36.0.0"
|
"build-tools;36.0.0"
|
||||||
|
|
||||||
- name: Run Android ${{ matrix.task }}
|
- name: Android unit tests + debug build
|
||||||
working-directory: apps/android
|
working-directory: apps/android
|
||||||
run: ${{ matrix.command }}
|
run: ./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
name: Install Smoke
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
install-smoke:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout CLI
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: corepack enable
|
|
||||||
|
|
||||||
- name: Install pnpm deps (minimal)
|
|
||||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Run installer docker tests
|
|
||||||
env:
|
|
||||||
CLAWDBOT_INSTALL_URL: https://clawd.bot/install.sh
|
|
||||||
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
|
|
||||||
CLAWDBOT_NO_ONBOARD: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
|
||||||
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
|
|
||||||
run: pnpm test:install:smoke
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Workflow Sanity
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
no-tabs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Fail on tabs in workflow files
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
root = pathlib.Path(".github/workflows")
|
|
||||||
bad: list[str] = []
|
|
||||||
for path in sorted(root.rglob("*.yml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
for path in sorted(root.rglob("*.yaml")):
|
|
||||||
if b"\t" in path.read_bytes():
|
|
||||||
bad.append(str(path))
|
|
||||||
|
|
||||||
if bad:
|
|
||||||
print("Tabs found in workflow file(s):")
|
|
||||||
for path in bad:
|
|
||||||
print(f"- {path}")
|
|
||||||
sys.exit(1)
|
|
||||||
PY
|
|
||||||
@@ -1,40 +1,29 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.env
|
.env
|
||||||
docker-compose.extra.yml
|
|
||||||
dist
|
dist
|
||||||
*.bun-build
|
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
bun.lock
|
|
||||||
bun.lockb
|
|
||||||
coverage
|
coverage
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
ui/src/ui/__screenshots__/
|
|
||||||
ui/playwright-report/
|
|
||||||
ui/test-results/
|
|
||||||
|
|
||||||
# Bun build artifacts
|
# Bun build artifacts
|
||||||
*.bun-build
|
*.bun-build
|
||||||
apps/macos/.build/
|
apps/macos/.build/
|
||||||
apps/shared/ClawdbotKit/.build/
|
apps/shared/ClawdisKit/.build/
|
||||||
**/ModuleCache/
|
bin/clawdis-mac
|
||||||
bin/
|
|
||||||
bin/clawdbot-mac
|
|
||||||
bin/docs-list
|
bin/docs-list
|
||||||
apps/macos/.build-local/
|
apps/macos/.build-local/
|
||||||
apps/macos/.swiftpm/
|
apps/macos/.swiftpm/
|
||||||
apps/shared/ClawdbotKit/.swiftpm/
|
apps/shared/ClawdisKit/.swiftpm/
|
||||||
Core/
|
Core/
|
||||||
apps/ios/*.xcodeproj/
|
apps/ios/*.xcodeproj/
|
||||||
apps/ios/*.xcworkspace/
|
apps/ios/*.xcworkspace/
|
||||||
apps/ios/.swiftpm/
|
apps/ios/.swiftpm/
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
.bundle.hash
|
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
apps/ios/fastlane/README.md
|
apps/ios/fastlane/README.md
|
||||||
@@ -50,10 +39,3 @@ apps/ios/*.dSYM.zip
|
|||||||
|
|
||||||
# provisioning profiles (local)
|
# provisioning profiles (local)
|
||||||
apps/ios/*.mobileprovision
|
apps/ios/*.mobileprovision
|
||||||
.env
|
|
||||||
|
|
||||||
# Local untracked files
|
|
||||||
.local/
|
|
||||||
.vscode/
|
|
||||||
IDENTITY.md
|
|
||||||
USER.md
|
|
||||||
@@ -1 +1 @@
|
|||||||
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
|
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
|
||||||
"indentWidth": 2,
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/oxlintrc",
|
|
||||||
"extends": ["recommended"]
|
|
||||||
}
|
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.5.0",
|
|
||||||
"plugins_used": [
|
|
||||||
{
|
|
||||||
"name": "ArtifactoryDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AWSKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AzureStorageKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Base64HighEntropyString",
|
|
||||||
"limit": 4.5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BasicAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "CloudantDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DiscordBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitHubTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "GitLabTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "HexHighEntropyString",
|
|
||||||
"limit": 3.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCloudIamDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IbmCosHmacDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "IPPublicDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "JwtTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "KeywordDetector",
|
|
||||||
"keyword_exclude": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "MailchimpDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "NpmDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "OpenAIDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PrivateKeyDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "PypiTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SendGridDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SlackDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SoftlayerDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SquareOAuthDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StripeDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TelegramBotTokenDetector"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "TwilioKeyDetector"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"filters_used": [
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_baseline_file",
|
|
||||||
"filename": ".secrets.baseline"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
|
|
||||||
"min_level": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_indirect_reference"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_likely_id_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_lock_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_potential_uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_sequential_string"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_swagger_file"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.heuristic.is_templated_secret"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_file",
|
|
||||||
"pattern": [
|
|
||||||
"(^|/)pnpm-lock\\.yaml$"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "detect_secrets.filters.regex.should_exclude_line",
|
|
||||||
"pattern": [
|
|
||||||
"key_content\\.include\\?\\(\"BEGIN PRIVATE KEY\"\\)",
|
|
||||||
"case \\.apiKeyEnv: \"API key \\(env var\\)\"",
|
|
||||||
"case apikey = \"apiKey\"",
|
|
||||||
"\"gateway\\.remote\\.password\"",
|
|
||||||
"\"gateway\\.auth\\.password\"",
|
|
||||||
"\"talk\\.apiKey\"",
|
|
||||||
"=== \"string\"",
|
|
||||||
"typeof remote\\?\\.password === \"string\""
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"results": {
|
|
||||||
".env.example": [
|
|
||||||
{
|
|
||||||
"type": "Twilio API Key",
|
|
||||||
"filename": ".env.example",
|
|
||||||
"hashed_secret": "3c7206eff845bc69cf12d904d0f95f9aec15535e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"appcast.xml": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "1b1c2b73eca84e441a823c37a06c71c9fadcfe24",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 19
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "5c47736fee5151b26b3bb61bb38955da0e8937c6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "appcast.xml",
|
|
||||||
"hashed_secret": "bbbca47179268f154c63affa0ca441c6e49e650f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "e761624445731fcb8b15da94343c6b92e507d190",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 26
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/AnthropicAuthResolverTests.swift",
|
|
||||||
"hashed_secret": "a23c8630c8a5fbaa21f095e0269c135c20d21689",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 42
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/ConnectionsSettingsSmokeTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 83
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "apps/macos/Tests/ClawdbotIPCTests/TailscaleIntegrationSectionTests.swift",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 27
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/configuration.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 268
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 465
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 718
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 760
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 859
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/configuration.md",
|
|
||||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 982
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/faq.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 593
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/faq.md",
|
|
||||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 650
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills-config.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills-config.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 28
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/skills.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/skills.md",
|
|
||||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 97
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/tailscale.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/tailscale.md",
|
|
||||||
"hashed_secret": "9cb0dc5383312aa15b9dc6745645bde18ff5ade9",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 52
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/talk.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/talk.md",
|
|
||||||
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"docs/telegram.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "docs/telegram.md",
|
|
||||||
"hashed_secret": "e9fe51f94eadabf54dbf2fbbd57188b9abee436e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 57
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/local-places/SERVER_README.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/local-places/SERVER_README.md",
|
|
||||||
"hashed_secret": "6d9c68c603e465077bdd49c62347fe54717f83a3",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 28
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/openai-whisper-api/SKILL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/openai-whisper-api/SKILL.md",
|
|
||||||
"hashed_secret": "1077361f94d70e1ddcc7c6dc581a489532a81d03",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 39
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"skills/trello/SKILL.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "skills/trello/SKILL.md",
|
|
||||||
"hashed_secret": "11fa7c37d697f30e6aee828b4426a10f83ab2380",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 18
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/models-config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/models-config.test.ts",
|
|
||||||
"hashed_secret": "3a81eb091f80c845232225be5663d270e90dacb7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 90
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/agents/skills.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "3acfb2c2b433c0ea7ff107e33df91b18e52f960f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 158
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "7a85f4764bbd6daf1c3545efbbf0f279a6dc0beb",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 265
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "5df3a673d724e8a1eb673a8baf623e183940804d",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 462
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/agents/skills.test.ts",
|
|
||||||
"hashed_secret": "8921daaa546693e52bc1f9c40bdcf15e816e0448",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 490
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/browser/target-id.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Hex High Entropy String",
|
|
||||||
"filename": "src/browser/target-id.test.ts",
|
|
||||||
"hashed_secret": "4e126c049580d66ca1549fa534d95a7263f27f46",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 16
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/antigravity-oauth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "709d0f232b6ac4f8d24dec3e4fabfdb14257174f",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 17
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/commands/antigravity-oauth.ts",
|
|
||||||
"hashed_secret": "3848603b8e866f62d07c206ff622279b9dcb0238",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 20
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/commands/onboard-auth.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/commands/onboard-auth.ts",
|
|
||||||
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 50
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/config/config.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/config/config.test.ts",
|
|
||||||
"hashed_secret": "bea2f7b64fab8d1d414d0449530b1e088d36d5b1",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 520
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/gateway/server.auth.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/gateway/server.auth.test.ts",
|
|
||||||
"hashed_secret": "a4b48a81cdab1e1a5dd37907d6c85ca1c61ddc7c",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 109
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "df98a117ddabf85991b9fe0e268214dc0e1254dc",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/env.test.ts",
|
|
||||||
"hashed_secret": "6d811dc1f59a55ca1a3d38b5042a062b9f79e8ec",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 25
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/infra/shell-env.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "65c10dc3549fe07424148a8a4790a3341ecbc253",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 35
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "64db6bf7f0e5a0491df4419f0eb1bbcc402989e8",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 56
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "e013ffda590d2178607c16d11b1ea42f75ceb0e7",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 73
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "Base64 High Entropy String",
|
|
||||||
"filename": "src/infra/shell-env.test.ts",
|
|
||||||
"hashed_secret": "be6ee9a6bf9f2dad84a5a67d6c0576a5bacc391e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 75
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"src/web/qr-image.test.ts": [
|
|
||||||
{
|
|
||||||
"type": "Hex High Entropy String",
|
|
||||||
"filename": "src/web/qr-image.test.ts",
|
|
||||||
"hashed_secret": "564666dc1ca6e7318b2d5feeb1ce7b5bf717411e",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 12
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"vendor/a2ui/README.md": [
|
|
||||||
{
|
|
||||||
"type": "Secret Keyword",
|
|
||||||
"filename": "vendor/a2ui/README.md",
|
|
||||||
"hashed_secret": "2619a5397a5d054dab3fe24e6a8da1fbd76ec3a6",
|
|
||||||
"is_verified": false,
|
|
||||||
"line_number": 123
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"generated_at": "2026-01-05T13:01:00Z"
|
|
||||||
}
|
|
||||||
@@ -48,4 +48,4 @@
|
|||||||
--allman false
|
--allman false
|
||||||
|
|
||||||
# Exclusions
|
# Exclusions
|
||||||
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,Peekaboo,Swabble,apps/android,apps/ios,apps/shared,apps/macos/Sources/ClawdisProtocol,apps/macos/Sources/ClawdbotProtocol
|
--exclude .build,.swiftpm,DerivedData,node_modules,dist,coverage,xcuserdata,apps/macos/Sources/ClawdisProtocol
|
||||||
@@ -17,8 +17,6 @@ excluded:
|
|||||||
- dist
|
- dist
|
||||||
- coverage
|
- coverage
|
||||||
- "*.playground"
|
- "*.playground"
|
||||||
# Generated (protocol-gen-swift.ts)
|
|
||||||
- apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift
|
|
||||||
|
|
||||||
analyzer_rules:
|
analyzer_rules:
|
||||||
- unused_declaration
|
- unused_declaration
|
||||||
@@ -110,10 +108,6 @@ function_body_length:
|
|||||||
warning: 150
|
warning: 150
|
||||||
error: 300
|
error: 300
|
||||||
|
|
||||||
function_parameter_count:
|
|
||||||
warning: 7
|
|
||||||
error: 10
|
|
||||||
|
|
||||||
file_length:
|
file_length:
|
||||||
warning: 1500
|
warning: 1500
|
||||||
error: 2500
|
error: 2500
|
||||||
|
|||||||
@@ -1,141 +1,71 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
|
||||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
- Tests: colocated `*.test.ts`.
|
- Tests: colocated `*.test.ts`.
|
||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
|
||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
|
||||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
|
||||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
|
||||||
- When Peter asks for links, reply with full `https://docs.clawd.bot/...` URLs (not root-relative).
|
|
||||||
- When you touch docs, end the reply with the `https://docs.clawd.bot/...` URLs you referenced.
|
|
||||||
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
|
|
||||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
|
||||||
- Install deps: `pnpm install`
|
- Install deps: `pnpm install`
|
||||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
- Run CLI in dev: `pnpm clawdis ...` (tsx entry) or `pnpm dev` for `src/index.ts`.
|
||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
|
||||||
- Type-check/build: `pnpm build` (tsc)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm lint` before commits.
|
- Formatting/linting via Biome; run `pnpm lint` before commits.
|
||||||
- Add brief code comments for tricky or non-obvious logic.
|
|
||||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
- Keep every file ≤ 500 LOC; refactor or split before exceeding and check frequently.
|
||||||
- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
|
||||||
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
|
||||||
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
|
||||||
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
|
||||||
- Full kit + what’s covered: `docs/testing.md`.
|
|
||||||
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
|
||||||
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
|
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||||
- Group related changes; avoid bundling unrelated refactors.
|
- Group related changes; avoid bundling unrelated refactors.
|
||||||
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
|
|
||||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
|
||||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
|
||||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
|
||||||
- When working on an issue: reference the issue in the changelog entry.
|
|
||||||
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
|
|
||||||
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.
|
|
||||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
|
||||||
|
|
||||||
## Shorthand Commands
|
|
||||||
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
|
||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
|
||||||
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
|
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## Security & Configuration Tips
|
||||||
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
|
- Web provider stores creds at `~/.clawdis/credentials/`; rerun `clawdis login` if logged out.
|
||||||
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
|
- Pi sessions live under `~/.clawdis/sessions/` by default; the base directory is not configurable.
|
||||||
- Environment variables: see `~/.profile`.
|
|
||||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
|
|
||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Gateway currently runs only as the menubar app (launchctl shows `application.com.steipete.clawdis.debug.*`), there is no separate LaunchAgent/helper label installed. Restart via the Clawdis Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdis` rather than expecting `com.steipete.clawdis`. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for subsystem `com.steipete.clawdis`; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- Also read the shared guardrails at `~/Projects/oracle/AGENTS.md` and `~/Projects/agent-scripts/AGENTS.MD` before making changes; align with any cross-repo rules noted there.
|
||||||
- Never update the Carbon dependency.
|
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
|
||||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
|
||||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
|
||||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/Clawdbot/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
|
||||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
- Notary key file lives at `~/Library/CloudStorage/Dropbox/Backup/AppStore/AuthKey_NJF3NFGTS3.p8` (Sparkle keys live under `~/Library/CloudStorage/Dropbox/Backup/Sparkle`).
|
||||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless Peter explicitly asks (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
- **Multi-agent safety:** when Peter says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When Peter says "commit", scope to your changes only. When Peter says "commit all", commit everything in grouped chunks.
|
||||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless Peter explicitly asks.
|
||||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless Peter explicitly asks.
|
||||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
- When asked to open a “session” file, open the Pi session logs under `~/.clawdis/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from Mac Studio, SSH via Tailscale and read the same path there.
|
||||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdis variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
|
||||||
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
|
|
||||||
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
|
|
||||||
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
|
|
||||||
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
|
|
||||||
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
|
|
||||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
|
||||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
|
||||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
|
||||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
|
||||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
|
||||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
- Command template should stay `clawdis-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
|
||||||
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
|
- launchd PATH is minimal; ensure the app’s launch agent sets PATH to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/steipete/Library/pnpm` so `pnpm`/`clawdis` binaries resolve when invoked via `clawdis-mac`.
|
||||||
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
- For manual `clawdis send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
|
||||||
|
|
||||||
## NPM + 1Password (publish/verify)
|
|
||||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
|
||||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
|
||||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
|
||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
|
||||||
- Kill the tmux session after publish.
|
|
||||||
|
|
||||||
## Exclamation Mark Escaping Workaround
|
## Exclamation Mark Escaping Workaround
|
||||||
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdis send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# WRONG - will send "Hello\\!" with backslash
|
# WRONG - will send "Hello\\!" with backslash
|
||||||
clawdbot message send --to "+1234" --message 'Hello!'
|
clawdis send --to "+1234" --message 'Hello!'
|
||||||
|
|
||||||
# CORRECT - use heredoc to avoid escaping
|
# CORRECT - use heredoc to avoid escaping
|
||||||
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
clawdis send --to "+1234" --message "$(cat <<'EOF'
|
||||||
Hello!
|
Hello!
|
||||||
EOF
|
EOF
|
||||||
)"
|
)"
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a Claude Code quirk, not a clawdbot bug.
|
This is a Claude Code quirk, not a clawdis bug.
|
||||||
@@ -1,545 +1,232 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2026.1.15 (unreleased)
|
## Unreleased — 2025-12-23
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Telegram/WhatsApp: native replies now target the original inbound message; reply context is captured in `ReplyTo*` fields for templates. (Thanks @joshp123 for the PR and follow-up question.)
|
||||||
|
|
||||||
|
## 2.0.0-beta2 — 2025-12-21
|
||||||
|
|
||||||
|
Second beta focused on bundled gateway packaging, skills management, onboarding polish, and provider reliability.
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Bundled gateway packaging: bun-compiled embedded gateway, new `gateway-daemon` command, launchd support, DMG packaging (zip+DMG).
|
||||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
- Skills platform: managed/bundled skills, install metadata + installers (uv), skill search + website, media/transcription helpers.
|
||||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
- macOS app: new Connections settings w/ provider status + QR login, skills settings redesign w/ install targets, models list loaded from the Gateway, clearer local/remote gateway choices.
|
||||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
- Web/agent UX: tool summary streaming + runtime toggle, WhatsApp QR login tool, agent steering queue, voice wake routes to main session, workspace bootstrap ritual.
|
||||||
|
|
||||||
|
### Gateway & providers
|
||||||
|
- Gateway: `models.list`, provider status events + RPC coverage, tailscale auth + PAM, bind-mode config, enriched agent WS logs, safer upgrade socket handling, fixed handshake auth crash.
|
||||||
|
- WhatsApp Web: QR login flow improvements (logged-out clearing, wait flow), self-chat mode handling, removed batching delay, web inbox made non-blocking.
|
||||||
|
- Telegram: normalized chat IDs with clearer error reporting.
|
||||||
|
|
||||||
|
### Canvas & browser control
|
||||||
|
- Canvas host served on Gateway port; removed standalone canvasHost port config; restored action bridge; refreshed A2UI bundle + message context; bridge canvas host for nodes.
|
||||||
|
- A2UI full-screen gutters + status clearance after successful load to avoid overlay collisions.
|
||||||
|
- Browser control API simplified; added MCP tool dispatch + native actions; control server can start without Playwright; hook timeouts extended.
|
||||||
|
|
||||||
|
### macOS UI polish
|
||||||
|
- Onboarding chat UI: kickoff flow, bubble tails, spacing + bottom bar refinements, window sizing tweaks, show Dock icon during onboarding.
|
||||||
|
- Skills UI: stabilized action column, fixed install target access, refined list layout and sizing, always show CLI installer.
|
||||||
|
- Remote/local gateway: auto-enable local gateway, clearer labels, re-ensure remote tunnel, hide local bridge discovery in remote mode.
|
||||||
|
|
||||||
|
### Build, CI, deps
|
||||||
|
- Bundled playwright-core + chromium-bidi/long; bun gateway bytecode builds; swiftformat/biome CI fixes; iOS lint script updates; Android icon/compiler updates; ignored new ClawdisKit `.swiftpm` path.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
- README architecture refresh + npm header image fix; onboarding/bootstrap steps; skills install guidance + new skills; browser/canvas control docs; bundled gateway + DMG packaging notes.
|
||||||
|
|
||||||
|
## 2.0.0-beta1 — 2025-12-19
|
||||||
|
|
||||||
|
First Clawdis release post rebrand. This is a semver-major because we dropped legacy providers/agents and moved defaults to new paths while adding a full macOS companion app, a WebSocket Gateway, and an iOS node.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- macOS: Voice Wake / push-to-talk no longer initialize `AVAudioEngine` at app launch, preventing Bluetooth headphones from switching into headset profile when voice features are unused. (Thanks @Nachx639)
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
- Renamed to **Clawdis**: defaults now live under `~/.clawdis` (sessions in `~/.clawdis/sessions/`, IPC at `~/.clawdis/clawdis.sock`, logs in `/tmp/clawdis`). Launchd labels and config filenames follow the new name; legacy stores are copied forward on first run.
|
||||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
- Pi only: `inbound.reply.agent.kind` accepts only `"pi"`, and the agent CLI/CLI flags for Claude/Codex/Gemini were removed. The Pi CLI runs in RPC mode with a persistent worker.
|
||||||
|
- WhatsApp Web is the only transport; Twilio support and related CLI flags/tests were removed.
|
||||||
|
- Direct chats now collapse into a single `main` session by default (no config needed); groups stay isolated as `group:<jid>`.
|
||||||
|
- Gateway is now a loopback-only WebSocket daemon (`ws://127.0.0.1:18789`) that owns all providers/state; clients (CLI, WebChat, macOS app, nodes) connect to it. Start it explicitly (`clawdis gateway …`) or via Clawdis.app; helper subcommands no longer auto-spawn a gateway.
|
||||||
|
|
||||||
### Changes
|
### Gateway, nodes, and automation
|
||||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
- New typed Gateway WS protocol (JSON schema validated) with `clawdis gateway {health,status,send,agent,call}` helpers and structured presence/instance updates for all clients.
|
||||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
- Optional LAN-facing bridge (`tcp://0.0.0.0:18790`) keeps the Gateway loopback-only while enabling direct Bonjour-discovered connections for paired nodes.
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Node pairing + management via `clawdis nodes {pending,approve,reject,invoke}` (used by the iOS node and future remote nodes).
|
||||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
- Cron jobs are Gateway-owned (`clawdis cron …`) with run history stored as JSONL and support for “isolated summary” posting into the main session.
|
||||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
|
||||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
|
||||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
|
||||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
|
||||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
|
||||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
|
||||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
|
||||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
|
||||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
|
||||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
|
||||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
|
||||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
|
||||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
|
||||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
|
||||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
|
||||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
|
||||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
|
||||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
|
||||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
|
||||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
|
||||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
|
||||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
|
||||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
|
||||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
|
||||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
|
||||||
|
|
||||||
### Fixes
|
### macOS companion app
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- **Clawdis.app menu bar companion**: packaged, signed bundle with gateway start/stop, launchd toggle, project-root and pnpm/node auto-resolution, live log shortcut, restart button, and status/recipient table plus badges/dimming for attention and paused states.
|
||||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
- **On-device Voice Wake**: Apple speech recognizer with wake-word table, language picker, live mic meter, “hold until silence,” animated ears/legs, and main-session routing that replies on the **last used surface** (WhatsApp/Telegram/WebChat). Delivery failures are logged, and the run remains visible via WebChat/session logs.
|
||||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
- **WebChat & Debugging**: bundled WebChat UI, Debug tab with heartbeat sliders, session-store picker, log opener (`clawlog`), gateway restart, health probes, and scrollable settings panes.
|
||||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
- **Browser control**: manage clawd’s dedicated Chrome/Chromium with tab listing/open/focus/close, screenshots, DOM query/dump, and “AI snapshots” (aria/domSnapshot/ai) via `clawdis browser …` and UI controls.
|
||||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
- **Remote gateway control**: Bonjour discovery for local masters plus SSH-tunnel fallback for remote control when multicast is unavailable.
|
||||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
|
||||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
|
||||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
|
||||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
|
||||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
|
||||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
|
||||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
|
||||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
|
||||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
|
||||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
|
||||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
|
||||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
|
||||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
|
||||||
|
|
||||||
## 2026.1.14-1
|
### iOS node
|
||||||
|
- New iOS companion app that pairs to the Gateway bridge, reports presence as a node, and exposes a WKWebView “Canvas” for agent-driven UI.
|
||||||
|
- `clawdis nodes invoke` supports `canvas.eval` and `canvas.snapshot` to drive and verify the iOS Canvas (fails fast when the iOS node is backgrounded).
|
||||||
|
- Voice wake words are configurable in-app; the iOS node reconnects to the last bridge when credentials are still present in Keychain.
|
||||||
|
|
||||||
### Highlights
|
### WhatsApp & agent experience
|
||||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
- Group chats fully supported: mention-gated triggers (including media-only captions), sender attribution, session primer with subject/member roster, allowlist bypass when you’re @‑mentioned, and safer handling of view-once/ephemeral media.
|
||||||
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
|
- Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise.
|
||||||
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
|
- Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys.
|
||||||
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
- Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn.
|
||||||
|
- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health.
|
||||||
|
|
||||||
### Changes
|
### CLI, RPC, and health
|
||||||
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
|
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||||
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
|
- `clawdis health` probes WhatsApp link status, connect latency, heartbeat interval, session-store recency, and IPC socket presence (JSON mode for monitors).
|
||||||
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
|
- Added `--help`/`--version` flags; login/logout accept `--provider` (WhatsApp default). Console output is mirrored into pino logs under `/tmp/clawdis`.
|
||||||
- Security: expand `clawdbot security audit` checks (model hygiene, config includes, plugin allowlists, exposure matrix) and extend `--fix` to tighten more sensitive state paths.
|
- RPC stability: stdin/stdout loop for Pi, auto-restart worker, raw error surfacing, and deliver-via-RPC when JSON agent output is returned.
|
||||||
- Security: add `SECURITY.md` reporting policy.
|
|
||||||
- Channels: add Matrix plugin (external) with docs + onboarding hooks.
|
|
||||||
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
|
|
||||||
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
|
|
||||||
- Docs: expand gateway security hardening guidance and incident response checklist.
|
|
||||||
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
|
|
||||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
|
||||||
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
|
|
||||||
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
|
|
||||||
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
|
|
||||||
|
|
||||||
### Fixes
|
### Security & hardening
|
||||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Media server blocks symlink/path traversal, clears temporary downloads, and rotates logs daily (24h retention).
|
||||||
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
|
- Session store purged on logout; IPC socket directory permissions tightened (0700/0600).
|
||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
- Launchd PATH and helper lookup hardened for packaged macOS builds; health probes surface missing binaries quickly.
|
||||||
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
|
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
|
||||||
- Agents: harden Antigravity Claude history/tool-call sanitization. (#968) — thanks @rdev.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
|
||||||
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
|
|
||||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
|
||||||
- Sandbox: restore `docker.binds` config validation for custom bind mounts. (#873) — thanks @akonyer.
|
|
||||||
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
|
|
||||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
|
||||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
|
||||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
|
||||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
|
||||||
|
|
||||||
## 2026.1.14
|
### Docs
|
||||||
|
- Added `docs/telegram.md` outlining the Telegram Bot API provider (grammY) and how it shares the `main` session. Default grammY throttler keeps Bot API calls under rate limits.
|
||||||
|
- Gateway can run WhatsApp + Telegram together when configured; `clawdis send --provider telegram …` sends via the Telegram bot (webhook/proxy options documented).
|
||||||
|
|
||||||
### Changes
|
## 1.5.0 — 2025-12-05
|
||||||
- Usage: add MiniMax coding plan usage tracking.
|
|
||||||
- Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.
|
|
||||||
- Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)
|
|
||||||
- Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.
|
|
||||||
- Config: add `channels.<provider>.configWrites` gating for channel-initiated config writes; migrate Slack channel IDs.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
|
||||||
- UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor.
|
|
||||||
- TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.
|
|
||||||
- TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.
|
|
||||||
- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`.
|
|
||||||
- Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`).
|
|
||||||
|
|
||||||
#### Agents / Auth / Tools / Sandbox
|
|
||||||
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
|
|
||||||
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
|
|
||||||
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
|
|
||||||
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
|
|
||||||
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
|
|
||||||
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
|
|
||||||
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
|
|
||||||
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
|
|
||||||
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
|
|
||||||
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
|
|
||||||
|
|
||||||
#### macOS / Apps
|
|
||||||
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
|
|
||||||
- macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.
|
|
||||||
- macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.
|
|
||||||
- macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)
|
|
||||||
- macOS: prefer the default bridge tunnel port in remote mode for node bridge connectivity; document macOS remote control + bridge tunnels. (#960, fixes #865) — thanks @kkarimi.
|
|
||||||
- Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare `main` sessions.
|
|
||||||
- macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis.
|
|
||||||
- Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver.
|
|
||||||
- Telegram: split long captions into media + follow-up text messages. (#907) - thanks @jalehman.
|
|
||||||
- Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.
|
|
||||||
- Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.
|
|
||||||
- Slack: drop Socket Mode events with mismatched `api_app_id`/`team_id`. (#889) — thanks @roshanasingh4.
|
|
||||||
- Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.
|
|
||||||
- WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.
|
|
||||||
- WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.
|
|
||||||
|
|
||||||
## 2026.1.13
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Postinstall: treat already-applied pnpm patches as no-ops to avoid npm/bun install failures.
|
|
||||||
- Packaging: pin `@mariozechner/pi-ai` to 0.45.7 and refresh patched dependency to match npm resolution.
|
|
||||||
|
|
||||||
## 2026.1.12-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
|
||||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
|
||||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
|
||||||
|
|
||||||
## 2026.1.12-1
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Packaging: include `dist/channels/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/channels/registry.js`).
|
|
||||||
|
|
||||||
## 2026.1.12
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- **BREAKING:** rename chat “providers” (Slack/Telegram/WhatsApp/…) to **channels** across CLI/RPC/config; legacy config keys auto-migrate on load (and are written back as `channels.*`).
|
|
||||||
- Memory: add vector search for agent memories (Markdown-only) with SQLite index, chunking, lazy sync + file watch, and per-agent enablement/fallback.
|
|
||||||
- Plugins: restore full voice-call plugin parity (Telnyx/Twilio, streaming, inbound policies, tools/CLI).
|
|
||||||
- Models: add Synthetic provider plus Moonshot Kimi K2 0905 + turbo/thinking variants (with docs). (#811) — thanks @siraht; (#818) — thanks @mickahouan.
|
|
||||||
- Cron: one-shot schedules accept ISO timestamps (UTC) with optional delete-after-run; cron jobs can target a specific agent (CLI + macOS/Control UI).
|
|
||||||
- Agents: add compaction mode config with optional safeguard summarization and per-agent model fallbacks. (#700) — thanks @thewilloftheshadow; (#583) — thanks @mitschabaude-bot.
|
|
||||||
|
|
||||||
### New & Improved
|
|
||||||
- Memory: add custom OpenAI-compatible embedding endpoints; support OpenAI/local `node-llama-cpp` embeddings with per-agent overrides and provider metadata in tools/CLI. (#819) — thanks @mukhtharcm.
|
|
||||||
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
|
|
||||||
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
|
|
||||||
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
|
|
||||||
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
|
|
||||||
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
|
|
||||||
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
|
|
||||||
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
|
|
||||||
- Providers: add `discord.allowBots`; trim legacy MiniMax M2 from default catalogs; route MiniMax vision to the Coding Plan VLM endpoint (also accepts `@/path/to/file.png` inputs). (#802) — thanks @zknicker.
|
|
||||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
|
||||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
|
||||||
|
|
||||||
### Installer
|
|
||||||
- Install: run `clawdbot doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
|
||||||
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
|
|
||||||
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
|
|
||||||
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
|
|
||||||
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
|
|
||||||
- Agents: auto-recover from compaction context overflow by resetting the session and retrying; propagate overflow details from embedded runs so callers can recover.
|
|
||||||
- MiniMax: strip malformed tool invocation XML; include `MiniMax-VL-01` in implicit provider for image pairing. (#809) — thanks @latitudeki5223.
|
|
||||||
- Onboarding/Auth: honor `CLAWDBOT_AGENT_DIR` / `PI_CODING_AGENT_DIR` when writing auth profiles (MiniMax). (#829) — thanks @roshanasingh4.
|
|
||||||
- Anthropic: handle `overloaded_error` with a friendly message and failover classification. (#832) — thanks @danielz1z.
|
|
||||||
- Anthropic: merge consecutive user turns (preserve newest metadata) before validation to avoid incorrect role errors. (#804) — thanks @ThomsenDrake.
|
|
||||||
- Messaging: enforce context isolation for message tool sends; keep typing indicators alive during tool execution. (#793) — thanks @hsrvc; (#450, #447) — thanks @thewilloftheshadow.
|
|
||||||
- Auto-reply: `/status` allowlist behavior, reasoning-tag enforcement on fallback, and system-event enqueueing for elevated/reasoning toggles. (#810) — thanks @mcinteerj.
|
|
||||||
- System events: include local timestamps when events are injected into prompts. (#245) — thanks @thewilloftheshadow.
|
|
||||||
- Auto-reply: resolve ambiguous `/model` matches; fix streaming block reply media handling; keep >300 char heartbeat replies instead of dropping.
|
|
||||||
- Discord/Slack: centralize reply-thread planning; fix autoThread routing + add per-channel autoThread; avoid duplicate listeners; keep reasoning italics intact; allow clearing channel parents via message tool. (#800, #807) — thanks @davidguttman; (#744) — thanks @thewilloftheshadow.
|
|
||||||
- Telegram: preserve forum topic thread ids, persist polling offsets, respect account bindings in webhook mode, and show typing indicator in General topics. (#727, #739) — thanks @thewilloftheshadow; (#821) — thanks @gumadeiras; (#779) — thanks @azade-c.
|
|
||||||
- Slack: accept slash commands with or without leading `/` for custom command configs. (#798) — thanks @thewilloftheshadow.
|
|
||||||
- Cron: persist disabled jobs correctly; accept `jobId` aliases for update/run/remove params. (#205, #252) — thanks @thewilloftheshadow.
|
|
||||||
- Gateway/CLI: honor `CLAWDBOT_LAUNCHD_LABEL` / `CLAWDBOT_SYSTEMD_UNIT` overrides; `agents.list` respects explicit config; reduce noisy loopback WS logs during tests; run `clawdbot doctor --non-interactive` during updates. (#781) — thanks @ronyrus.
|
|
||||||
- Onboarding/Control UI: refuse invalid configs (run doctor first); quote Windows browser URLs for OAuth; keep chat scroll position unless the user is near the bottom. (#764) — thanks @mukhtharcm; (#794) — thanks @roshanasingh4; (#217) — thanks @thewilloftheshadow.
|
|
||||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
|
||||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
|
||||||
|
|
||||||
### Maintenance
|
|
||||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
|
||||||
- Testing: update Vitest + browser-playwright to 4.0.17.
|
|
||||||
- Docs: add Amazon Bedrock provider notes and link from models/FAQ.
|
|
||||||
|
|
||||||
## 2026.1.11
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Plugins are now first-class: loader + CLI management, plus the new Voice Call plugin.
|
|
||||||
- Config: modular `$include` support for split config files. (#731) — thanks @pasogott.
|
|
||||||
- Agents/Pi: reserve compaction headroom so pre-compaction memory writes can run before auto-compaction.
|
|
||||||
- Agents: automatic pre-compaction memory flush turn to store durable memories before compaction.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option.
|
|
||||||
- CLI: configure section selection now loops until Continue.
|
|
||||||
- Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example.
|
|
||||||
- Docs: add Cerebras GLM 4.6/4.7 config example (OpenAI-compatible endpoint).
|
|
||||||
- Onboarding/CLI: group model/auth choice by provider and label Z.AI as GLM 4.7.
|
|
||||||
- Onboarding/Docs: add Moonshot AI (Kimi K2) auth choice + config example.
|
|
||||||
- CLI/Onboarding: prompt to reuse detected API keys for Moonshot/MiniMax/Z.AI/Gemini/Anthropic/OpenCode.
|
|
||||||
- Auto-reply: add compact `/model` picker (models + available providers) and show provider endpoints in `/model status`.
|
|
||||||
- Control UI: add Config tab model presets (MiniMax M2.1, GLM 4.7, Kimi) for one-click setup.
|
|
||||||
- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints).
|
|
||||||
- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX.
|
|
||||||
- Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests.
|
|
||||||
- Docs: add plugins doc + cross-links from tools/skills/gateway config.
|
|
||||||
- Docs: add beginner-friendly plugin quick start + expand Voice Call plugin docs.
|
|
||||||
- Tests: add Docker plugin loader + tgz-install smoke test.
|
|
||||||
- Tests: extend Docker plugin E2E to cover installing from local folders (`plugins.load.paths`) and `file:` npm specs.
|
|
||||||
- Tests: add coverage for pre-compaction memory flush settings.
|
|
||||||
- Tests: modernize live model smoke selection for current releases and enforce tools/images/thinking-high coverage. (#769) — thanks @steipete.
|
|
||||||
- Agents/Tools: add `apply_patch` tool for multi-file edits (experimental; gated by tools.exec.applyPatch; OpenAI-only).
|
|
||||||
- Agents/Tools: rename the bash tool to exec (config alias maintained). (#748) — thanks @myfunc.
|
|
||||||
- Agents: add pre-compaction memory flush config (`agents.defaults.compaction.*`) with a soft threshold + system prompt.
|
|
||||||
- Config: add `$include` directive for modular config files. (#731) — thanks @pasogott.
|
|
||||||
- Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr.
|
|
||||||
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
|
||||||
- Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell.
|
|
||||||
- Cron/CLI: add `--model` flag to cron add/edit commands. (#711) — thanks @mjrussell.
|
|
||||||
- Cron/CLI: trim model overrides on cron edits and document main-session guidance. (#711) — thanks @mjrussell.
|
|
||||||
- Skills: bundle `skill-creator` to guide creating and packaging skills.
|
|
||||||
- Providers: add per-DM history limit overrides (`dmHistoryLimit`) with provider-level config. (#728) — thanks @pkrmf.
|
|
||||||
- Discord: expose channel/category management actions in the message tool. (#730) — thanks @NicholasSpisak.
|
|
||||||
- Docs: rename README “macOS app” section to “Apps”. (#733) — thanks @AbhisekBasu1.
|
|
||||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
|
||||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
|
||||||
|
|
||||||
### Installer
|
|
||||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
|
||||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
|
||||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch clawd.bot scripts and run install sh/cli with onboarding skipped.
|
|
||||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
|
||||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
|
||||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
|
||||||
- Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete.
|
|
||||||
- CLI: fix guardCancel typing for configure prompts. (#769) — thanks @steipete.
|
|
||||||
- Gateway/WebChat: include handshake validation details in the WebSocket close reason for easier debugging; preserve close codes.
|
|
||||||
- Gateway/Auth: send invalid connect responses before closing the handshake; stabilize invalid-connect auth test.
|
|
||||||
- Gateway: tighten gateway listener detection.
|
|
||||||
- Control UI: hide onboarding chat when configured and guard the mobile chat sidebar overlay.
|
|
||||||
- Auth: read Codex keychain credentials and make the lookup platform-aware.
|
|
||||||
- macOS/Release: avoid bundling dist artifacts in relay builds and generate appcasts from zip-only sources.
|
|
||||||
- Doctor: surface plugin diagnostics in the report.
|
|
||||||
- Plugins: treat `plugins.load.paths` directory entries as package roots when they contain `package.json` + `clawdbot.extensions`; load plugin packages from config dirs; extract archives without system tar.
|
|
||||||
- Config: expand `~` in `CLAWDBOT_CONFIG_PATH` and common path-like config fields (including `plugins.load.paths`); guard invalid `$include` paths. (#731) — thanks @pasogott.
|
|
||||||
- Agents: stop pre-creating session transcripts so first user messages persist in JSONL history.
|
|
||||||
- Agents: skip pre-compaction memory flush when the session workspace is read-only.
|
|
||||||
- Auto-reply: ignore inline `/status` directives unless the message is directive-only.
|
|
||||||
- Auto-reply: align `/think` default display with model reasoning defaults. (#751) — thanks @gabriel-trigo.
|
|
||||||
- Auto-reply: flush block reply buffers on tool boundaries. (#750) — thanks @sebslight.
|
|
||||||
- Auto-reply: allow sender fallback for command authorization when `SenderId` is empty (WhatsApp self-chat). (#755) — thanks @juanpablodlc.
|
|
||||||
- Auto-reply: treat whitespace-only sender ids as missing for command authorization (WhatsApp self-chat). (#766) — thanks @steipete.
|
|
||||||
- Heartbeat: refresh prompt text for updated defaults.
|
|
||||||
- Agents/Tools: use PowerShell on Windows to capture system utility output. (#748) — thanks @myfunc.
|
|
||||||
- Docker: tolerate unset optional env vars in docker-setup.sh under strict mode. (#725) — thanks @petradonka.
|
|
||||||
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
|
|
||||||
- Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44.
|
|
||||||
- Agents: preserve reasoning items on tool-only turns.
|
|
||||||
- Agents/Subagents: wait for completion before announcing, align wait timeout with run timeout, and make announce prompts more emphatic.
|
|
||||||
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
|
|
||||||
- Agents/Tools: preserve action enums when flattening tool schemas. (#708) — thanks @xMikeMickelson.
|
|
||||||
- Gateway/Agents: canonicalize main session aliases for store writes and add regression coverage. (#709) — thanks @xMikeMickelson.
|
|
||||||
- Agents: reset sessions and retry when auto-compaction overflows instead of crashing the gateway.
|
|
||||||
- Providers/Telegram: normalize command mentions for consistent parsing. (#729) — thanks @obviyus.
|
|
||||||
- Providers: skip DM history limit handling for non-DM sessions. (#728) — thanks @pkrmf.
|
|
||||||
- Sandbox: fix non-main mode incorrectly sandboxing the main DM session and align `/status` runtime reporting with effective sandbox state.
|
|
||||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
|
||||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
|
||||||
|
|
||||||
## 2026.1.10
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- CLI: `clawdbot status` now table-based + shows OS/update/gateway/daemon/agents/sessions; `status --all` adds a full read-only debug report (tables, log tails, Tailscale summary, and scan progress via OSC-9 + spinner).
|
|
||||||
- CLI Backends: add Codex CLI fallback with resume support (text output) and JSONL parsing for new runs, plus a live CLI resume probe.
|
|
||||||
- CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa.
|
|
||||||
- Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680).
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Onboarding/Models: add first-class Z.AI (GLM) auth choice (`zai-api-key`) + `--zai-api-key` flag.
|
|
||||||
- CLI/Onboarding: add OpenRouter API key auth option in configure/onboard. (#703) — thanks @mteam88.
|
|
||||||
- Agents: add human-delay pacing between block replies (modes: off/natural/custom, per-agent configurable). (#446) — thanks @tony-freedomology.
|
|
||||||
- Agents/Browser: add `browser.target` (sandbox/host/custom) with sandbox host-control gating via `agents.defaults.sandbox.browser.allowHostControl`, allowlists for custom control URLs/hosts/ports, and expand browser tool docs (remote control, profiles, internals).
|
|
||||||
- Onboarding/Models: add catalog-backed default model picker to onboarding + configure. (#611) — thanks @jonasjancarik.
|
|
||||||
- Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal.
|
|
||||||
- CLI: add `clawdbot reset` and `clawdbot uninstall` flows (interactive + non-interactive) plus docker cleanup smoke test.
|
|
||||||
- Providers: move provider wiring to a plugin architecture. (#661).
|
|
||||||
- Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672).
|
|
||||||
- Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690)
|
|
||||||
- Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Auto-reply: suppress draft/typing streaming for `NO_REPLY` (silent system ops) so it doesn’t leak partial output.
|
|
||||||
- CLI/Status: expand tables to full terminal width; clarify provider setup vs runtime warnings; richer per-provider detail; token previews in `status` while keeping `status --all` redacted; add troubleshooting link footer; keep log tails pasteable; show gateway auth used when reachable; surface provider runtime errors (Signal/iMessage/Slack); harden `tailscale status --json` parsing; make `status --all` scan progress determinate; and replace the footer with a 3-line “Next steps” recommendation (share/debug/probe).
|
|
||||||
- CLI/Gateway: clarify that `clawdbot gateway status` reports RPC health (connect + RPC) and shows RPC failures separately from connect failures.
|
|
||||||
- CLI/Update: gate progress spinner on stdout TTY and align clean-check step label. (#701) — thanks @bjesuiter.
|
|
||||||
- Telegram: add `/whoami` + `/id` commands to reveal sender id for allowlists; allow `@username` and prefixed ids in `allowFrom` prompts (with stability warning).
|
|
||||||
- Heartbeat: strip markup-wrapped `HEARTBEAT_OK` so acks don’t leak to external providers (e.g., Telegram).
|
|
||||||
- Control UI: stop auto-writing `telegram.groups["*"]` and warn/confirm before enabling wildcard groups.
|
|
||||||
- WhatsApp: send ack reactions only for handled messages and ignore legacy `messages.ackReaction` (doctor copies to `whatsapp.ackReaction`). (#629) — thanks @pasogott.
|
|
||||||
- Sandbox/Skills: mirror skills into sandbox workspaces for read-only mounts so SKILL.md stays accessible.
|
|
||||||
- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage.
|
|
||||||
- Docker: allow optional apt packages during image build and document the build arg. (#697) — thanks @gabriel-trigo.
|
|
||||||
- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons.
|
|
||||||
- Agents/Pi: inject config `temperature`/`maxTokens` into streaming without replacing the session streamFn; cover with live maxTokens probe. (#732) — thanks @peschee.
|
|
||||||
- macOS: clear unsigned launchd overrides on signed restarts and warn via doctor when attach-only/disable markers are set. (#695) — thanks @jeffersonwarrior.
|
|
||||||
- Agents: enforce single-writer session locks and drop orphan tool results to prevent tool-call ID failures (MiniMax/Anthropic-compatible APIs).
|
|
||||||
- Docs: make `clawdbot status` the first diagnostic step, clarify `status --deep` behavior, and document `/whoami` + `/id`.
|
|
||||||
- Docs/Testing: clarify live tool+image probes and how to list your testable `provider/model` ids.
|
|
||||||
- Tests/Live: make gateway bash+read probes resilient to provider formatting while still validating real tool calls.
|
|
||||||
- WhatsApp: detect @lid mentions in groups using authDir reverse mapping + resolve self JID E.164 for mention gating. (#692) — thanks @peschee.
|
|
||||||
- Gateway/Auth: default to token auth on loopback during onboarding, add doctor token generation flow, and tighten audio transcription config to Whisper-only.
|
|
||||||
- Providers: dedupe inbound messages across providers to avoid duplicate LLM runs on redeliveries/reconnects. (#689) — thanks @adam91holt.
|
|
||||||
- Agents: strip `<thought>`/`<antthinking>` tags from hidden reasoning output and cover tag variants in tests. (#688) — thanks @theglove44.
|
|
||||||
- macOS: save model picker selections as normalized provider/model IDs and keep manual entries aligned. (#683) — thanks @benithors.
|
|
||||||
- Agents: recognize "usage limit" errors as rate limits for failover. (#687) — thanks @evalexpr.
|
|
||||||
- CLI: avoid success message when daemon restart is skipped. (#685) — thanks @carlulsoe.
|
|
||||||
- Commands: disable `/config` + `/debug` by default; gate via `commands.config`/`commands.debug` and hide from native registration/help output.
|
|
||||||
- Agents/System: clarify that sub-agents remain sandboxed and cannot use elevated host access.
|
|
||||||
- Gateway: disable the OpenAI-compatible `/v1/chat/completions` endpoint by default; enable via `gateway.http.endpoints.chatCompletions.enabled=true`.
|
|
||||||
- macOS: stabilize bridge tunnels, guard invoke senders on disconnect, and drain stdout/stderr to avoid deadlocks. (#676) — thanks @ngutman.
|
|
||||||
- Agents/System: clarify sandboxed runtime in system prompt and surface elevated availability when sandboxed.
|
|
||||||
- Auto-reply: prefer `RawBody` for command/directive parsing (WhatsApp + Discord) and prevent fallback runs from clobbering concurrent session updates. (#643) — thanks @mcinteerj.
|
|
||||||
- WhatsApp: fix group reactions by preserving message IDs and sender JIDs in history; normalize participant phone numbers to JIDs in outbound reactions. (#640) — thanks @mcinteerj.
|
|
||||||
- WhatsApp: expose group participant IDs to the model so reactions can target the right sender.
|
|
||||||
- Cron: `wakeMode: "now"` waits for heartbeat completion (and retries when the main lane is busy). (#666) — thanks @roshanasingh4.
|
|
||||||
- Agents/OpenAI: fix Responses tool-only → follow-up turn handling (avoid standalone `reasoning` items that trigger 400 “required following item”) and replay reasoning items in Responses/Codex Responses history for tool-call-only turns.
|
|
||||||
- Sandbox: add `clawdbot sandbox explain` (effective policy inspector + fix-it keys); improve “sandbox jail” tool-policy/elevated errors with actionable config key paths; link to docs.
|
|
||||||
- Hooks/Gmail: keep Tailscale serve path at `/` while preserving the public path. (#668) — thanks @antons.
|
|
||||||
- Hooks/Gmail: allow Tailscale target URLs to preserve internal serve paths.
|
|
||||||
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
|
|
||||||
- Auth: throttle external CLI credential syncs (Claude/Codex), reduce Keychain reads, and skip sync when cached credentials are still fresh.
|
|
||||||
- CLI: respect `CLAWDBOT_STATE_DIR` for node pairing + voice wake settings storage. (#664) — thanks @azade-c.
|
|
||||||
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
|
|
||||||
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
|
|
||||||
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
|
|
||||||
- Gateway/Control UI: sniff image attachments for chat.send, drop non-images, and log mismatches. (#670) — thanks @cristip73.
|
|
||||||
- macOS: force `restart-mac.sh --sign` to require identities and keep bundled Node signed for relay verification. (#580) — thanks @jeffersonwarrior.
|
|
||||||
- Gateway/Agent: accept image attachments on `agent` (multimodal message) and add live gateway image probe (`CLAWDBOT_LIVE_GATEWAY_IMAGE_PROBE=1`).
|
|
||||||
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
|
|
||||||
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
|
|
||||||
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).
|
|
||||||
- Auto-reply: fix native `/model` not updating the actual chat session (Telegram/Slack/Discord). (#646)
|
|
||||||
- Doctor: offer to run `clawdbot update` first on git installs (keeps doctor output aligned with latest).
|
|
||||||
- Doctor: avoid false legacy workspace warning when install dir is `~/clawdbot`. (#660)
|
|
||||||
- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons.
|
|
||||||
- Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75.
|
|
||||||
- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan.
|
|
||||||
- Agents: harden Cloud Code Assist tool ID sanitization (toolUse/toolCall/toolResult) and scrub extra JSON Schema constraints. (#665) — thanks @sebslight.
|
|
||||||
- Agents: sanitize tool results + Cloud Code Assist tool IDs at context-build time (prevents mid-run strict-provider request rejects).
|
|
||||||
- Agents/Tools: resolve workspace-relative Read/Write/Edit paths; align bash default cwd. (#642) — thanks @mukhtharcm.
|
|
||||||
- Discord: include forwarded message snapshots in agent session context. (#667) — thanks @rubyrunsstuff.
|
|
||||||
- Telegram: add `telegram.draftChunk` to tune draft streaming chunking for `streamMode: "block"`. (#667) — thanks @rubyrunsstuff.
|
|
||||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
|
||||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
|
||||||
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
|
|
||||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
|
||||||
- Telegram: serialize media-group processing to avoid missed albums under load.
|
|
||||||
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
|
|
||||||
- Docs: showcase entries for ParentPay, R2 Upload, iOS TestFlight, and Oura Health. (#650) — thanks @henrino3.
|
|
||||||
- Agents: repair session transcripts by dropping duplicate tool results across the whole history (unblocks Anthropic-compatible APIs after retries).
|
|
||||||
- Tests/Live: reset the gateway session between model runs to avoid cross-provider transcript incompatibilities (notably OpenAI Responses reasoning replay rules).
|
|
||||||
|
|
||||||
|
|
||||||
## 2026.1.9
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
- Microsoft Teams provider: polling, attachments, outbound CLI send, per-channel policy.
|
|
||||||
- Models/Auth expansion: OpenCode Zen + MiniMax API onboarding; token auth profiles + auth order; OAuth health in doctor/status.
|
|
||||||
- CLI/Gateway UX: message subcommands, gateway discover/status/SSH, /config + /debug, sandbox CLI.
|
|
||||||
- Provider reliability sweep: WhatsApp contact cards/targets, Telegram audio-as-voice + streaming, Signal reactions, Slack threading, Discord stability.
|
|
||||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
|
||||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- CLI: `clawdbot message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
- Dropped all non-Pi agents (Claude, Codex, Gemini, Opencode); `inbound.reply.agent.kind` now only accepts `"pi"` and related CLI helpers have been removed.
|
||||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
- Removed Twilio support and all related commands/options (webhook/up/provider flags/wait-poll); CLAWDIS is Baileys Web-only.
|
||||||
|
|
||||||
### New Features and Changes
|
### Changes
|
||||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
- Default agent handling now favors Pi RPC while falling back to plain command execution for non-Pi invocations, keeping heartbeat/session plumbing intact.
|
||||||
- Models/Auth: setup-token + token auth profiles; `clawdbot models auth order {get,set,clear}`; per-agent auth candidates in `/model status`; OAuth expiry checks in doctor/status.
|
- Documentation updated to reflect Pi-only support and to mark legacy Claude paths as historical.
|
||||||
- Agent/System: claude-cli runner; `session_status` tool (and sandbox allow); adaptive context pruning default; system prompt messaging guidance + no auto self-update; eligible skills list injection; sub-agent context trimmed.
|
- Status command reports web session health + session recipients; config paths are locked to `~/.clawdis` with session metadata stored under `~/.clawdis/sessions/`.
|
||||||
- Commands: `/commands` list; `/models` alias; `/usage` alias; `/debug` runtime overrides + effective config view; `/config` chat updates + `/config get`; `config --section`.
|
- Simplified send/agent/gateway/heartbeat to web-only delivery; removed Twilio mocks/tests and dead code.
|
||||||
- CLI/Gateway: unified message tool + message subcommands; gateway discover (local + wide-area DNS-SD) with JSON/timeout; gateway status human-readable + JSON + SSH loopback; wide-area records include gatewayPort/sshPort/cliPath + tailnet DNS fallback.
|
- Pi RPC timeout is now inactivity-based (5m without events) and error messages show seconds only.
|
||||||
- CLI UX: logs output modes (pretty/plain/JSONL) + colorized health/daemon output; global `--no-color`; lobster palette in onboarding/config.
|
- Pi sessions now write to `~/.clawdis/sessions/` by default (legacy session logs from older installs are copied over when present).
|
||||||
- Dev ergonomics: gateway `--dev/--reset` + dev profile auto-config; C-3PO dev templates; dev gateway/TUI helper scripts.
|
- Directive triggers (`/think`, `/verbose`, `/stop` et al.) now reply immediately using normalized bodies (timestamps/group prefixes stripped) without waiting for the agent.
|
||||||
- Sandbox/Workspace: sandbox list/recreate commands; sync skills into sandbox workspace; sandbox browser auto-start.
|
- Directive/system acks carry a `⚙️` prefix and verbose parsing rejects typoed `/ver*` strings so unrelated text doesn’t flip verbosity.
|
||||||
- Config/Onboarding: inline env vars; OpenAI API key flow to shared `~/.clawdbot/.env`; Opus 4.5 default prompt for Anthropic auth; QuickStart auto-install gateway (Node-only) + provider picker tweaks + skip-systemd flags; TUI bootstrap prompt (`tui --message`); remove Bun runtime choice.
|
- Batched history blocks no longer trip directive parsing; `/think` in prior messages won't emit stray acknowledgements.
|
||||||
- Providers: Microsoft Teams provider (polling, attachments, outbound sends, requireMention, config reload/DM policy). (#404) — thanks @onutc
|
- RPC fallbacks no longer echo the user's prompt (e.g., pasting a link) when the agent returns no assistant text.
|
||||||
- Providers: WhatsApp broadcast groups for multi-agent replies (#547) — thanks @pasogott; inbound media size cap configurable (#505) — thanks @koala73; identity-based message prefixes (#578) — thanks @p6l-richard.
|
- Heartbeat prompts with `/think` no longer send directive acks; heartbeat replies stay silent on settings.
|
||||||
- Providers: Telegram inline keyboard buttons + callback payload routing (#491) — thanks @azade-c; cron topic delivery targets (#474/#478) — thanks @mitschabaude-bot, @nachoiacovino; `[[audio_as_voice]]` tag support (#490) — thanks @jarvis-medmatic.
|
- `clawdis sessions` now renders a colored table (a la oracle) with context usage shown in k tokens and percent of the context window.
|
||||||
- Providers: Signal reactions + notifications with allowlist support.
|
|
||||||
- Status/Usage: /status cost reporting + `/cost` lines; auth profile snippet; provider usage windows.
|
|
||||||
- Control UI: mobile responsiveness (#558) — thanks @carlulsoe; queued messages + Enter-to-send (#527) — thanks @YuriNachos; session links (#471) — thanks @HazAT; reasoning view; skill install feedback (#445) — thanks @pkrmf; chat layout refresh (#475) — thanks @rahthakor; docs link + new session button; drop explicit `ui:install`.
|
|
||||||
- TUI: agent picker + agents list RPC; improved status line.
|
|
||||||
- Doctor/Daemon: audit/repair flows, permissions checks, supervisor config audits; provider status probes + warnings for Discord intents and Telegram privacy; last activity timestamps; gateway restart guidance.
|
|
||||||
- Docs: Hetzner Docker VPS guide + cross-links (#556/#592) — thanks @Iamadig; Ansible guide (#545) — thanks @pasogott; provider troubleshooting index; hook parameter expansion (#532) — thanks @mcinteerj; model allowlist notes; OAuth deep dive; showcase refresh.
|
|
||||||
- Apps/Branding: refreshed iOS/Android/macOS icons (#521) — thanks @fishfisher.
|
|
||||||
|
|
||||||
### Fixes
|
## 1.4.1 — 2025-12-04
|
||||||
- Packaging: include MS Teams send module in npm tarball.
|
|
||||||
- Sandbox/Browser: auto-start CDP endpoint; proxy CDP out of container for attachOnly; relax Bun fetch typing; align sandbox list output with config images.
|
|
||||||
- Agents/Runtime: gate heartbeat prompt to default sessions; /stop aborts between tool calls; require explicit system-event session keys; guard small context windows; fix model fallback stringification; sessions_spawn inherits provider; failover on billing/credits; respect auth cooldown ordering; restore Anthropic OAuth tool dispatch + tool-name bypass; avoid OpenAI invalid reasoning replay; harden Gmail hook model defaults.
|
|
||||||
- Agent history/schema: strip/skip empty assistant/error blocks to prevent session corruption/Claude 400s; scrub unsupported JSON Schema keywords + sanitize tool call IDs for Cloud Code Assist; simplify Gemini-compatible tool/session schemas; require raw for config.apply.
|
|
||||||
- Auto-reply/Streaming: default audioAsVoice false; preserve audio_as_voice propagation + buffer audio blocks + guard voice notes; block reply ordering (timeout) + forced-block fence-safe; avoid chunk splits inside parentheses + fence-close breaks + invalid UTF-16 truncation; preserve inline directive spacing + allow whitespace in reply tags; filter NO_REPLY prefixes + normalize routed replies; suppress <think> leakage with separate Reasoning; block streaming defaults (off by default, minChars/idle tuning) + coalesced blocks; dedupe followup queue; restore explicit responsePrefix default.
|
|
||||||
- Status/Commands: provider prefix in /status model display; usage filtering + provider mapping; auth label + usage snapshots (claude-cli fallback + optional claude.ai); show Verbose/Elevated only when enabled; compact usage/cost line + restore emoji-rich status; /status in directive-only + multi-directive handling; mention-bypass elevated handling; surface provider usage errors; wire /usage to /status; restore hidden gateway-daemon alias; fallback /model list when catalog unavailable.
|
|
||||||
- WhatsApp: vCard/contact cards (prefer FN, include numbers, show all contacts, keep summary counts, better empty summaries); preserve group JIDs + normalize targets; resolve @lid mappings/JIDs (Baileys/auth-dir) + inbound mapping; route queued replies to sender; improve web listener errors + remove provider name from errors; record outbound activity account id; fix web media fetch errors; broadcast group history consistency.
|
|
||||||
- Telegram: keep streamMode draft-only; long-poll conflict retries + update dedupe; grammY fetch mismatch fixes + restrict native fetch to Bun; suppress getUpdates stack traces; include user id in pairing; audio_as_voice handling fixes.
|
|
||||||
- Discord/Slack: thread context helpers + forum thread starters; avoid category parent overrides; gateway reconnect logs + HELLO timeout + stop provider after reconnect exhaustion; DM recipient parsing for numeric IDs; remove incorrect limited warning; reply threading + mrkdwn edge cases; remove ack reactions after reply; gateway debug event visibility.
|
|
||||||
- Signal: reaction handling safety; own-reaction matching (uuid+phone); UUID-only senders accepted; ignore reaction-only messages.
|
|
||||||
- MS Teams: download image attachments reliably; fix top-level replies; stop on shutdown + honor chunk limits; normalize poll providers/deps; pairing label fixes.
|
|
||||||
- iMessage: isolate group-ish threads by chat_id.
|
|
||||||
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle ClawdbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
|
||||||
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
|
||||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
|
||||||
|
|
||||||
### Maintenance
|
### Changes
|
||||||
- Dependencies: bump pi-* stack to 0.42.2.
|
- Added `clawdis agent` CLI command to talk directly to the configured agent using existing session handling (no WhatsApp send), with JSON output and delivery option.
|
||||||
- Dependencies: Pi 0.40.0 bump (#543) — thanks @mcinteerj.
|
- `/new` reset trigger now works even when inbound messages have timestamp prefixes (e.g., `[Dec 4 17:35]`).
|
||||||
- Build: Docker build cache layer (#605) — thanks @zknicker.
|
- WhatsApp mention parsing accepts nullable arrays and flattens safely to avoid missed mentions.
|
||||||
|
|
||||||
- Auth: enable OAuth token refresh for Claude Code CLI credentials (`anthropic:claude-cli`) with bidirectional sync back to Claude Code storage (file on Linux/Windows, Keychain on macOS). This allows long-running agents to operate autonomously without manual re-authentication (#654 — thanks @radek-paclt).
|
## 1.4.0 — 2025-12-03
|
||||||
|
|
||||||
## 2026.1.8
|
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Security: DMs locked down by default across providers; pairing-first + allowlist guidance.
|
- **Thinking directives & state:** `/t|/think|/thinking <level>` (aliases off|minimal|low|medium|high|max/highest). Inline applies to that message; directive-only message pins the level for the session; `/think:off` clears. Resolution: inline > session override > `inbound.reply.thinkingDefault` > off. Pi gets `--thinking <level>` (except off); other agents append cue words (`think` → `think hard` → `think harder` → `ultrathink`). Heartbeat probe uses `HEARTBEAT /think:high`.
|
||||||
- Sandbox: per-agent scope defaults + workspace access controls; tool/session isolation tuned.
|
- **Group chats (web provider):** Clawdis now fully supports WhatsApp groups: mention-gated triggers (including image-only @ mentions), recent group history injection, per-group sessions, sender attribution, and a first-turn primer with group subject/member roster; heartbeats are skipped for groups.
|
||||||
- Agent loop: compaction, pruning, streaming, and error handling hardened.
|
- **Group session primer:** The first turn of a group session now tells the agent it is in a WhatsApp group and lists known members/subject so it can address the right speaker.
|
||||||
- Providers: Telegram/WhatsApp/Discord/Slack reliability, threading, reactions, media, and retries improved.
|
- **Media failures are surfaced:** When a web auto-reply media fetch/send fails (e.g., HTTP 404), we now append a warning to the fallback text so you know the attachment was skipped.
|
||||||
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
|
- **Verbose directives + session hints:** `/v|/verbose on|full|off` mirrors thinking: inline > session > config default. Directive-only replies with an acknowledgement; invalid levels return a hint. When enabled, tool results from JSON-emitting agents (Pi, etc.) are forwarded as metadata-only `[🛠️ <tool-name> <arg>]` messages (now streamed as they happen), and new sessions surface a `🧭 New session: <id>` hint.
|
||||||
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
|
- **Verbose tool coalescing:** successive tool results of the same tool within ~1s are batched into one `[🛠️ tool] arg1, arg2` message to reduce WhatsApp noise.
|
||||||
|
- **Directive confirmations:** Directive-only messages now reply with an acknowledgement (`Thinking level set to high.` / `Thinking disabled.`) and reject unknown levels with a helpful hint (state is unchanged).
|
||||||
|
- **Pi stability:** RPC replies buffered until the assistant turn finishes; parsers return consistent `texts[]`; web auto-replies keep a warm Pi RPC process to avoid cold starts.
|
||||||
|
- **Claude prompt flow:** One-time `sessionIntro` with per-message `/think:high` bodyPrefix; system prompt always sent on first turn even with `sendSystemOnce`.
|
||||||
|
- **Heartbeat UX:** Backpressure skips reply heartbeats while other commands run; skips don’t refresh session `updatedAt`; web heartbeats normalize array payloads and optional `heartbeatCommand`.
|
||||||
|
- **Control via WhatsApp:** Send `/restart` to restart the launchd service (`com.steipete.clawdis`) from your allowed numbers.
|
||||||
|
- **Pi completion signal:** RPC now resolves on Pi’s `agent_end` (or process exit) so late assistant messages aren’t truncated; 5-minute hard cap only as a failsafe.
|
||||||
|
|
||||||
### Breaking
|
### Reliability & UX
|
||||||
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
|
- Outbound chunking prefers newlines/word boundaries and enforces caps (~4000 chars for web/WhatsApp).
|
||||||
- Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
|
- Web auto-replies fall back to caption-only if media send fails; hosted media MIME-sniffed and cleaned up immediately.
|
||||||
- New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
|
- IPC gateway send shows typing indicator; batched inbound messages keep timestamps; watchdog restarts WhatsApp after long inactivity.
|
||||||
- To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
|
- Early `allowFrom` filtering prevents decryption errors; same-phone mode supported with echo suppression.
|
||||||
- Approve requests via `clawdbot pairing list <provider>` + `clawdbot pairing approve <provider> <code>`.
|
- All console output is now mirrored into pino logs (still printed to stdout/stderr), so verbose runs keep full traces.
|
||||||
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
|
- `--verbose` now forces log level `trace` (was `debug`) to capture every event.
|
||||||
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
|
- Verbose tool messages now include emoji + args + a short result preview for bash/read/edit/write/attach (derived from RPC tool start/end events).
|
||||||
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
|
|
||||||
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
|
|
||||||
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
|
|
||||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
|
||||||
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
|
|
||||||
|
|
||||||
### Fixes
|
### Security / Hardening
|
||||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
- IPC socket hardened (0700 dir / 0600 socket, no symlinks/foreign owners); `clawdis logout` also prunes session store.
|
||||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
- Media server blocks symlinks and enforces path containment; logging rotates daily and prunes >24h.
|
||||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
|
||||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
|
||||||
- **Gateway/CLI UX:** `clawdbot logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
|
||||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
|
||||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
|
||||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
|
||||||
- **Docs:** new FAQ/ClawdHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
|
||||||
|
|
||||||
### Maintenance
|
### Bug Fixes
|
||||||
- Skills additions (Himalaya email, CodexBar, 1Password).
|
- Web group chats now bypass the second `allowFrom` check (we still enforce it on the group participant at inbox ingest), so mentioned group messages reply even when the group JID isn’t in your allowlist.
|
||||||
- Dependency refreshes (pi-* stack, Slack SDK, discord-api-types, file-type, zod, Biome, Vite).
|
- `logVerbose` also writes to the configured Pino logger at debug level (without breaking stdout).
|
||||||
- Refactors: centralized group allowlist/mention policy; lint/import cleanup; switch tsx → bun for TS execution.
|
- Group auto-replies now append the triggering sender (`[from: Name (+E164)]`) to the batch body so agents can address the right person in group chats.
|
||||||
|
- Media-only pings now pick up mentions inside captions (image/video/etc.), so @-mentions on media-only messages trigger replies.
|
||||||
|
- MIME sniffing and redirect handling for downloads/hosted media.
|
||||||
|
- Response prefix applied to heartbeat alerts; heartbeat array payloads handled for both providers.
|
||||||
|
- Pi RPC typing exposes `signal`/`killed`; NDJSON parsers normalized across agents.
|
||||||
|
- Pi session resumes now append `--continue`, so existing history/think level are reloaded instead of starting empty.
|
||||||
|
|
||||||
## 2026.1.5
|
### Testing
|
||||||
|
- Fixtures isolate session stores; added coverage for thinking directives, stateful levels, heartbeat backpressure, and agent parsing.
|
||||||
|
|
||||||
|
## 1.3.0 — 2025-12-02
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
|
- **Pluggable agents (Claude, Pi, Codex, Opencode):** `inbound.reply.agent` selects CLI/parser; per-agent argv builders and NDJSON parsers enable swapping without template changes.
|
||||||
- Agent tools: new `image` tool routed to the image model (when configured).
|
- **Safety stop words:** `stop|esc|abort|wait|exit` immediately reply “Agent was aborted.” and mark the session so the next prompt is prefixed with an abort reminder.
|
||||||
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
|
- **Agent session reliability:** Only Claude returns a stable `session_id`; others may reset between runs.
|
||||||
- Docs: document built-in model shorthands + precedence (user config wins).
|
|
||||||
- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`).
|
|
||||||
|
|
||||||
### Fixes
|
### Bug Fixes
|
||||||
- Control UI: render Markdown in tool result cards.
|
- Empty `result` fields no longer leak raw JSON to users.
|
||||||
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
|
- Heartbeat alerts now honor `responsePrefix`.
|
||||||
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
|
- Command failures return user-friendly messages.
|
||||||
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
|
- Test session isolation to avoid touching real `sessions.json`.
|
||||||
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
|
- (Removed in 2.0.0) IPC reuse for `clawdis send/heartbeat` prevents Signal/WhatsApp session corruption.
|
||||||
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
|
- Web send respects media kind (image/audio/video/document) with correct limits.
|
||||||
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
|
|
||||||
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
|
### Changes
|
||||||
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
|
- (Removed in 2.0.0) IPC gateway socket at `~/.clawdis/ipc/gateway.sock` with automatic CLI fallback.
|
||||||
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
|
- Batched inbound messages with timestamps; typing indicator after sends.
|
||||||
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
|
- Watchdog restarts WhatsApp after long inactivity; heartbeat logging includes minutes since last message.
|
||||||
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
|
- Early `allowFrom` filtering before decryption.
|
||||||
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
|
- Same-phone mode with echo detection and optional `inbound.samePhoneMarker`.
|
||||||
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
|
|
||||||
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
|
## 1.2.2 — 2025-11-28
|
||||||
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
|
|
||||||
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
|
### Changes
|
||||||
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
|
- Manual heartbeat sends: `clawdis heartbeat --message/--body` (web provider only); `--dry-run` previews payloads.
|
||||||
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
|
|
||||||
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
|
## 1.2.1 — 2025-11-28
|
||||||
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
|
|
||||||
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.
|
### Changes
|
||||||
|
- Media MIME-first handling; hosted media extensions derived from detected MIME with tests.
|
||||||
|
|
||||||
|
### Planned / in progress (from prior notes)
|
||||||
|
- Heartbeat targeting quality: clearer recipient resolution and verbose logs.
|
||||||
|
- Heartbeat delivery preview (Claude path) dry-run.
|
||||||
|
- Simulated inbound hook for local testing.
|
||||||
|
|
||||||
|
## 1.2.0 — 2025-11-27
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Heartbeat interval default 10m for command mode; prompt `HEARTBEAT /think:high`; skips don’t refresh session; session `heartbeatIdleMinutes` support.
|
||||||
|
- Heartbeat tooling: `--session-id`, `--heartbeat-now` (inline flag on `gateway`) for immediate startup probes.
|
||||||
|
- Prompt structure: `sessionIntro` plus per-message `/think:high`; session idle up to 7 days.
|
||||||
|
- Thinking directives: `/think:<level>`; Pi uses `--thinking`; others append cue; `/think:off` no-op.
|
||||||
|
- Robustness: Baileys/WebSocket guards; global unhandled error handlers; WhatsApp LID mapping; hosted media MIME-sniffing and cleanup.
|
||||||
|
- Docs: README Clawd setup; `docs/claude-config.md` for live config.
|
||||||
|
|
||||||
|
## 1.1.0 — 2025-11-26
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Web auto-replies resize/recompress media and honor `inbound.reply.mediaMaxMb`.
|
||||||
|
- Detect media kind, enforce provider caps (images ≤6MB, audio/video ≤16MB, docs ≤100MB).
|
||||||
|
- `session.sendSystemOnce` and optional `sessionIntro`.
|
||||||
|
- Typing indicator refresh during commands; configurable via `inbound.reply.typingIntervalSeconds`.
|
||||||
|
- Optional audio transcription via external CLI.
|
||||||
|
- Command replies return structured payload/meta; respect `mediaMaxMb`; log Claude metadata; include `cwd` in timeout messages.
|
||||||
|
- Web provider refactor; logout command; web-only gateway start helper.
|
||||||
|
- Structured reconnect/heartbeat logging; bounded backoff with CLI/config knobs; troubleshooting guide.
|
||||||
|
- Relay help prints effective heartbeat/backoff when in web mode.
|
||||||
|
|
||||||
|
## 1.0.4 — 2025-11-25
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Timeout fallbacks send partial stdout (≤800 chars) to the user instead of silence; tests added.
|
||||||
|
- Web gateway auto-reconnects after Baileys/WebSocket drops; close propagation tests.
|
||||||
|
|
||||||
|
## 0.1.3 — 2025-11-25
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Auto-replies send a WhatsApp fallback message on command/Claude timeout with truncated stdout.
|
||||||
|
- Added tests for timeout fallback and partial-output truncation.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Contributing to Clawdbot
|
|
||||||
|
|
||||||
Welcome to the lobster tank! 🦞
|
|
||||||
|
|
||||||
## Quick Links
|
|
||||||
- **GitHub:** https://github.com/clawdbot/clawdbot
|
|
||||||
- **Discord:** https://discord.gg/qkhbAGHRBT
|
|
||||||
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)
|
|
||||||
|
|
||||||
## Maintainers
|
|
||||||
|
|
||||||
- **Peter Steinberger** - Benevolent Dictator
|
|
||||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
|
||||||
|
|
||||||
- **Shadow** - Discord + Slack subsystem
|
|
||||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
|
||||||
|
|
||||||
- **Jos** - Telegram, API, Nix mode
|
|
||||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
|
||||||
|
|
||||||
## How to Contribute
|
|
||||||
1. **Bugs & small fixes** → Open a PR!
|
|
||||||
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
|
|
||||||
3. **Questions** → Discord #setup-help
|
|
||||||
|
|
||||||
## Before You PR
|
|
||||||
- Test locally with your Clawdbot instance
|
|
||||||
- Run linter: `npm run lint`
|
|
||||||
- Keep PRs focused (one thing per PR)
|
|
||||||
- Describe what & why
|
|
||||||
|
|
||||||
## AI/Vibe-Coded PRs Welcome! 🤖
|
|
||||||
|
|
||||||
Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
|
||||||
|
|
||||||
Please include in your PR:
|
|
||||||
- [ ] Mark as AI-assisted in the PR title or description
|
|
||||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
|
||||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
|
||||||
- [ ] Confirm you understand what the code does
|
|
||||||
|
|
||||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
FROM node:22-bookworm
|
|
||||||
|
|
||||||
# Install Bun (required for build scripts)
|
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
|
||||||
ENV PATH="/root/.bun/bin:${PATH}"
|
|
||||||
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ARG CLAWDBOT_DOCKER_APT_PACKAGES=""
|
|
||||||
RUN if [ -n "$CLAWDBOT_DOCKER_APT_PACKAGES" ]; then \
|
|
||||||
apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $CLAWDBOT_DOCKER_APT_PACKAGES && \
|
|
||||||
apt-get clean && \
|
|
||||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
|
||||||
COPY ui/package.json ./ui/package.json
|
|
||||||
COPY patches ./patches
|
|
||||||
COPY scripts ./scripts
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm build
|
|
||||||
RUN pnpm ui:install
|
|
||||||
RUN pnpm ui:build
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
python3 \
|
|
||||||
ripgrep \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
CMD ["sleep", "infinity"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM debian:bookworm-slim
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
bash \
|
|
||||||
ca-certificates \
|
|
||||||
chromium \
|
|
||||||
curl \
|
|
||||||
fonts-liberation \
|
|
||||||
fonts-noto-color-emoji \
|
|
||||||
git \
|
|
||||||
jq \
|
|
||||||
novnc \
|
|
||||||
python3 \
|
|
||||||
socat \
|
|
||||||
websockify \
|
|
||||||
x11vnc \
|
|
||||||
xvfb \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/clawdbot-sandbox-browser
|
|
||||||
RUN chmod +x /usr/local/bin/clawdbot-sandbox-browser
|
|
||||||
|
|
||||||
EXPOSE 9222 5900 6080
|
|
||||||
|
|
||||||
CMD ["clawdbot-sandbox-browser"]
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 🦞 Clawdbot — Personal AI Assistant
|
# 🦞 CLAWDIS — Personal AI Assistant
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="Clawdbot" width="400">
|
<img src="https://raw.githubusercontent.com/steipete/clawdis/main/docs/whatsapp-clawd.jpg" alt="CLAWDIS" width="400">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -9,370 +9,164 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
<a href="https://github.com/steipete/clawdis/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/steipete/clawdis/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
<a href="https://github.com/steipete/clawdis/releases"><img src="https://img.shields.io/github/v/release/steipete/clawdis?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the surfaces you already use (WhatsApp, Telegram, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a private, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/start/getting-started) · [Updating](https://docs.clawd.bot/install/updating) · [Showcase](https://docs.clawd.bot/start/showcase) · [FAQ](https://docs.clawd.bot/start/faq) · [Wizard](https://docs.clawd.bot/start/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/install/docker) · [Discord](https://discord.gg/clawd)
|
|
||||||
|
|
||||||
Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
|
||||||
Works with npm, pnpm, or bun.
|
|
||||||
New install? Start here: [Getting started](https://docs.clawd.bot/start/getting-started)
|
|
||||||
|
|
||||||
**Subscriptions (OAuth):**
|
|
||||||
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
|
|
||||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
|
||||||
|
|
||||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/start/onboarding).
|
|
||||||
|
|
||||||
## Models (selection + auth)
|
|
||||||
|
|
||||||
- Models config + CLI: [Models](https://docs.clawd.bot/concepts/models)
|
|
||||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/concepts/model-failover)
|
|
||||||
|
|
||||||
## Install (recommended)
|
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g clawdbot@latest
|
|
||||||
# or: pnpm add -g clawdbot@latest
|
|
||||||
|
|
||||||
clawdbot onboard --install-daemon
|
|
||||||
```
|
|
||||||
|
|
||||||
The wizard installs the Gateway daemon (launchd/systemd user service) so it stays running.
|
|
||||||
|
|
||||||
## Quick start (TL;DR)
|
|
||||||
|
|
||||||
Runtime: **Node ≥22**.
|
|
||||||
|
|
||||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.clawd.bot/start/getting-started)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot onboard --install-daemon
|
|
||||||
|
|
||||||
clawdbot gateway --port 18789 --verbose
|
|
||||||
|
|
||||||
# Send a message
|
|
||||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
|
||||||
```
|
|
||||||
|
|
||||||
Upgrading? [Updating guide](https://docs.clawd.bot/install/updating) (and run `clawdbot doctor`).
|
|
||||||
|
|
||||||
## From source (development)
|
|
||||||
|
|
||||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/clawdbot/clawdbot.git
|
|
||||||
cd clawdbot
|
|
||||||
|
|
||||||
pnpm install
|
|
||||||
pnpm ui:build # auto-installs UI deps on first run
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
pnpm clawdbot onboard --install-daemon
|
|
||||||
|
|
||||||
# Dev loop (auto-reload on TS changes)
|
|
||||||
pnpm gateway:watch
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.
|
|
||||||
|
|
||||||
## Security defaults (DM access)
|
|
||||||
|
|
||||||
Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.
|
|
||||||
|
|
||||||
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
|
|
||||||
|
|
||||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
|
|
||||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
|
||||||
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
|
||||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
|
||||||
|
|
||||||
Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
|
||||||
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
|
|
||||||
- **[Companion apps](https://docs.clawd.bot/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
|
|
||||||
- **[Onboarding](https://docs.clawd.bot/start/wizard) + [skills](https://docs.clawd.bot/tools/skills)** — wizard-driven setup with bundled/managed/workspace skills.
|
|
||||||
|
|
||||||
## Star History
|
|
||||||
|
|
||||||
[](https://www.star-history.com/#clawdbot/clawdbot&type=date&legend=top-left)
|
|
||||||
|
|
||||||
## Everything we built so far
|
|
||||||
|
|
||||||
### Core platform
|
|
||||||
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
|
||||||
- [CLI surface](https://docs.clawd.bot/tools/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/start/wizard), and [doctor](https://docs.clawd.bot/gateway/doctor).
|
|
||||||
- [Pi agent runtime](https://docs.clawd.bot/concepts/agent) in RPC mode with tool streaming and block streaming.
|
|
||||||
- [Session model](https://docs.clawd.bot/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/concepts/groups).
|
|
||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
|
||||||
|
|
||||||
### Channels
|
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
|
||||||
|
|
||||||
### Apps + nodes
|
|
||||||
- [macOS app](https://docs.clawd.bot/platforms/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/nodes/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/nodes/talk) overlay, [WebChat](https://docs.clawd.bot/web/webchat), debug tools, [remote gateway](https://docs.clawd.bot/gateway/remote) control.
|
|
||||||
- [iOS node](https://docs.clawd.bot/platforms/ios): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Voice Wake](https://docs.clawd.bot/nodes/voicewake), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, Bonjour pairing.
|
|
||||||
- [Android node](https://docs.clawd.bot/platforms/android): [Canvas](https://docs.clawd.bot/platforms/mac/canvas), [Talk Mode](https://docs.clawd.bot/nodes/talk), camera, screen recording, optional SMS.
|
|
||||||
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.
|
|
||||||
|
|
||||||
### Tools + automation
|
|
||||||
- [Browser control](https://docs.clawd.bot/tools/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
|
|
||||||
- [Canvas](https://docs.clawd.bot/platforms/mac/canvas): [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
|
||||||
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/nodes/location-command), notifications.
|
|
||||||
- [Cron + wakeups](https://docs.clawd.bot/automation/cron-jobs); [webhooks](https://docs.clawd.bot/automation/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/automation/gmail-pubsub).
|
|
||||||
- [Skills platform](https://docs.clawd.bot/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
|
||||||
|
|
||||||
### Runtime + safety
|
|
||||||
- [Channel routing](https://docs.clawd.bot/concepts/channel-routing), [retry policy](https://docs.clawd.bot/concepts/retry), and [streaming/chunking](https://docs.clawd.bot/concepts/streaming).
|
|
||||||
- [Presence](https://docs.clawd.bot/concepts/presence), [typing indicators](https://docs.clawd.bot/concepts/typing-indicators), and [usage tracking](https://docs.clawd.bot/concepts/usage-tracking).
|
|
||||||
- [Models](https://docs.clawd.bot/concepts/models), [model failover](https://docs.clawd.bot/concepts/model-failover), and [session pruning](https://docs.clawd.bot/concepts/session-pruning).
|
|
||||||
- [Security](https://docs.clawd.bot/gateway/security) and [troubleshooting](https://docs.clawd.bot/channels/troubleshooting).
|
|
||||||
|
|
||||||
### Ops + packaging
|
|
||||||
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/web/webchat) served directly from the Gateway.
|
|
||||||
- [Tailscale Serve/Funnel](https://docs.clawd.bot/gateway/tailscale) or [SSH tunnels](https://docs.clawd.bot/gateway/remote) with token/password auth.
|
|
||||||
- [Nix mode](https://docs.clawd.bot/install/nix) for declarative config; [Docker](https://docs.clawd.bot/install/docker)-based installs.
|
|
||||||
- [Doctor](https://docs.clawd.bot/gateway/doctor) migrations, [logging](https://docs.clawd.bot/logging).
|
|
||||||
|
|
||||||
## How it works (short)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
Your surfaces
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────┐
|
||||||
│ Gateway │
|
│ Gateway │ ws://127.0.0.1:18789
|
||||||
│ (control plane) │
|
│ (control plane) │ tcp://0.0.0.0:18790 (optional Bridge)
|
||||||
│ ws://127.0.0.1:18789 │
|
|
||||||
└──────────────┬────────────────┘
|
└──────────────┬────────────────┘
|
||||||
│
|
│
|
||||||
├─ Pi agent (RPC)
|
├─ Pi agent (RPC)
|
||||||
├─ CLI (clawdbot …)
|
├─ CLI (clawdis …)
|
||||||
├─ WebChat UI
|
├─ WebChat (browser)
|
||||||
├─ macOS app
|
├─ macOS app (Clawdis.app)
|
||||||
└─ iOS / Android nodes
|
└─ iOS node (Canvas + voice)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key subsystems
|
## What Clawdis does
|
||||||
|
|
||||||
- **[Gateway WebSocket network](https://docs.clawd.bot/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
|
- **Personal assistant** — one user, one identity, one memory surface.
|
||||||
- **[Tailscale exposure](https://docs.clawd.bot/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/gateway/remote)).
|
- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS.
|
||||||
- **[Browser control](https://docs.clawd.bot/tools/browser)** — clawd‑managed Chrome/Chromium with CDP control.
|
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||||
- **[Canvas + A2UI](https://docs.clawd.bot/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui)).
|
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always‑on speech and continuous conversation.
|
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||||
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
- **Local-first control plane** — the Gateway owns state, everything else connects.
|
||||||
|
- **Group chats** — mention-based by default, `/activation always|mention` per group (owner-only).
|
||||||
|
|
||||||
## Tailscale access (Gateway dashboard)
|
## How it works (short)
|
||||||
|
|
||||||
Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
- **Gateway** is the single source of truth for sessions/providers.
|
||||||
|
- **Loopback-first**: `ws://127.0.0.1:18789` by default.
|
||||||
|
- **Bridge** (optional) exposes a paired-node port for iOS/Android.
|
||||||
|
- **Agent runtime** is **Pi** in RPC mode.
|
||||||
|
|
||||||
- `off`: no Tailscale automation (default).
|
## Quick start (from source)
|
||||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
|
||||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
|
||||||
|
|
||||||
Notes:
|
Runtime: **Node ≥22** + **pnpm**.
|
||||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
|
|
||||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
|
||||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
|
||||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
|
||||||
|
|
||||||
Details: [Tailscale guide](https://docs.clawd.bot/gateway/tailscale) · [Web surfaces](https://docs.clawd.bot/web)
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm build
|
||||||
|
pnpm ui:build
|
||||||
|
|
||||||
## Remote Gateway (Linux is great)
|
# Link WhatsApp (stores creds in ~/.clawdis/credentials)
|
||||||
|
pnpm clawdis login
|
||||||
|
|
||||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
# Start the gateway
|
||||||
|
pnpm clawdis gateway --port 18789 --verbose
|
||||||
|
|
||||||
- **Gateway host** runs the exec tool and channel connections by default.
|
# Send a message
|
||||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
||||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
|
||||||
|
|
||||||
Details: [Remote access](https://docs.clawd.bot/gateway/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/gateway/security)
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram)
|
||||||
|
pnpm clawdis agent --message "Ship checklist" --thinking high
|
||||||
|
```
|
||||||
|
|
||||||
## macOS permissions via the Gateway protocol
|
If you run from source, prefer `pnpm clawdis …` (not global `clawdis`).
|
||||||
|
|
||||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
|
||||||
|
|
||||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
|
||||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
|
||||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
|
||||||
|
|
||||||
Elevated bash (host permissions) is separate from macOS TCC:
|
|
||||||
|
|
||||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
|
||||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
|
||||||
|
|
||||||
Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/platforms/macos) · [Gateway protocol](https://docs.clawd.bot/concepts/architecture)
|
|
||||||
|
|
||||||
## Agent to Agent (sessions_* tools)
|
|
||||||
|
|
||||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
|
||||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
|
||||||
- `sessions_history` — fetch transcript logs for a session.
|
|
||||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
|
||||||
|
|
||||||
Details: [Session tools](https://docs.clawd.bot/concepts/session-tool)
|
|
||||||
|
|
||||||
## Skills registry (ClawdHub)
|
|
||||||
|
|
||||||
ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
|
||||||
|
|
||||||
[ClawdHub](https://ClawdHub.com)
|
|
||||||
|
|
||||||
## Chat commands
|
## Chat commands
|
||||||
|
|
||||||
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
|
Send these in WhatsApp/Telegram/WebChat (group commands are owner-only):
|
||||||
|
|
||||||
- `/status` — compact session status (model + tokens, cost when available)
|
- `/status` — health + session info (group shows activation mode)
|
||||||
- `/new` or `/reset` — reset the session
|
- `/new` or `/reset` — reset the session
|
||||||
- `/compact` — compact session context (summary)
|
- `/think <level>` — off|minimal|low|medium|high
|
||||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
|
||||||
- `/verbose on|off`
|
- `/verbose on|off`
|
||||||
- `/cost on|off` — append per-response token/cost usage lines
|
|
||||||
- `/restart` — restart the gateway (owner-only in groups)
|
- `/restart` — restart the gateway (owner-only in groups)
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
- `/activation mention|always` — group activation toggle (groups only)
|
||||||
|
|
||||||
## Apps (optional)
|
## Architecture
|
||||||
|
|
||||||
The Gateway alone delivers a great experience. All apps are optional and add extra features.
|
### TypeScript Gateway (src/gateway/server.ts)
|
||||||
|
- **Single HTTP+WS server** on `ws://127.0.0.1:18789` (bind policy: loopback/lan/tailnet/auto). The first frame must be `connect`; AJV validates frames against TypeBox schemas (`src/gateway/protocol`).
|
||||||
|
- **Single source of truth** for sessions, providers, cron, voice wake, and presence. Methods cover `send`, `agent`, `chat.*`, `sessions.*`, `config.*`, `cron.*`, `voicewake.*`, `node.*`, `system-*`, `wake`.
|
||||||
|
- **Events + snapshot**: handshake returns a snapshot (presence/health) and declares event types; runtime events include `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `cron`, `node.pair.*`, `voicewake.changed`, `shutdown`.
|
||||||
|
- **Idempotency & safety**: `send`/`agent`/`chat.send` require idempotency keys with a TTL cache (5 min, cap 1000) to avoid double‑sends on reconnects; payload sizes are capped per connection.
|
||||||
|
- **Bridge for nodes**: optional TCP bridge (`src/infra/bridge/server.ts`) is newline‑delimited JSON frames (`hello`, pairing, RPC, `invoke`); node connect/disconnect is surfaced into presence.
|
||||||
|
- **Control UI + Canvas Host**: HTTP serves `/ui` assets (if built) and can host a live‑reload Canvas host for nodes (`src/canvas-host/server.ts`), injecting the A2UI postMessage bridge.
|
||||||
|
|
||||||
If you plan to build/run companion apps, initialize submodules first:
|
### iOS app (apps/ios)
|
||||||
|
- **Discovery + pairing**: Bonjour discovery via `BridgeDiscoveryModel` (NWBrowser). `BridgeConnectionController` auto‑connects using Keychain token or allows manual host/port.
|
||||||
|
- **Node runtime**: `BridgeSession` (actor) maintains the `NWConnection`, hello handshake, ping/pong, RPC requests, and `invoke` callbacks.
|
||||||
|
- **Capabilities + commands**: advertises `canvas`, `screen`, `camera`, `voiceWake` (settings‑driven) and executes `canvas.*`, `canvas.a2ui.*`, `camera.*`, `screen.record` (`NodeAppModel.handleInvoke`).
|
||||||
|
- **Canvas**: `WKWebView` with bundled Canvas scaffold + A2UI, JS eval, snapshot capture, and `clawdis://` deep‑link interception (`ScreenController`).
|
||||||
|
- **Voice + deep links**: voice wake sends `voice.transcript` events; `clawdis://agent` links emit `agent.request`. Voice wake triggers sync via `voicewake.get` + `voicewake.changed`.
|
||||||
|
|
||||||
```bash
|
## Companion apps
|
||||||
git submodule update --init --recursive
|
|
||||||
./scripts/restart-mac.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### macOS (Clawdbot.app) (optional)
|
The **macOS app is critical**: it runs the menu‑bar control plane, owns local permissions (TCC), hosts Voice Wake, exposes WebChat/debug tools, and coordinates local/remote gateway mode. Most “assistant” UX lives here.
|
||||||
|
|
||||||
|
### macOS (Clawdis.app)
|
||||||
|
|
||||||
- Menu bar control for the Gateway and health.
|
- Menu bar control for the Gateway and health.
|
||||||
- Voice Wake + push-to-talk overlay.
|
- Voice Wake + push-to-talk overlay.
|
||||||
- WebChat + debug tools.
|
- WebChat + debug tools.
|
||||||
- Remote gateway control over SSH.
|
- Remote gateway control over SSH.
|
||||||
|
|
||||||
Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).
|
Build/run: `./scripts/restart-mac.sh` (packages + launches).
|
||||||
|
|
||||||
### iOS node (optional)
|
### iOS node (internal)
|
||||||
|
|
||||||
- Pairs as a node via the Bridge.
|
- Pairs as a node via the Bridge.
|
||||||
- Voice trigger forwarding + Canvas surface.
|
- Voice trigger forwarding + Canvas surface.
|
||||||
- Controlled via `clawdbot nodes …`.
|
- Controlled via `clawdis nodes …`.
|
||||||
|
|
||||||
Runbook: [iOS connect](https://docs.clawd.bot/platforms/ios).
|
Runbook: `docs/ios/connect.md`.
|
||||||
|
|
||||||
### Android node (optional)
|
### Android node (internal)
|
||||||
|
|
||||||
- Pairs via the same Bridge + pairing flow as iOS.
|
- Pairs via the same Bridge + pairing flow as iOS.
|
||||||
- Exposes Canvas, Camera, and Screen capture commands.
|
- Exposes Canvas, Camera, and Screen capture commands.
|
||||||
- Runbook: [Android connect](https://docs.clawd.bot/platforms/android).
|
- Runbook: `docs/android/connect.md`.
|
||||||
|
|
||||||
## Agent workspace + skills
|
## Agent workspace + skills
|
||||||
|
|
||||||
- Workspace root: `~/clawd` (configurable via `agents.defaults.workspace`).
|
- Workspace root: `~/clawd` (configurable via `inbound.workspace`).
|
||||||
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
|
||||||
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
- Skills: `~/clawd/skills/<skill>/SKILL.md`.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Minimal `~/.clawdbot/clawdbot.json` (model + defaults):
|
Minimal `~/.clawdis/clawdis.json`:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agent: {
|
inbound: {
|
||||||
model: "anthropic/claude-opus-4-5"
|
allowFrom: ["+1234567890"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
[Full configuration reference (all keys + examples).](https://docs.clawd.bot/gateway/configuration)
|
### WhatsApp
|
||||||
|
|
||||||
## Security model (important)
|
- Link the device: `pnpm clawdis login` (stores creds in `~/.clawdis/credentials`).
|
||||||
|
- Allowlist who can talk to the assistant via `inbound.allowFrom`.
|
||||||
|
|
||||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
### Telegram
|
||||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
|
||||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
|
||||||
|
|
||||||
Details: [Security guide](https://docs.clawd.bot/gateway/security) · [Docker + sandboxing](https://docs.clawd.bot/install/docker) · [Sandbox config](https://docs.clawd.bot/gateway/configuration)
|
- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
|
||||||
|
- Optional: set `telegram.requireMention`, `telegram.allowFrom`, or `telegram.webhookUrl` as needed.
|
||||||
### [WhatsApp](https://docs.clawd.bot/channels/whatsapp)
|
|
||||||
|
|
||||||
- Link the device: `pnpm clawdbot channels login` (stores creds in `~/.clawdbot/credentials`).
|
|
||||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
|
||||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
|
||||||
|
|
||||||
### [Telegram](https://docs.clawd.bot/channels/telegram)
|
|
||||||
|
|
||||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
|
||||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` as needed.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
channels: {
|
telegram: {
|
||||||
telegram: {
|
botToken: "123456:ABCDEF"
|
||||||
botToken: "123456:ABCDEF"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### [Slack](https://docs.clawd.bot/channels/slack)
|
|
||||||
|
|
||||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
|
||||||
|
|
||||||
### [Discord](https://docs.clawd.bot/channels/discord)
|
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
|
||||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
discord: {
|
|
||||||
token: "1234abcd"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### [Signal](https://docs.clawd.bot/channels/signal)
|
|
||||||
|
|
||||||
- Requires `signal-cli` and a `channels.signal` config section.
|
|
||||||
|
|
||||||
### [iMessage](https://docs.clawd.bot/channels/imessage)
|
|
||||||
|
|
||||||
- macOS only; Messages must be signed in.
|
|
||||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
|
||||||
|
|
||||||
### [Microsoft Teams](https://docs.clawd.bot/channels/msteams)
|
|
||||||
|
|
||||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
|
||||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
|
||||||
|
|
||||||
### [WebChat](https://docs.clawd.bot/web/webchat)
|
|
||||||
|
|
||||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
|
||||||
|
|
||||||
Browser control (optional):
|
Browser control (optional):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -387,110 +181,22 @@ Browser control (optional):
|
|||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
- [`docs/index.md`](docs/index.md) (overview)
|
||||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
|
- [`docs/configuration.md`](docs/configuration.md)
|
||||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/concepts/architecture)
|
- [`docs/group-messages.md`](docs/group-messages.md)
|
||||||
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/gateway/configuration)
|
- [`docs/gateway.md`](docs/gateway.md)
|
||||||
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
|
- [`docs/web.md`](docs/web.md)
|
||||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
|
- [`docs/discovery.md`](docs/discovery.md)
|
||||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/gateway/remote)
|
- [`docs/agent.md`](docs/agent.md)
|
||||||
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/start/wizard)
|
- [`docs/security.md`](docs/security.md)
|
||||||
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/automation/webhook)
|
- [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/automation/gmail-pubsub)
|
- [`docs/ios/connect.md`](docs/ios/connect.md)
|
||||||
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/platforms/mac/menu-bar)
|
- [`docs/clawdis-mac.md`](docs/clawdis-mac.md)
|
||||||
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/platforms/windows), [Linux](https://docs.clawd.bot/platforms/linux), [macOS](https://docs.clawd.bot/platforms/macos), [iOS](https://docs.clawd.bot/platforms/ios), [Android](https://docs.clawd.bot/platforms/android)
|
|
||||||
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/channels/troubleshooting)
|
|
||||||
- [Review security guidance before exposing anything.](https://docs.clawd.bot/gateway/security)
|
|
||||||
|
|
||||||
## Advanced docs (discovery + control)
|
|
||||||
|
|
||||||
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
|
|
||||||
- [Bonjour/mDNS](https://docs.clawd.bot/gateway/bonjour)
|
|
||||||
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
|
|
||||||
- [Remote gateway README](https://docs.clawd.bot/gateway/remote-gateway-readme)
|
|
||||||
- [Control UI](https://docs.clawd.bot/web/control-ui)
|
|
||||||
- [Dashboard](https://docs.clawd.bot/web/dashboard)
|
|
||||||
|
|
||||||
## Operations & troubleshooting
|
|
||||||
|
|
||||||
- [Health checks](https://docs.clawd.bot/gateway/health)
|
|
||||||
- [Gateway lock](https://docs.clawd.bot/gateway/gateway-lock)
|
|
||||||
- [Background process](https://docs.clawd.bot/gateway/background-process)
|
|
||||||
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
|
|
||||||
- [Logging](https://docs.clawd.bot/logging)
|
|
||||||
|
|
||||||
## Deep dives
|
|
||||||
|
|
||||||
- [Agent loop](https://docs.clawd.bot/concepts/agent-loop)
|
|
||||||
- [Presence](https://docs.clawd.bot/concepts/presence)
|
|
||||||
- [TypeBox schemas](https://docs.clawd.bot/concepts/typebox)
|
|
||||||
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
|
|
||||||
- [Queue](https://docs.clawd.bot/concepts/queue)
|
|
||||||
|
|
||||||
## Workspace & skills
|
|
||||||
|
|
||||||
- [Skills config](https://docs.clawd.bot/tools/skills-config)
|
|
||||||
- [Default AGENTS](https://docs.clawd.bot/reference/AGENTS.default)
|
|
||||||
- [Templates: AGENTS](https://docs.clawd.bot/reference/templates/AGENTS)
|
|
||||||
- [Templates: BOOTSTRAP](https://docs.clawd.bot/reference/templates/BOOTSTRAP)
|
|
||||||
- [Templates: IDENTITY](https://docs.clawd.bot/reference/templates/IDENTITY)
|
|
||||||
- [Templates: SOUL](https://docs.clawd.bot/reference/templates/SOUL)
|
|
||||||
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
|
|
||||||
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
|
|
||||||
|
|
||||||
## Platform internals
|
|
||||||
|
|
||||||
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
|
|
||||||
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
|
|
||||||
- [macOS voice wake](https://docs.clawd.bot/platforms/mac/voicewake)
|
|
||||||
- [iOS node](https://docs.clawd.bot/platforms/ios)
|
|
||||||
- [Android node](https://docs.clawd.bot/platforms/android)
|
|
||||||
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
|
|
||||||
- [Linux app](https://docs.clawd.bot/platforms/linux)
|
|
||||||
|
|
||||||
## Email hooks (Gmail)
|
|
||||||
|
|
||||||
- [docs.clawd.bot/gmail-pubsub](https://docs.clawd.bot/automation/gmail-pubsub)
|
|
||||||
|
|
||||||
## Clawd
|
## Clawd
|
||||||
|
|
||||||
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
|
Clawdis was built for **Clawd**, a space lobster AI assistant.
|
||||||
by Peter Steinberger and the community.
|
|
||||||
|
|
||||||
- [clawd.me](https://clawd.me)
|
- https://clawd.me
|
||||||
- [soul.md](https://soul.md)
|
- https://soul.md
|
||||||
- [steipete.me](https://steipete.me)
|
- https://steipete.me
|
||||||
|
|
||||||
## Community
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
|
||||||
|
|
||||||
Special thanks to @andrewting19 for the Anthropic OAuth tool-name fix.
|
|
||||||
|
|
||||||
Core contributors:
|
|
||||||
- @cpojer — Telegram onboarding UX + docs
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
|
||||||
|
|
||||||
<p align="left">
|
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a>
|
|
||||||
<a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a>
|
|
||||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
|
||||||
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
|
||||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
|
||||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
|
|
||||||
<a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
|
||||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a>
|
|
||||||
<a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
|
||||||
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a>
|
|
||||||
<a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a>
|
|
||||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
|
||||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a>
|
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
|
||||||
<a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
|
|
||||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
|
||||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
|
||||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
|
||||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
If you believe you’ve found a security issue in Clawdbot, please report it privately.
|
|
||||||
|
|
||||||
## Reporting
|
|
||||||
|
|
||||||
- Email: `steipete@gmail.com`
|
|
||||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
|
||||||
|
|
||||||
## Operational Guidance
|
|
||||||
|
|
||||||
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
|
|
||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
|
||||||
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "5d29ee82825e0764775562242cfa1ff4dc79584797dd638f76c9876545454748",
|
"originHash" : "3018b2c8c183d55b57ad0c4526b2380ac3b957d13a3a86e1b2845e81323c443a",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "elevenlabskit",
|
"identity" : "commander",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/Commander.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
"revision" : "8b8cb4f34315ce9e5307b3a2bcd77ff73f586a02",
|
||||||
"version" : "0.1.0"
|
"version" : "0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(path: "../Peekaboo/Commander"),
|
.package(url: "https://github.com/steipete/Commander.git", from: "0.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ extension AttributedString {
|
|||||||
var ranges: [Range<AttributedString.Index>] = []
|
var ranges: [Range<AttributedString.Index>] = []
|
||||||
for wordRange in wordRanges {
|
for wordRange in wordRanges {
|
||||||
if let lastRange = ranges.last,
|
if let lastRange = ranges.last,
|
||||||
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
|
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
|
||||||
|
{
|
||||||
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
|
||||||
} else {
|
} else {
|
||||||
ranges.append(wordRange)
|
ranges.append(wordRange)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public actor TranscriptsStore {
|
|||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
fileURL = dir.appendingPathComponent("transcripts.log")
|
fileURL = dir.appendingPathComponent("transcripts.log")
|
||||||
if let data = try? Data(contentsOf: fileURL),
|
if let data = try? Data(contentsOf: fileURL),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let text = String(data: data, encoding: .utf8)
|
||||||
|
{
|
||||||
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
entries = text.split(separator: "\n").map(String.init).suffix(limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public struct WakeWordSegment: Sendable, Equatable {
|
|||||||
self.range = range
|
self.range = range
|
||||||
}
|
}
|
||||||
|
|
||||||
public var end: TimeInterval { start + duration }
|
public var end: TimeInterval { self.start + self.duration }
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct WakeWordGateConfig: Sendable, Equatable {
|
public struct WakeWordGateConfig: Sendable, Equatable {
|
||||||
@@ -24,7 +24,8 @@ public struct WakeWordGateConfig: Sendable, Equatable {
|
|||||||
public init(
|
public init(
|
||||||
triggers: [String],
|
triggers: [String],
|
||||||
minPostTriggerGap: TimeInterval = 0.45,
|
minPostTriggerGap: TimeInterval = 0.45,
|
||||||
minCommandLength: Int = 1) {
|
minCommandLength: Int = 1)
|
||||||
|
{
|
||||||
self.triggers = triggers
|
self.triggers = triggers
|
||||||
self.minPostTriggerGap = minPostTriggerGap
|
self.minPostTriggerGap = minPostTriggerGap
|
||||||
self.minCommandLength = minCommandLength
|
self.minCommandLength = minCommandLength
|
||||||
@@ -56,30 +57,32 @@ public enum WakeWordGate {
|
|||||||
let tokens: [String]
|
let tokens: [String]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MatchCandidate {
|
|
||||||
let index: Int
|
|
||||||
let triggerEnd: TimeInterval
|
|
||||||
let gap: TimeInterval
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func match(
|
public static func match(
|
||||||
transcript: String,
|
transcript: String,
|
||||||
segments: [WakeWordSegment],
|
segments: [WakeWordSegment],
|
||||||
config: WakeWordGateConfig)
|
config: WakeWordGateConfig)
|
||||||
-> WakeWordGateMatch? {
|
-> WakeWordGateMatch? {
|
||||||
let triggerTokens = normalizeTriggers(config.triggers)
|
let triggerTokens = self.normalizeTriggers(config.triggers)
|
||||||
guard !triggerTokens.isEmpty else { return nil }
|
guard !triggerTokens.isEmpty else { return nil }
|
||||||
|
|
||||||
let tokens = normalizeSegments(segments)
|
let tokens = self.normalizeSegments(segments)
|
||||||
guard !tokens.isEmpty else { return nil }
|
guard !tokens.isEmpty else { return nil }
|
||||||
|
|
||||||
var best: MatchCandidate?
|
var bestIndex: Int?
|
||||||
|
var bestTriggerEnd: TimeInterval = 0
|
||||||
|
var bestGap: TimeInterval = 0
|
||||||
|
|
||||||
for trigger in triggerTokens {
|
for trigger in triggerTokens {
|
||||||
let count = trigger.tokens.count
|
let count = trigger.tokens.count
|
||||||
guard count > 0, tokens.count > count else { continue }
|
guard count > 0, tokens.count > count else { continue }
|
||||||
for i in 0...(tokens.count - count - 1) {
|
for i in 0...(tokens.count - count - 1) {
|
||||||
let matched = (0..<count).allSatisfy { tokens[i + $0].normalized == trigger.tokens[$0] }
|
var matched = true
|
||||||
|
for t in 0..<count {
|
||||||
|
if tokens[i + t].normalized != trigger.tokens[t] {
|
||||||
|
matched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
if !matched { continue }
|
if !matched { continue }
|
||||||
|
|
||||||
let triggerEnd = tokens[i + count - 1].end
|
let triggerEnd = tokens[i + count - 1].end
|
||||||
@@ -87,17 +90,19 @@ public enum WakeWordGate {
|
|||||||
let gap = nextToken.start - triggerEnd
|
let gap = nextToken.start - triggerEnd
|
||||||
if gap < config.minPostTriggerGap { continue }
|
if gap < config.minPostTriggerGap { continue }
|
||||||
|
|
||||||
if let best, i <= best.index { continue }
|
if let bestIndex, i <= bestIndex { continue }
|
||||||
|
|
||||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
bestIndex = i
|
||||||
|
bestTriggerEnd = triggerEnd
|
||||||
|
bestGap = gap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let best else { return nil }
|
guard let bestIndex else { return nil }
|
||||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
let command = self.commandText(transcript: transcript, segments: segments, triggerEndTime: bestTriggerEnd)
|
||||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
guard command.count >= config.minCommandLength else { return nil }
|
guard command.count >= config.minCommandLength else { return nil }
|
||||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
return WakeWordGateMatch(triggerEndTime: bestTriggerEnd, postGap: bestGap, command: command)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func commandText(
|
public static func commandText(
|
||||||
@@ -116,7 +121,7 @@ public enum WakeWordGate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = segments
|
let text = segments
|
||||||
.filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty }
|
.filter { $0.start >= threshold && !self.normalizeToken($0.text).isEmpty }
|
||||||
.map(\.text)
|
.map(\.text)
|
||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
return text.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||||
@@ -126,7 +131,7 @@ public enum WakeWordGate {
|
|||||||
guard !text.isEmpty else { return false }
|
guard !text.isEmpty else { return false }
|
||||||
let normalized = text.lowercased()
|
let normalized = text.lowercased()
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation).lowercased()
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation).lowercased()
|
||||||
if token.isEmpty { continue }
|
if token.isEmpty { continue }
|
||||||
if normalized.contains(token) { return true }
|
if normalized.contains(token) { return true }
|
||||||
}
|
}
|
||||||
@@ -136,11 +141,11 @@ public enum WakeWordGate {
|
|||||||
public static func stripWake(text: String, triggers: [String]) -> String {
|
public static func stripWake(text: String, triggers: [String]) -> String {
|
||||||
var out = text
|
var out = text
|
||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let token = trigger.trimmingCharacters(in: whitespaceAndPunctuation)
|
let token = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
guard !token.isEmpty else { continue }
|
guard !token.isEmpty else { continue }
|
||||||
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
out = out.replacingOccurrences(of: token, with: "", options: [.caseInsensitive])
|
||||||
}
|
}
|
||||||
return out.trimmingCharacters(in: whitespaceAndPunctuation)
|
return out.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
private static func normalizeTriggers(_ triggers: [String]) -> [TriggerTokens] {
|
||||||
@@ -148,7 +153,7 @@ public enum WakeWordGate {
|
|||||||
for trigger in triggers {
|
for trigger in triggers {
|
||||||
let tokens = trigger
|
let tokens = trigger
|
||||||
.split(whereSeparator: { $0.isWhitespace })
|
.split(whereSeparator: { $0.isWhitespace })
|
||||||
.map { normalizeToken(String($0)) }
|
.map { self.normalizeToken(String($0)) }
|
||||||
.filter { !$0.isEmpty }
|
.filter { !$0.isEmpty }
|
||||||
if tokens.isEmpty { continue }
|
if tokens.isEmpty { continue }
|
||||||
output.append(TriggerTokens(tokens: tokens))
|
output.append(TriggerTokens(tokens: tokens))
|
||||||
@@ -158,7 +163,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
private static func normalizeSegments(_ segments: [WakeWordSegment]) -> [Token] {
|
||||||
segments.compactMap { segment in
|
segments.compactMap { segment in
|
||||||
let normalized = normalizeToken(segment.text)
|
let normalized = self.normalizeToken(segment.text)
|
||||||
guard !normalized.isEmpty else { return nil }
|
guard !normalized.isEmpty else { return nil }
|
||||||
return Token(
|
return Token(
|
||||||
normalized: normalized,
|
normalized: normalized,
|
||||||
@@ -171,7 +176,7 @@ public enum WakeWordGate {
|
|||||||
|
|
||||||
private static func normalizeToken(_ token: String) -> String {
|
private static func normalizeToken(_ token: String) -> String {
|
||||||
token
|
token
|
||||||
.trimmingCharacters(in: whitespaceAndPunctuation)
|
.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||||
.lowercased()
|
.lowercased()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum CLIRegistry {
|
|||||||
subcommands: [
|
subcommands: [
|
||||||
descriptor(for: ServiceInstall.self),
|
descriptor(for: ServiceInstall.self),
|
||||||
descriptor(for: ServiceUninstall.self),
|
descriptor(for: ServiceUninstall.self),
|
||||||
descriptor(for: ServiceStatus.self)
|
descriptor(for: ServiceStatus.self),
|
||||||
])
|
])
|
||||||
let doctorDesc = descriptor(for: DoctorCommand.self)
|
let doctorDesc = descriptor(for: DoctorCommand.self)
|
||||||
let setupDesc = descriptor(for: SetupCommand.self)
|
let setupDesc = descriptor(for: SetupCommand.self)
|
||||||
@@ -54,7 +54,7 @@ enum CLIRegistry {
|
|||||||
startDesc,
|
startDesc,
|
||||||
stopDesc,
|
stopDesc,
|
||||||
restartDesc,
|
restartDesc,
|
||||||
statusDesc
|
statusDesc,
|
||||||
])
|
])
|
||||||
return [root]
|
return [root]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
|
|||||||
"Label": label,
|
"Label": label,
|
||||||
"ProgramArguments": [executable, "serve"],
|
"ProgramArguments": [executable, "serve"],
|
||||||
"RunAtLoad": true,
|
"RunAtLoad": true,
|
||||||
"KeepAlive": true
|
"KeepAlive": true,
|
||||||
]
|
]
|
||||||
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
|
||||||
try data.write(to: plistURL)
|
try data.write(to: plistURL)
|
||||||
|
|||||||
@@ -25,123 +25,78 @@ private func dispatch(invocation: CommandInvocation) async throws {
|
|||||||
|
|
||||||
switch first {
|
switch first {
|
||||||
case "swabble":
|
case "swabble":
|
||||||
try await dispatchSwabble(parsed: parsed, path: path)
|
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
|
||||||
|
let sub = path[1]
|
||||||
|
switch sub {
|
||||||
|
case "serve":
|
||||||
|
var cmd = ServeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "transcribe":
|
||||||
|
var cmd = TranscribeCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "test-hook":
|
||||||
|
var cmd = TestHookCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "mic":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
|
||||||
|
let micSub = path[2]
|
||||||
|
if micSub == "list" {
|
||||||
|
var cmd = MicList(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else if micSub == "set" {
|
||||||
|
var cmd = MicSet(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
} else {
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
||||||
|
}
|
||||||
|
case "service":
|
||||||
|
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
|
||||||
|
let svcSub = path[2]
|
||||||
|
switch svcSub {
|
||||||
|
case "install":
|
||||||
|
var cmd = ServiceInstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "uninstall":
|
||||||
|
var cmd = ServiceUninstall()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = ServiceStatus()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
||||||
|
}
|
||||||
|
case "doctor":
|
||||||
|
var cmd = DoctorCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "setup":
|
||||||
|
var cmd = SetupCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "health":
|
||||||
|
var cmd = HealthCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "tail-log":
|
||||||
|
var cmd = TailLogCommand(parsed: parsed)
|
||||||
|
try await cmd.run()
|
||||||
|
case "start":
|
||||||
|
var cmd = StartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "stop":
|
||||||
|
var cmd = StopCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "restart":
|
||||||
|
var cmd = RestartCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
case "status":
|
||||||
|
var cmd = StatusCommand()
|
||||||
|
try await cmd.run()
|
||||||
|
default:
|
||||||
|
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw CommanderProgramError.unknownCommand(first)
|
throw CommanderProgramError.unknownCommand(first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let sub = try subcommand(path, index: 1, command: "swabble")
|
|
||||||
switch sub {
|
|
||||||
case "mic":
|
|
||||||
try await dispatchMic(parsed: parsed, path: path)
|
|
||||||
case "service":
|
|
||||||
try await dispatchService(path: path)
|
|
||||||
default:
|
|
||||||
let handlers = swabbleHandlers(parsed: parsed)
|
|
||||||
guard let handler = handlers[sub] else {
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
|
|
||||||
}
|
|
||||||
try await handler()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
|
|
||||||
[
|
|
||||||
"serve": {
|
|
||||||
var cmd = ServeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"transcribe": {
|
|
||||||
var cmd = TranscribeCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"test-hook": {
|
|
||||||
var cmd = TestHookCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"doctor": {
|
|
||||||
var cmd = DoctorCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"setup": {
|
|
||||||
var cmd = SetupCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
var cmd = HealthCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"tail-log": {
|
|
||||||
var cmd = TailLogCommand(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"start": {
|
|
||||||
var cmd = StartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"stop": {
|
|
||||||
var cmd = StopCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"restart": {
|
|
||||||
var cmd = RestartCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
var cmd = StatusCommand()
|
|
||||||
try await cmd.run()
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
|
|
||||||
let micSub = try subcommand(path, index: 2, command: "mic")
|
|
||||||
switch micSub {
|
|
||||||
case "list":
|
|
||||||
var cmd = MicList(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
case "set":
|
|
||||||
var cmd = MicSet(parsed: parsed)
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(macOS 26.0, *)
|
|
||||||
@MainActor
|
|
||||||
private func dispatchService(path: [String]) async throws {
|
|
||||||
let svcSub = try subcommand(path, index: 2, command: "service")
|
|
||||||
switch svcSub {
|
|
||||||
case "install":
|
|
||||||
var cmd = ServiceInstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "uninstall":
|
|
||||||
var cmd = ServiceUninstall()
|
|
||||||
try await cmd.run()
|
|
||||||
case "status":
|
|
||||||
var cmd = ServiceStatus()
|
|
||||||
try await cmd.run()
|
|
||||||
default:
|
|
||||||
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
|
|
||||||
guard path.count > index else {
|
|
||||||
throw CommanderProgramError.missingSubcommand(command: command)
|
|
||||||
}
|
|
||||||
return path[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
let exitCode = await runCLI()
|
let exitCode = await runCLI()
|
||||||
exit(exitCode)
|
exit(exitCode)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwabbleKit
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import SwabbleKit
|
||||||
|
|
||||||
@Suite struct WakeWordGateTests {
|
@Suite struct WakeWordGateTests {
|
||||||
@Test func matchRequiresGapAfterTrigger() {
|
@Test func matchRequiresGapAfterTrigger() {
|
||||||
|
|||||||
@@ -1,211 +1,32 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss version="2.0"
|
||||||
<channel>
|
xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"
|
||||||
<title>Clawdbot</title>
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
<item>
|
<channel>
|
||||||
<title>2026.1.14-1</title>
|
<title>Clawdis Updates</title>
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
<link>https://raw.githubusercontent.com/steipete/clawdis/main/appcast.xml</link>
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
<description>Signed update feed for the Clawdis macOS companion app.</description>
|
||||||
<sparkle:version>5825</sparkle:version>
|
<item>
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
<title>Clawdis 2.0.0-beta2</title>
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta2</sparkle:releaseNotesLink>
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
<pubDate>Sun, 21 Dec 2025 02:25:39 +0000</pubDate>
|
||||||
<h3>Highlights</h3>
|
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta2/Clawdis-2.0.0-beta2.zip"
|
||||||
<ul>
|
sparkle:edSignature="voRWLh2Cbg/i2KtUV6ci/MW3b7hK/u1ZPoiryKs+S36ua3xnc51R97JGwmIaToCfTHg2mgFWF7M6qppfe7YsAw=="
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
sparkle:version="2.0.0-beta2"
|
||||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
sparkle:shortVersionString="2.0.0-beta2"
|
||||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
length="67435891"
|
||||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
type="application/octet-stream" />
|
||||||
</ul>
|
</item>
|
||||||
<h3>Changes</h3>
|
<item>
|
||||||
<h4>Web Tools</h4>
|
<title>Clawdis 2.0.0-beta1</title>
|
||||||
<ul>
|
<sparkle:releaseNotesLink>https://github.com/steipete/clawdis/releases/tag/v2.0.0-beta1</sparkle:releaseNotesLink>
|
||||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
<pubDate>Fri, 19 Dec 2025 17:19:50 +0000</pubDate>
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
<enclosure url="https://github.com/steipete/clawdis/releases/download/v2.0.0-beta1/Clawdis-2.0.0-beta1.zip"
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
sparkle:edSignature="oEpGD46U4ZyBBSY9/piUIFDJU+KlFB751JIWOW2yS0sRNHKszyG5khDHg9o9bV9Zo8DOCNF/HOi88jmtHJAaCQ=="
|
||||||
</ul>
|
sparkle:version="2.0.0-beta1"
|
||||||
<h4>Browser / Control UI</h4>
|
sparkle:shortVersionString="2.0.0-beta1"
|
||||||
<ul>
|
length="72410016"
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
type="application/octet-stream" />
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
</item>
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
</channel>
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
</rss>
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Plugins</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
|
||||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Security</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
|
||||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Onboarding / Daemon</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
|
||||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Auth / Usage / Config</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
|
||||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
|
||||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
|
||||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
|
||||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
|
||||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Docs</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
|
||||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
|
||||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
|
||||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
|
||||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
|
||||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
|
||||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<h4>CLI / Onboarding</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Control UI / TUI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
|
||||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>macOS / Apps</h4>
|
|
||||||
<ul>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
|
||||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels / Messaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
|
||||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
|
||||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
|
||||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
|
||||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
|
||||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
|
||||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
|
||||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
|
||||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
|
||||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
|
||||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
|
||||||
<li>WhatsApp: harden owner command auth.</li>
|
|
||||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Config / Doctor / Packaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
|
||||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.12-2</title>
|
|
||||||
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5534</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<title>2026.1.11-3</title>
|
|
||||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5212</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
|
||||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
|
||||||
</rss>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## Clawdbot Node (Android) (internal)
|
## Clawdis Node (Android) (internal)
|
||||||
|
|
||||||
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdis-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
|
||||||
@@ -25,7 +25,7 @@ cd apps/android
|
|||||||
|
|
||||||
1) Start the gateway (on your “master” machine):
|
1) Start the gateway (on your “master” machine):
|
||||||
```bash
|
```bash
|
||||||
pnpm clawdbot gateway --port 18789 --verbose
|
pnpm clawdis gateway --port 18789 --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
2) In the Android app:
|
2) In the Android app:
|
||||||
@@ -34,8 +34,8 @@ pnpm clawdbot gateway --port 18789 --verbose
|
|||||||
|
|
||||||
3) Approve pairing (on the gateway machine):
|
3) Approve pairing (on the gateway machine):
|
||||||
```bash
|
```bash
|
||||||
clawdbot nodes pending
|
clawdis nodes pending
|
||||||
clawdbot nodes approve <requestId>
|
clawdis nodes approve <requestId>
|
||||||
```
|
```
|
||||||
|
|
||||||
More details: `docs/android/connect.md`.
|
More details: `docs/android/connect.md`.
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import com.android.build.api.variant.impl.VariantOutputImpl
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -8,21 +6,21 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.clawdbot.android"
|
namespace = "com.steipete.clawdis.node"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
assets.srcDir(file("../../shared/ClawdbotKit/Sources/ClawdbotKit/Resources"))
|
assets.srcDir(file("../../shared/ClawdisKit/Sources/ClawdisKit/Resources"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.clawdbot.android"
|
applicationId = "com.steipete.clawdis.node"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202601114
|
versionCode = 1
|
||||||
versionName = "2026.1.11-4"
|
versionName = "0.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -33,7 +31,6 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -49,31 +46,12 @@ android {
|
|||||||
|
|
||||||
lint {
|
lint {
|
||||||
disable += setOf("IconLauncherShape")
|
disable += setOf("IconLauncherShape")
|
||||||
warningsAsErrors = true
|
|
||||||
}
|
|
||||||
|
|
||||||
testOptions {
|
|
||||||
unitTests.isIncludeAndroidResources = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
androidComponents {
|
|
||||||
onVariants { variant ->
|
|
||||||
variant.outputs
|
|
||||||
.filterIsInstance<VariantOutputImpl>()
|
|
||||||
.forEach { output ->
|
|
||||||
val versionName = output.versionName.orNull ?: "0"
|
|
||||||
val buildType = variant.buildType
|
|
||||||
|
|
||||||
val outputFileName = "clawdbot-${versionName}-${buildType}.apk"
|
|
||||||
output.outputFileName = outputFileName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
allWarningsAsErrors.set(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +63,6 @@ dependencies {
|
|||||||
implementation("androidx.core:core-ktx:1.17.0")
|
implementation("androidx.core:core-ktx:1.17.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.activity:activity-compose:1.12.2")
|
implementation("androidx.activity:activity-compose:1.12.2")
|
||||||
implementation("androidx.webkit:webkit:1.15.0")
|
|
||||||
|
|
||||||
implementation("androidx.compose.ui:ui")
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
@@ -102,7 +79,6 @@ dependencies {
|
|||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||||
|
|
||||||
implementation("androidx.security:security-crypto:1.1.0")
|
implementation("androidx.security:security-crypto:1.1.0")
|
||||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
|
||||||
|
|
||||||
// CameraX (for node.invoke camera.* parity)
|
// CameraX (for node.invoke camera.* parity)
|
||||||
implementation("androidx.camera:camera-core:1.5.2")
|
implementation("androidx.camera:camera-core:1.5.2")
|
||||||
@@ -116,12 +92,4 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.0.7")
|
|
||||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.0.7")
|
|
||||||
testImplementation("org.robolectric:robolectric:4.16")
|
|
||||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.2")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
}
|
||||||
@@ -9,18 +9,17 @@
|
|||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||||
android:usesPermissionFlags="neverForLocation" />
|
android:usesPermissionFlags="neverForLocation" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
|
||||||
android:name="android.hardware.telephony"
|
|
||||||
android:required="false" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".NodeApp"
|
android:name=".NodeApp"
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:theme="@style/Theme.ClawdbotNode">
|
android:theme="@style/Theme.ClawdisNode">
|
||||||
<service
|
<service
|
||||||
android:name=".NodeForegroundService"
|
android:name=".NodeForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.clawdbot.android
|
|
||||||
|
|
||||||
enum class LocationMode(val rawValue: String) {
|
|
||||||
Off("off"),
|
|
||||||
WhileUsing("whileUsing"),
|
|
||||||
Always("always"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromRawValue(raw: String?): LocationMode {
|
|
||||||
val normalized = raw?.trim()?.lowercase()
|
|
||||||
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package com.clawdbot.android
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.os.StrictMode
|
|
||||||
|
|
||||||
class NodeApp : Application() {
|
|
||||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
StrictMode.setThreadPolicy(
|
|
||||||
StrictMode.ThreadPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
StrictMode.setVmPolicy(
|
|
||||||
StrictMode.VmPolicy.Builder()
|
|
||||||
.detectAll()
|
|
||||||
.penaltyLog()
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.clawdbot.android
|
|
||||||
|
|
||||||
internal fun normalizeMainKey(raw: String?): String {
|
|
||||||
val trimmed = raw?.trim()
|
|
||||||
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return false
|
|
||||||
if (trimmed == "global") return true
|
|
||||||
return trimmed.startsWith("agent:")
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package com.clawdbot.android.bridge
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.BufferedWriter
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.io.OutputStreamWriter
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
|
|
||||||
class BridgePairingClient {
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
data class Hello(
|
|
||||||
val nodeId: String,
|
|
||||||
val displayName: String?,
|
|
||||||
val token: String?,
|
|
||||||
val platform: String?,
|
|
||||||
val version: String?,
|
|
||||||
val deviceFamily: String?,
|
|
||||||
val modelIdentifier: String?,
|
|
||||||
val caps: List<String>?,
|
|
||||||
val commands: List<String>?,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
|
||||||
|
|
||||||
suspend fun pairAndHello(
|
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams? = null,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)? = null,
|
|
||||||
): PairResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
if (tls != null) {
|
|
||||||
try {
|
|
||||||
return@withContext pairAndHelloWithTls(endpoint, hello, tls, onTlsFingerprint)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (tls.required) throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pairAndHelloWithTls(endpoint, hello, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pairAndHelloWithTls(
|
|
||||||
endpoint: BridgeEndpoint,
|
|
||||||
hello: Hello,
|
|
||||||
tls: BridgeTlsParams?,
|
|
||||||
onTlsFingerprint: ((String) -> Unit)?,
|
|
||||||
): PairResult {
|
|
||||||
val socket =
|
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
|
||||||
onTlsFingerprint?.invoke(fingerprint)
|
|
||||||
}
|
|
||||||
socket.tcpNoDelay = true
|
|
||||||
try {
|
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
|
||||||
socket.soTimeout = 60_000
|
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
|
||||||
|
|
||||||
fun send(line: String) {
|
|
||||||
writer.write(line)
|
|
||||||
writer.write("\n")
|
|
||||||
writer.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendJson(obj: JsonObject) = send(obj.toString())
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("hello"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
|
||||||
?: return PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
return when (firstObj["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
|
||||||
"error" -> {
|
|
||||||
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
|
||||||
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
|
||||||
return PairResult(ok = false, token = null, error = "$code: $message")
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("pair-request"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val nextLine = reader.readLine() ?: break
|
|
||||||
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
|
||||||
when (next["type"].asStringOrNull()) {
|
|
||||||
"pair-ok" -> {
|
|
||||||
val token = next["token"].asStringOrNull()
|
|
||||||
return PairResult(ok = !token.isNullOrBlank(), token = token)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
|
||||||
return PairResult(ok = false, token = null, error = "$c: $m")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PairResult(ok = false, token = null, error = "pairing failed")
|
|
||||||
}
|
|
||||||
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val message = e.message?.trim().orEmpty().ifEmpty { "gateway unreachable" }
|
|
||||||
return PairResult(ok = false, token = null, error = message)
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
socket.close()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? =
|
|
||||||
when (this) {
|
|
||||||
is JsonNull -> null
|
|
||||||
is JsonPrimitive -> content
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package com.clawdbot.android.bridge
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import java.net.Socket
|
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.CertificateException
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
data class BridgeTlsParams(
|
|
||||||
val required: Boolean,
|
|
||||||
val expectedFingerprint: String?,
|
|
||||||
val allowTOFU: Boolean,
|
|
||||||
val stableId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun createBridgeSocket(params: BridgeTlsParams?, onStore: ((String) -> Unit)? = null): Socket {
|
|
||||||
if (params == null) return Socket()
|
|
||||||
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
|
|
||||||
val defaultTrust = defaultTrustManager()
|
|
||||||
@SuppressLint("CustomX509TrustManager")
|
|
||||||
val trustManager =
|
|
||||||
object : X509TrustManager {
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
defaultTrust.checkClientTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
if (chain.isEmpty()) throw CertificateException("empty certificate chain")
|
|
||||||
val fingerprint = sha256Hex(chain[0].encoded)
|
|
||||||
if (expected != null) {
|
|
||||||
if (fingerprint != expected) {
|
|
||||||
throw CertificateException("bridge TLS fingerprint mismatch")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (params.allowTOFU) {
|
|
||||||
onStore?.invoke(fingerprint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defaultTrust.checkServerTrusted(chain, authType)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> = defaultTrust.acceptedIssuers
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = SSLContext.getInstance("TLS")
|
|
||||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
|
||||||
return context.socketFactory.createSocket()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startTlsHandshakeIfNeeded(socket: Socket) {
|
|
||||||
if (socket is SSLSocket) {
|
|
||||||
socket.startHandshake()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun defaultTrustManager(): X509TrustManager {
|
|
||||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
|
||||||
factory.init(null as java.security.KeyStore?)
|
|
||||||
val trust =
|
|
||||||
factory.trustManagers.firstOrNull { it is X509TrustManager } as? X509TrustManager
|
|
||||||
return trust ?: throw IllegalStateException("No default X509TrustManager found")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sha256Hex(data: ByteArray): String {
|
|
||||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
|
||||||
val out = StringBuilder(digest.size * 2)
|
|
||||||
for (byte in digest) {
|
|
||||||
out.append(String.format("%02x", byte))
|
|
||||||
}
|
|
||||||
return out.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeFingerprint(raw: String): String {
|
|
||||||
return raw.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package com.clawdbot.android.node
|
|
||||||
|
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
internal data class JpegSizeLimiterResult(
|
|
||||||
val bytes: ByteArray,
|
|
||||||
val width: Int,
|
|
||||||
val height: Int,
|
|
||||||
val quality: Int,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal object JpegSizeLimiter {
|
|
||||||
fun compressToLimit(
|
|
||||||
initialWidth: Int,
|
|
||||||
initialHeight: Int,
|
|
||||||
startQuality: Int,
|
|
||||||
maxBytes: Int,
|
|
||||||
minQuality: Int = 20,
|
|
||||||
minSize: Int = 256,
|
|
||||||
scaleStep: Double = 0.85,
|
|
||||||
maxScaleAttempts: Int = 6,
|
|
||||||
maxQualityAttempts: Int = 6,
|
|
||||||
encode: (width: Int, height: Int, quality: Int) -> ByteArray,
|
|
||||||
): JpegSizeLimiterResult {
|
|
||||||
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
|
|
||||||
require(maxBytes > 0) { "Invalid maxBytes" }
|
|
||||||
|
|
||||||
var width = initialWidth
|
|
||||||
var height = initialHeight
|
|
||||||
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
|
|
||||||
var best = JpegSizeLimiterResult(bytes = encode(width, height, clampedStartQuality), width = width, height = height, quality = clampedStartQuality)
|
|
||||||
if (best.bytes.size <= maxBytes) return best
|
|
||||||
|
|
||||||
repeat(maxScaleAttempts) {
|
|
||||||
var quality = clampedStartQuality
|
|
||||||
repeat(maxQualityAttempts) {
|
|
||||||
val bytes = encode(width, height, quality)
|
|
||||||
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
|
|
||||||
if (bytes.size <= maxBytes) return best
|
|
||||||
if (quality <= minQuality) return@repeat
|
|
||||||
quality = max(minQuality, (quality * 0.75).roundToInt())
|
|
||||||
}
|
|
||||||
|
|
||||||
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
|
|
||||||
val nextScale = max(scaleStep, minScale)
|
|
||||||
val nextWidth = max(minSize, (width * nextScale).roundToInt())
|
|
||||||
val nextHeight = max(minSize, (height * nextScale).roundToInt())
|
|
||||||
if (nextWidth == width && nextHeight == height) return@repeat
|
|
||||||
width = min(nextWidth, width)
|
|
||||||
height = min(nextHeight, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (best.bytes.size > maxBytes) {
|
|
||||||
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
|
|
||||||
}
|
|
||||||
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package com.clawdbot.android.node
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.location.Location
|
|
||||||
import android.location.LocationManager
|
|
||||||
import android.os.CancellationSignal
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.resumeWithException
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
|
|
||||||
class LocationCaptureManager(private val context: Context) {
|
|
||||||
data class Payload(val payloadJson: String)
|
|
||||||
|
|
||||||
suspend fun getLocation(
|
|
||||||
desiredProviders: List<String>,
|
|
||||||
maxAgeMs: Long?,
|
|
||||||
timeoutMs: Long,
|
|
||||||
isPrecise: Boolean,
|
|
||||||
): Payload =
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
val manager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
|
||||||
if (!manager.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
|
|
||||||
!manager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
|
||||||
) {
|
|
||||||
throw IllegalStateException("LOCATION_UNAVAILABLE: no location providers enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
val cached = bestLastKnown(manager, desiredProviders, maxAgeMs)
|
|
||||||
val location =
|
|
||||||
cached ?: requestCurrent(manager, desiredProviders, timeoutMs)
|
|
||||||
|
|
||||||
val timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(location.time))
|
|
||||||
val source = location.provider
|
|
||||||
val altitudeMeters = if (location.hasAltitude()) location.altitude else null
|
|
||||||
val speedMps = if (location.hasSpeed()) location.speed.toDouble() else null
|
|
||||||
val headingDeg = if (location.hasBearing()) location.bearing.toDouble() else null
|
|
||||||
Payload(
|
|
||||||
buildString {
|
|
||||||
append("{\"lat\":")
|
|
||||||
append(location.latitude)
|
|
||||||
append(",\"lon\":")
|
|
||||||
append(location.longitude)
|
|
||||||
append(",\"accuracyMeters\":")
|
|
||||||
append(location.accuracy.toDouble())
|
|
||||||
if (altitudeMeters != null) append(",\"altitudeMeters\":").append(altitudeMeters)
|
|
||||||
if (speedMps != null) append(",\"speedMps\":").append(speedMps)
|
|
||||||
if (headingDeg != null) append(",\"headingDeg\":").append(headingDeg)
|
|
||||||
append(",\"timestamp\":\"").append(timestamp).append('"')
|
|
||||||
append(",\"isPrecise\":").append(isPrecise)
|
|
||||||
append(",\"source\":\"").append(source).append('"')
|
|
||||||
append('}')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bestLastKnown(
|
|
||||||
manager: LocationManager,
|
|
||||||
providers: List<String>,
|
|
||||||
maxAgeMs: Long?,
|
|
||||||
): Location? {
|
|
||||||
val fineOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
val coarseOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!fineOk && !coarseOk) {
|
|
||||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
|
||||||
}
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val candidates =
|
|
||||||
providers.mapNotNull { provider -> manager.getLastKnownLocation(provider) }
|
|
||||||
val freshest = candidates.maxByOrNull { it.time } ?: return null
|
|
||||||
if (maxAgeMs != null && now - freshest.time > maxAgeMs) return null
|
|
||||||
return freshest
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun requestCurrent(
|
|
||||||
manager: LocationManager,
|
|
||||||
providers: List<String>,
|
|
||||||
timeoutMs: Long,
|
|
||||||
): Location {
|
|
||||||
val fineOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
val coarseOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!fineOk && !coarseOk) {
|
|
||||||
throw IllegalStateException("LOCATION_PERMISSION_REQUIRED: grant Location permission")
|
|
||||||
}
|
|
||||||
val resolved =
|
|
||||||
providers.firstOrNull { manager.isProviderEnabled(it) }
|
|
||||||
?: throw IllegalStateException("LOCATION_UNAVAILABLE: no providers available")
|
|
||||||
return withTimeout(timeoutMs.coerceAtLeast(1)) {
|
|
||||||
suspendCancellableCoroutine { cont ->
|
|
||||||
val signal = CancellationSignal()
|
|
||||||
cont.invokeOnCancellation { signal.cancel() }
|
|
||||||
manager.getCurrentLocation(resolved, signal, context.mainExecutor) { location ->
|
|
||||||
if (location != null) {
|
|
||||||
cont.resume(location)
|
|
||||||
} else {
|
|
||||||
cont.resumeWithException(IllegalStateException("LOCATION_UNAVAILABLE: no fix"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
package com.clawdbot.android.node
|
|
||||||
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.telephony.SmsManager as AndroidSmsManager
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import com.clawdbot.android.PermissionRequester
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends SMS messages via the Android SMS API.
|
|
||||||
* Requires SEND_SMS permission to be granted.
|
|
||||||
*/
|
|
||||||
class SmsManager(private val context: Context) {
|
|
||||||
|
|
||||||
private val json = JsonConfig
|
|
||||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
|
||||||
|
|
||||||
data class SendResult(
|
|
||||||
val ok: Boolean,
|
|
||||||
val to: String,
|
|
||||||
val message: String?,
|
|
||||||
val error: String? = null,
|
|
||||||
val payloadJson: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal data class ParsedParams(
|
|
||||||
val to: String,
|
|
||||||
val message: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal sealed class ParseResult {
|
|
||||||
data class Ok(val params: ParsedParams) : ParseResult()
|
|
||||||
data class Error(
|
|
||||||
val error: String,
|
|
||||||
val to: String = "",
|
|
||||||
val message: String? = null,
|
|
||||||
) : ParseResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal data class SendPlan(
|
|
||||||
val parts: List<String>,
|
|
||||||
val useMultipart: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
internal val JsonConfig = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
internal fun parseParams(paramsJson: String?, json: Json = JsonConfig): ParseResult {
|
|
||||||
val params = paramsJson?.trim().orEmpty()
|
|
||||||
if (params.isEmpty()) {
|
|
||||||
return ParseResult.Error(error = "INVALID_REQUEST: paramsJSON required")
|
|
||||||
}
|
|
||||||
|
|
||||||
val obj = try {
|
|
||||||
json.parseToJsonElement(params).jsonObject
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (obj == null) {
|
|
||||||
return ParseResult.Error(error = "INVALID_REQUEST: expected JSON object")
|
|
||||||
}
|
|
||||||
|
|
||||||
val to = (obj["to"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
|
||||||
val message = (obj["message"] as? JsonPrimitive)?.content.orEmpty()
|
|
||||||
|
|
||||||
if (to.isEmpty()) {
|
|
||||||
return ParseResult.Error(
|
|
||||||
error = "INVALID_REQUEST: 'to' phone number required",
|
|
||||||
message = message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
return ParseResult.Error(
|
|
||||||
error = "INVALID_REQUEST: 'message' text required",
|
|
||||||
to = to,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ParseResult.Ok(ParsedParams(to = to, message = message))
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun buildSendPlan(
|
|
||||||
message: String,
|
|
||||||
divider: (String) -> List<String>,
|
|
||||||
): SendPlan {
|
|
||||||
val parts = divider(message).ifEmpty { listOf(message) }
|
|
||||||
return SendPlan(parts = parts, useMultipart = parts.size > 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun buildPayloadJson(
|
|
||||||
json: Json = JsonConfig,
|
|
||||||
ok: Boolean,
|
|
||||||
to: String,
|
|
||||||
error: String?,
|
|
||||||
): String {
|
|
||||||
val payload =
|
|
||||||
mutableMapOf<String, JsonElement>(
|
|
||||||
"ok" to JsonPrimitive(ok),
|
|
||||||
"to" to JsonPrimitive(to),
|
|
||||||
)
|
|
||||||
if (!ok) {
|
|
||||||
payload["error"] = JsonPrimitive(error ?: "SMS_SEND_FAILED")
|
|
||||||
}
|
|
||||||
return json.encodeToString(JsonObject.serializer(), JsonObject(payload))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasSmsPermission(): Boolean {
|
|
||||||
return ContextCompat.checkSelfPermission(
|
|
||||||
context,
|
|
||||||
Manifest.permission.SEND_SMS
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canSendSms(): Boolean {
|
|
||||||
return hasSmsPermission() && hasTelephonyFeature()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasTelephonyFeature(): Boolean {
|
|
||||||
return context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attachPermissionRequester(requester: PermissionRequester) {
|
|
||||||
permissionRequester = requester
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an SMS message.
|
|
||||||
*
|
|
||||||
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
|
|
||||||
* @return SendResult indicating success or failure
|
|
||||||
*/
|
|
||||||
suspend fun send(paramsJson: String?): SendResult {
|
|
||||||
if (!hasTelephonyFeature()) {
|
|
||||||
return errorResult(
|
|
||||||
error = "SMS_UNAVAILABLE: telephony not available",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ensureSmsPermission()) {
|
|
||||||
return errorResult(
|
|
||||||
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val parseResult = parseParams(paramsJson, json)
|
|
||||||
if (parseResult is ParseResult.Error) {
|
|
||||||
return errorResult(
|
|
||||||
error = parseResult.error,
|
|
||||||
to = parseResult.to,
|
|
||||||
message = parseResult.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val params = (parseResult as ParseResult.Ok).params
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val smsManager = context.getSystemService(AndroidSmsManager::class.java)
|
|
||||||
?: throw IllegalStateException("SMS_UNAVAILABLE: SmsManager not available")
|
|
||||||
|
|
||||||
val plan = buildSendPlan(params.message) { smsManager.divideMessage(it) }
|
|
||||||
if (plan.useMultipart) {
|
|
||||||
smsManager.sendMultipartTextMessage(
|
|
||||||
params.to, // destination
|
|
||||||
null, // service center (null = default)
|
|
||||||
ArrayList(plan.parts), // message parts
|
|
||||||
null, // sent intents
|
|
||||||
null, // delivery intents
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
smsManager.sendTextMessage(
|
|
||||||
params.to, // destination
|
|
||||||
null, // service center (null = default)
|
|
||||||
params.message,// message
|
|
||||||
null, // sent intent
|
|
||||||
null, // delivery intent
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
okResult(to = params.to, message = params.message)
|
|
||||||
} catch (e: SecurityException) {
|
|
||||||
errorResult(
|
|
||||||
error = "SMS_PERMISSION_REQUIRED: ${e.message}",
|
|
||||||
to = params.to,
|
|
||||||
message = params.message,
|
|
||||||
)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
errorResult(
|
|
||||||
error = "SMS_SEND_FAILED: ${e.message ?: "unknown error"}",
|
|
||||||
to = params.to,
|
|
||||||
message = params.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun ensureSmsPermission(): Boolean {
|
|
||||||
if (hasSmsPermission()) return true
|
|
||||||
val requester = permissionRequester ?: return false
|
|
||||||
val results = requester.requestIfMissing(listOf(Manifest.permission.SEND_SMS))
|
|
||||||
return results[Manifest.permission.SEND_SMS] == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun okResult(to: String, message: String): SendResult {
|
|
||||||
return SendResult(
|
|
||||||
ok = true,
|
|
||||||
to = to,
|
|
||||||
message = message,
|
|
||||||
error = null,
|
|
||||||
payloadJson = buildPayloadJson(json = json, ok = true, to = to, error = null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun errorResult(error: String, to: String = "", message: String? = null): SendResult {
|
|
||||||
return SendResult(
|
|
||||||
ok = false,
|
|
||||||
to = to,
|
|
||||||
message = message,
|
|
||||||
error = error,
|
|
||||||
payloadJson = buildPayloadJson(json = json, ok = false, to = to, error = error),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
package com.clawdbot.android.tools
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class ToolDisplayActionSpec(
|
|
||||||
val label: String? = null,
|
|
||||||
val detailKeys: List<String>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class ToolDisplaySpec(
|
|
||||||
val emoji: String? = null,
|
|
||||||
val title: String? = null,
|
|
||||||
val label: String? = null,
|
|
||||||
val detailKeys: List<String>? = null,
|
|
||||||
val actions: Map<String, ToolDisplayActionSpec>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class ToolDisplayConfig(
|
|
||||||
val version: Int? = null,
|
|
||||||
val fallback: ToolDisplaySpec? = null,
|
|
||||||
val tools: Map<String, ToolDisplaySpec>? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ToolDisplaySummary(
|
|
||||||
val name: String,
|
|
||||||
val emoji: String,
|
|
||||||
val title: String,
|
|
||||||
val label: String,
|
|
||||||
val verb: String?,
|
|
||||||
val detail: String?,
|
|
||||||
) {
|
|
||||||
val detailLine: String?
|
|
||||||
get() {
|
|
||||||
val parts = mutableListOf<String>()
|
|
||||||
if (!verb.isNullOrBlank()) parts.add(verb)
|
|
||||||
if (!detail.isNullOrBlank()) parts.add(detail)
|
|
||||||
return if (parts.isEmpty()) null else parts.joinToString(" · ")
|
|
||||||
}
|
|
||||||
|
|
||||||
val summaryLine: String
|
|
||||||
get() = if (detailLine != null) "${emoji} ${label}: ${detailLine}" else "${emoji} ${label}"
|
|
||||||
}
|
|
||||||
|
|
||||||
object ToolDisplayRegistry {
|
|
||||||
private const val CONFIG_ASSET = "tool-display.json"
|
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
|
||||||
@Volatile private var cachedConfig: ToolDisplayConfig? = null
|
|
||||||
|
|
||||||
fun resolve(
|
|
||||||
context: Context,
|
|
||||||
name: String?,
|
|
||||||
args: JsonObject?,
|
|
||||||
meta: String? = null,
|
|
||||||
): ToolDisplaySummary {
|
|
||||||
val trimmedName = name?.trim().orEmpty().ifEmpty { "tool" }
|
|
||||||
val key = trimmedName.lowercase()
|
|
||||||
val config = loadConfig(context)
|
|
||||||
val spec = config.tools?.get(key)
|
|
||||||
val fallback = config.fallback
|
|
||||||
|
|
||||||
val emoji = spec?.emoji ?: fallback?.emoji ?: "🧩"
|
|
||||||
val title = spec?.title ?: titleFromName(trimmedName)
|
|
||||||
val label = spec?.label ?: trimmedName
|
|
||||||
|
|
||||||
val actionRaw = args?.get("action")?.asStringOrNull()?.trim()
|
|
||||||
val action = actionRaw?.takeIf { it.isNotEmpty() }
|
|
||||||
val actionSpec = action?.let { spec?.actions?.get(it) }
|
|
||||||
val verb = normalizeVerb(actionSpec?.label ?: action)
|
|
||||||
|
|
||||||
var detail: String? = null
|
|
||||||
if (key == "read") {
|
|
||||||
detail = readDetail(args)
|
|
||||||
} else if (key == "write" || key == "edit" || key == "attach") {
|
|
||||||
detail = pathDetail(args)
|
|
||||||
}
|
|
||||||
|
|
||||||
val detailKeys = actionSpec?.detailKeys ?: spec?.detailKeys ?: fallback?.detailKeys ?: emptyList()
|
|
||||||
if (detail == null) {
|
|
||||||
detail = firstValue(args, detailKeys)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail == null) {
|
|
||||||
detail = meta
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detail != null) {
|
|
||||||
detail = shortenHomeInString(detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ToolDisplaySummary(
|
|
||||||
name = trimmedName,
|
|
||||||
emoji = emoji,
|
|
||||||
title = title,
|
|
||||||
label = label,
|
|
||||||
verb = verb,
|
|
||||||
detail = detail,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadConfig(context: Context): ToolDisplayConfig {
|
|
||||||
val existing = cachedConfig
|
|
||||||
if (existing != null) return existing
|
|
||||||
return try {
|
|
||||||
val jsonString = context.assets.open(CONFIG_ASSET).bufferedReader().use { it.readText() }
|
|
||||||
val decoded = json.decodeFromString(ToolDisplayConfig.serializer(), jsonString)
|
|
||||||
cachedConfig = decoded
|
|
||||||
decoded
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
val fallback = ToolDisplayConfig()
|
|
||||||
cachedConfig = fallback
|
|
||||||
fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun titleFromName(name: String): String {
|
|
||||||
val cleaned = name.replace("_", " ").trim()
|
|
||||||
if (cleaned.isEmpty()) return "Tool"
|
|
||||||
return cleaned
|
|
||||||
.split(Regex("\\s+"))
|
|
||||||
.joinToString(" ") { part ->
|
|
||||||
val upper = part.uppercase()
|
|
||||||
if (part.length <= 2 && part == upper) part
|
|
||||||
else upper.firstOrNull()?.toString().orEmpty() + part.lowercase().drop(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeVerb(value: String?): String? {
|
|
||||||
val trimmed = value?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
return trimmed.replace("_", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readDetail(args: JsonObject?): String? {
|
|
||||||
val path = args?.get("path")?.asStringOrNull() ?: return null
|
|
||||||
val offset = args["offset"].asNumberOrNull()
|
|
||||||
val limit = args["limit"].asNumberOrNull()
|
|
||||||
return if (offset != null && limit != null) {
|
|
||||||
val end = offset + limit
|
|
||||||
"${path}:${offset.toInt()}-${end.toInt()}"
|
|
||||||
} else {
|
|
||||||
path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pathDetail(args: JsonObject?): String? {
|
|
||||||
return args?.get("path")?.asStringOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun firstValue(args: JsonObject?, keys: List<String>): String? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = valueForPath(args, key)
|
|
||||||
val rendered = renderValue(value)
|
|
||||||
if (!rendered.isNullOrBlank()) return rendered
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun valueForPath(args: JsonObject?, path: String): JsonElement? {
|
|
||||||
var current: JsonElement? = args
|
|
||||||
for (segment in path.split(".")) {
|
|
||||||
if (segment.isBlank()) return null
|
|
||||||
val obj = current as? JsonObject ?: return null
|
|
||||||
current = obj[segment]
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun renderValue(value: JsonElement?): String? {
|
|
||||||
if (value == null) return null
|
|
||||||
if (value is JsonPrimitive) {
|
|
||||||
if (value.isString) {
|
|
||||||
val trimmed = value.contentOrNull?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
val firstLine = trimmed.lineSequence().firstOrNull()?.trim().orEmpty()
|
|
||||||
if (firstLine.isEmpty()) return null
|
|
||||||
return if (firstLine.length > 160) "${firstLine.take(157)}…" else firstLine
|
|
||||||
}
|
|
||||||
val raw = value.contentOrNull?.trim().orEmpty()
|
|
||||||
raw.toBooleanStrictOrNull()?.let { return it.toString() }
|
|
||||||
raw.toLongOrNull()?.let { return it.toString() }
|
|
||||||
raw.toDoubleOrNull()?.let { return it.toString() }
|
|
||||||
}
|
|
||||||
if (value is JsonArray) {
|
|
||||||
val items = value.mapNotNull { renderValue(it) }
|
|
||||||
if (items.isEmpty()) return null
|
|
||||||
val preview = items.take(3).joinToString(", ")
|
|
||||||
return if (items.size > 3) "${preview}…" else preview
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun shortenHomeInString(value: String): String {
|
|
||||||
val home = System.getProperty("user.home")?.takeIf { it.isNotBlank() }
|
|
||||||
?: System.getenv("HOME")?.takeIf { it.isNotBlank() }
|
|
||||||
if (home.isNullOrEmpty()) return value
|
|
||||||
return value.replace(home, "~")
|
|
||||||
.replace(Regex("/Users/[^/]+"), "~")
|
|
||||||
.replace(Regex("/home/[^/]+"), "~")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
return if (primitive.isString) primitive.contentOrNull else primitive.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asNumberOrNull(): Double? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
val raw = primitive.contentOrNull ?: return null
|
|
||||||
return raw.toDoubleOrNull()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.clawdbot.android.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CameraFlashOverlay(
|
|
||||||
token: Long,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
|
||||||
CameraFlash(token = token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CameraFlash(token: Long) {
|
|
||||||
var alpha by remember { mutableFloatStateOf(0f) }
|
|
||||||
LaunchedEffect(token) {
|
|
||||||
if (token == 0L) return@LaunchedEffect
|
|
||||||
alpha = 0.85f
|
|
||||||
delay(110)
|
|
||||||
alpha = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(alpha)
|
|
||||||
.background(Color.White),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
package com.clawdbot.android.ui
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.Manifest
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.View
|
|
||||||
import android.webkit.JavascriptInterface
|
|
||||||
import android.webkit.ConsoleMessage
|
|
||||||
import android.webkit.WebChromeClient
|
|
||||||
import android.webkit.WebView
|
|
||||||
import android.webkit.WebSettings
|
|
||||||
import android.webkit.WebResourceError
|
|
||||||
import android.webkit.WebResourceRequest
|
|
||||||
import android.webkit.WebResourceResponse
|
|
||||||
import android.webkit.WebViewClient
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.webkit.WebSettingsCompat
|
|
||||||
import androidx.webkit.WebViewFeature
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
|
||||||
import androidx.compose.foundation.layout.only
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
|
||||||
import androidx.compose.material.icons.filled.ChatBubble
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Error
|
|
||||||
import androidx.compose.material.icons.filled.FiberManualRecord
|
|
||||||
import androidx.compose.material.icons.filled.PhotoCamera
|
|
||||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material.icons.filled.Report
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color as ComposeColor
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
|
||||||
import androidx.compose.ui.window.Popup
|
|
||||||
import androidx.compose.ui.window.PopupProperties
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.clawdbot.android.CameraHudKind
|
|
||||||
import com.clawdbot.android.MainViewModel
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun RootScreen(viewModel: MainViewModel) {
|
|
||||||
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
|
||||||
val context = LocalContext.current
|
|
||||||
val serverName by viewModel.serverName.collectAsState()
|
|
||||||
val statusText by viewModel.statusText.collectAsState()
|
|
||||||
val cameraHud by viewModel.cameraHud.collectAsState()
|
|
||||||
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
|
||||||
val screenRecordActive by viewModel.screenRecordActive.collectAsState()
|
|
||||||
val isForeground by viewModel.isForeground.collectAsState()
|
|
||||||
val voiceWakeStatusText by viewModel.voiceWakeStatusText.collectAsState()
|
|
||||||
val talkEnabled by viewModel.talkEnabled.collectAsState()
|
|
||||||
val talkStatusText by viewModel.talkStatusText.collectAsState()
|
|
||||||
val talkIsListening by viewModel.talkIsListening.collectAsState()
|
|
||||||
val talkIsSpeaking by viewModel.talkIsSpeaking.collectAsState()
|
|
||||||
val seamColorArgb by viewModel.seamColorArgb.collectAsState()
|
|
||||||
val seamColor = remember(seamColorArgb) { ComposeColor(seamColorArgb) }
|
|
||||||
val audioPermissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) viewModel.setTalkEnabled(true)
|
|
||||||
}
|
|
||||||
val activity =
|
|
||||||
remember(cameraHud, screenRecordActive, isForeground, statusText, voiceWakeStatusText) {
|
|
||||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
|
||||||
if (!isForeground) {
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Foreground required",
|
|
||||||
icon = Icons.Default.Report,
|
|
||||||
contentDescription = "Foreground required",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val lowerStatus = statusText.lowercase()
|
|
||||||
if (lowerStatus.contains("repair")) {
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Repairing…",
|
|
||||||
icon = Icons.Default.Refresh,
|
|
||||||
contentDescription = "Repairing",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (lowerStatus.contains("pairing") || lowerStatus.contains("approval")) {
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Approval pending",
|
|
||||||
icon = Icons.Default.RecordVoiceOver,
|
|
||||||
contentDescription = "Approval pending",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Avoid duplicating the primary bridge status ("Connecting…") in the activity slot.
|
|
||||||
|
|
||||||
if (screenRecordActive) {
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Recording screen…",
|
|
||||||
icon = Icons.AutoMirrored.Filled.ScreenShare,
|
|
||||||
contentDescription = "Recording screen",
|
|
||||||
tint = androidx.compose.ui.graphics.Color.Red,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraHud?.let { hud ->
|
|
||||||
return@remember when (hud.kind) {
|
|
||||||
CameraHudKind.Photo ->
|
|
||||||
StatusActivity(
|
|
||||||
title = hud.message,
|
|
||||||
icon = Icons.Default.PhotoCamera,
|
|
||||||
contentDescription = "Taking photo",
|
|
||||||
)
|
|
||||||
CameraHudKind.Recording ->
|
|
||||||
StatusActivity(
|
|
||||||
title = hud.message,
|
|
||||||
icon = Icons.Default.FiberManualRecord,
|
|
||||||
contentDescription = "Recording",
|
|
||||||
tint = androidx.compose.ui.graphics.Color.Red,
|
|
||||||
)
|
|
||||||
CameraHudKind.Success ->
|
|
||||||
StatusActivity(
|
|
||||||
title = hud.message,
|
|
||||||
icon = Icons.Default.CheckCircle,
|
|
||||||
contentDescription = "Capture finished",
|
|
||||||
)
|
|
||||||
CameraHudKind.Error ->
|
|
||||||
StatusActivity(
|
|
||||||
title = hud.message,
|
|
||||||
icon = Icons.Default.Error,
|
|
||||||
contentDescription = "Capture failed",
|
|
||||||
tint = androidx.compose.ui.graphics.Color.Red,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (voiceWakeStatusText.contains("Microphone permission", ignoreCase = true)) {
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Mic permission",
|
|
||||||
icon = Icons.Default.Error,
|
|
||||||
contentDescription = "Mic permission required",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (voiceWakeStatusText == "Paused") {
|
|
||||||
val suffix = if (!isForeground) " (background)" else ""
|
|
||||||
return@remember StatusActivity(
|
|
||||||
title = "Voice Wake paused$suffix",
|
|
||||||
icon = Icons.Default.RecordVoiceOver,
|
|
||||||
contentDescription = "Voice Wake paused",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val bridgeState =
|
|
||||||
remember(serverName, statusText) {
|
|
||||||
when {
|
|
||||||
serverName != null -> BridgeState.Connected
|
|
||||||
statusText.contains("connecting", ignoreCase = true) ||
|
|
||||||
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
|
||||||
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
|
||||||
else -> BridgeState.Disconnected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val voiceEnabled =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Camera flash must be in a Popup to render above the WebView.
|
|
||||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
|
||||||
CameraFlashOverlay(token = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
|
||||||
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
|
||||||
StatusPill(
|
|
||||||
bridge = bridgeState,
|
|
||||||
voiceEnabled = voiceEnabled,
|
|
||||||
activity = activity,
|
|
||||||
onClick = { sheet = Sheet.Settings },
|
|
||||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
horizontalAlignment = Alignment.End,
|
|
||||||
) {
|
|
||||||
OverlayIconButton(
|
|
||||||
onClick = { sheet = Sheet.Chat },
|
|
||||||
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Talk mode gets a dedicated side bubble instead of burying it in settings.
|
|
||||||
val baseOverlay = overlayContainerColor()
|
|
||||||
val talkContainer =
|
|
||||||
lerp(
|
|
||||||
baseOverlay,
|
|
||||||
seamColor.copy(alpha = baseOverlay.alpha),
|
|
||||||
if (talkEnabled) 0.35f else 0.22f,
|
|
||||||
)
|
|
||||||
val talkContent = if (talkEnabled) seamColor else overlayIconColor()
|
|
||||||
OverlayIconButton(
|
|
||||||
onClick = {
|
|
||||||
val next = !talkEnabled
|
|
||||||
if (next) {
|
|
||||||
val micOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!micOk) audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
|
||||||
viewModel.setTalkEnabled(true)
|
|
||||||
} else {
|
|
||||||
viewModel.setTalkEnabled(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
containerColor = talkContainer,
|
|
||||||
contentColor = talkContent,
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.RecordVoiceOver,
|
|
||||||
contentDescription = "Talk Mode",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
OverlayIconButton(
|
|
||||||
onClick = { sheet = Sheet.Settings },
|
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (talkEnabled) {
|
|
||||||
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
|
||||||
TalkOrbOverlay(
|
|
||||||
seamColor = seamColor,
|
|
||||||
statusText = talkStatusText,
|
|
||||||
isListening = talkIsListening,
|
|
||||||
isSpeaking = talkIsSpeaking,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentSheet = sheet
|
|
||||||
if (currentSheet != null) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { sheet = null },
|
|
||||||
sheetState = sheetState,
|
|
||||||
) {
|
|
||||||
when (currentSheet) {
|
|
||||||
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
|
||||||
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class Sheet {
|
|
||||||
Chat,
|
|
||||||
Settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun OverlayIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
icon: @Composable () -> Unit,
|
|
||||||
containerColor: ComposeColor? = null,
|
|
||||||
contentColor: ComposeColor? = null,
|
|
||||||
) {
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = Modifier.size(44.dp),
|
|
||||||
colors =
|
|
||||||
IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = containerColor ?: overlayContainerColor(),
|
|
||||||
contentColor = contentColor ?: overlayIconColor(),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
icon()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
@Composable
|
|
||||||
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
||||||
AndroidView(
|
|
||||||
modifier = modifier,
|
|
||||||
factory = {
|
|
||||||
WebView(context).apply {
|
|
||||||
settings.javaScriptEnabled = true
|
|
||||||
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
|
||||||
settings.domStorageEnabled = true
|
|
||||||
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
|
||||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
|
||||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
|
||||||
} else {
|
|
||||||
disableForceDarkIfSupported(settings)
|
|
||||||
}
|
|
||||||
if (isDebuggable) {
|
|
||||||
Log.d("ClawdbotWebView", "userAgent: ${settings.userAgentString}")
|
|
||||||
}
|
|
||||||
isScrollContainer = true
|
|
||||||
overScrollMode = View.OVER_SCROLL_IF_CONTENT_SCROLLS
|
|
||||||
isVerticalScrollBarEnabled = true
|
|
||||||
isHorizontalScrollBarEnabled = true
|
|
||||||
webViewClient =
|
|
||||||
object : WebViewClient() {
|
|
||||||
override fun onReceivedError(
|
|
||||||
view: WebView,
|
|
||||||
request: WebResourceRequest,
|
|
||||||
error: WebResourceError,
|
|
||||||
) {
|
|
||||||
if (!isDebuggable) return
|
|
||||||
if (!request.isForMainFrame) return
|
|
||||||
Log.e("ClawdbotWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceivedHttpError(
|
|
||||||
view: WebView,
|
|
||||||
request: WebResourceRequest,
|
|
||||||
errorResponse: WebResourceResponse,
|
|
||||||
) {
|
|
||||||
if (!isDebuggable) return
|
|
||||||
if (!request.isForMainFrame) return
|
|
||||||
Log.e(
|
|
||||||
"ClawdbotWebView",
|
|
||||||
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView, url: String?) {
|
|
||||||
if (isDebuggable) {
|
|
||||||
Log.d("ClawdbotWebView", "onPageFinished: $url")
|
|
||||||
}
|
|
||||||
viewModel.canvas.onPageFinished()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRenderProcessGone(
|
|
||||||
view: WebView,
|
|
||||||
detail: android.webkit.RenderProcessGoneDetail,
|
|
||||||
): Boolean {
|
|
||||||
if (isDebuggable) {
|
|
||||||
Log.e(
|
|
||||||
"ClawdbotWebView",
|
|
||||||
"onRenderProcessGone didCrash=${detail.didCrash()} priorityAtExit=${detail.rendererPriorityAtExit()}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
webChromeClient =
|
|
||||||
object : WebChromeClient() {
|
|
||||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
|
||||||
if (!isDebuggable) return false
|
|
||||||
val msg = consoleMessage ?: return false
|
|
||||||
Log.d(
|
|
||||||
"ClawdbotWebView",
|
|
||||||
"console ${msg.messageLevel()} @ ${msg.sourceId()}:${msg.lineNumber()} ${msg.message()}",
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Use default layer/background; avoid forcing a black fill over WebView content.
|
|
||||||
|
|
||||||
val a2uiBridge =
|
|
||||||
CanvasA2UIActionBridge { payload ->
|
|
||||||
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
|
||||||
}
|
|
||||||
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
|
||||||
addJavascriptInterface(
|
|
||||||
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
|
||||||
CanvasA2UIActionLegacyBridge.interfaceName,
|
|
||||||
)
|
|
||||||
viewModel.canvas.attach(this)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun disableForceDarkIfSupported(settings: WebSettings) {
|
|
||||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun postMessage(payload: String?) {
|
|
||||||
val msg = payload?.trim().orEmpty()
|
|
||||||
if (msg.isEmpty()) return
|
|
||||||
onMessage(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val interfaceName: String = "clawdbotCanvasA2UIAction"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
|
||||||
@JavascriptInterface
|
|
||||||
fun canvasAction(payload: String?) {
|
|
||||||
bridge.postMessage(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JavascriptInterface
|
|
||||||
fun postMessage(payload: String?) {
|
|
||||||
bridge.postMessage(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val interfaceName: String = "Android"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package com.clawdbot.android.ui
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TalkOrbOverlay(
|
|
||||||
seamColor: Color,
|
|
||||||
statusText: String,
|
|
||||||
isListening: Boolean,
|
|
||||||
isSpeaking: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val transition = rememberInfiniteTransition(label = "talk-orb")
|
|
||||||
val t by
|
|
||||||
transition.animateFloat(
|
|
||||||
initialValue = 0f,
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec =
|
|
||||||
infiniteRepeatable(
|
|
||||||
animation = tween(durationMillis = 1500, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Restart,
|
|
||||||
),
|
|
||||||
label = "pulse",
|
|
||||||
)
|
|
||||||
|
|
||||||
val trimmed = statusText.trim()
|
|
||||||
val showStatus = trimmed.isNotEmpty() && trimmed != "Off"
|
|
||||||
val phase =
|
|
||||||
when {
|
|
||||||
isSpeaking -> "Speaking"
|
|
||||||
isListening -> "Listening"
|
|
||||||
else -> "Thinking"
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Canvas(modifier = Modifier.size(360.dp)) {
|
|
||||||
val center = this.center
|
|
||||||
val baseRadius = size.minDimension * 0.30f
|
|
||||||
|
|
||||||
val ring1 = 1.05f + (t * 0.25f)
|
|
||||||
val ring2 = 1.20f + (t * 0.55f)
|
|
||||||
val ringAlpha1 = (1f - t) * 0.34f
|
|
||||||
val ringAlpha2 = (1f - t) * 0.22f
|
|
||||||
|
|
||||||
drawCircle(
|
|
||||||
color = seamColor.copy(alpha = ringAlpha1),
|
|
||||||
radius = baseRadius * ring1,
|
|
||||||
center = center,
|
|
||||||
style = Stroke(width = 3.dp.toPx()),
|
|
||||||
)
|
|
||||||
drawCircle(
|
|
||||||
color = seamColor.copy(alpha = ringAlpha2),
|
|
||||||
radius = baseRadius * ring2,
|
|
||||||
center = center,
|
|
||||||
style = Stroke(width = 3.dp.toPx()),
|
|
||||||
)
|
|
||||||
|
|
||||||
drawCircle(
|
|
||||||
brush =
|
|
||||||
Brush.radialGradient(
|
|
||||||
colors =
|
|
||||||
listOf(
|
|
||||||
seamColor.copy(alpha = 0.92f),
|
|
||||||
seamColor.copy(alpha = 0.40f),
|
|
||||||
Color.Black.copy(alpha = 0.56f),
|
|
||||||
),
|
|
||||||
center = center,
|
|
||||||
radius = baseRadius * 1.35f,
|
|
||||||
),
|
|
||||||
radius = baseRadius,
|
|
||||||
center = center,
|
|
||||||
)
|
|
||||||
|
|
||||||
drawCircle(
|
|
||||||
color = seamColor.copy(alpha = 0.34f),
|
|
||||||
radius = baseRadius,
|
|
||||||
center = center,
|
|
||||||
style = Stroke(width = 1.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showStatus) {
|
|
||||||
Surface(
|
|
||||||
color = Color.Black.copy(alpha = 0.40f),
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = trimmed,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
|
|
||||||
color = Color.White.copy(alpha = 0.92f),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = phase,
|
|
||||||
color = Color.White.copy(alpha = 0.80f),
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
|
||||||
|
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
|
||||||
|
|
||||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
|
||||||
|
|
||||||
fun resolveSessionChoices(
|
|
||||||
currentSessionKey: String,
|
|
||||||
sessions: List<ChatSessionEntry>,
|
|
||||||
mainSessionKey: String,
|
|
||||||
nowMs: Long = System.currentTimeMillis(),
|
|
||||||
): List<ChatSessionEntry> {
|
|
||||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
|
||||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
|
||||||
val aliasKey = if (mainKey == "main") null else "main"
|
|
||||||
val cutoff = nowMs - RECENT_WINDOW_MS
|
|
||||||
val sorted = sessions.sortedByDescending { it.updatedAtMs ?: 0L }
|
|
||||||
val recent = mutableListOf<ChatSessionEntry>()
|
|
||||||
val seen = mutableSetOf<String>()
|
|
||||||
for (entry in sorted) {
|
|
||||||
if (aliasKey != null && entry.key == aliasKey) continue
|
|
||||||
if (!seen.add(entry.key)) continue
|
|
||||||
if ((entry.updatedAtMs ?: 0L) < cutoff) continue
|
|
||||||
recent.add(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = mutableListOf<ChatSessionEntry>()
|
|
||||||
val included = mutableSetOf<String>()
|
|
||||||
val mainEntry = sorted.firstOrNull { it.key == mainKey }
|
|
||||||
if (mainEntry != null) {
|
|
||||||
result.add(mainEntry)
|
|
||||||
included.add(mainKey)
|
|
||||||
} else if (current == mainKey) {
|
|
||||||
result.add(ChatSessionEntry(key = mainKey, updatedAtMs = null))
|
|
||||||
included.add(mainKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (entry in recent) {
|
|
||||||
if (included.add(entry.key)) {
|
|
||||||
result.add(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.isNotEmpty() && !included.contains(current)) {
|
|
||||||
result.add(ChatSessionEntry(key = current, updatedAtMs = null))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package com.clawdbot.android.voice
|
|
||||||
|
|
||||||
import android.media.MediaDataSource
|
|
||||||
import kotlin.math.min
|
|
||||||
|
|
||||||
internal class StreamingMediaDataSource : MediaDataSource() {
|
|
||||||
private data class Chunk(val start: Long, val data: ByteArray)
|
|
||||||
|
|
||||||
private val lock = Object()
|
|
||||||
private val chunks = ArrayList<Chunk>()
|
|
||||||
private var totalSize: Long = 0
|
|
||||||
private var closed = false
|
|
||||||
private var finished = false
|
|
||||||
private var lastReadIndex = 0
|
|
||||||
|
|
||||||
fun append(data: ByteArray) {
|
|
||||||
if (data.isEmpty()) return
|
|
||||||
synchronized(lock) {
|
|
||||||
if (closed || finished) return
|
|
||||||
val chunk = Chunk(totalSize, data)
|
|
||||||
chunks.add(chunk)
|
|
||||||
totalSize += data.size.toLong()
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finish() {
|
|
||||||
synchronized(lock) {
|
|
||||||
if (closed) return
|
|
||||||
finished = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fail() {
|
|
||||||
synchronized(lock) {
|
|
||||||
closed = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
|
||||||
if (position < 0) return -1
|
|
||||||
synchronized(lock) {
|
|
||||||
while (!closed && !finished && position >= totalSize) {
|
|
||||||
lock.wait()
|
|
||||||
}
|
|
||||||
if (closed) return -1
|
|
||||||
if (position >= totalSize && finished) return -1
|
|
||||||
|
|
||||||
val available = (totalSize - position).toInt()
|
|
||||||
val toRead = min(size, available)
|
|
||||||
var remaining = toRead
|
|
||||||
var destOffset = offset
|
|
||||||
var pos = position
|
|
||||||
|
|
||||||
var index = findChunkIndex(pos)
|
|
||||||
while (remaining > 0 && index < chunks.size) {
|
|
||||||
val chunk = chunks[index]
|
|
||||||
val inChunkOffset = (pos - chunk.start).toInt()
|
|
||||||
if (inChunkOffset >= chunk.data.size) {
|
|
||||||
index++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
|
|
||||||
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
|
|
||||||
remaining -= copyLen
|
|
||||||
destOffset += copyLen
|
|
||||||
pos += copyLen
|
|
||||||
if (inChunkOffset + copyLen >= chunk.data.size) {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toRead - remaining
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSize(): Long = -1
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
synchronized(lock) {
|
|
||||||
closed = true
|
|
||||||
lock.notifyAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findChunkIndex(position: Long): Int {
|
|
||||||
var index = lastReadIndex
|
|
||||||
while (index < chunks.size) {
|
|
||||||
val chunk = chunks[index]
|
|
||||||
if (position < chunk.start + chunk.data.size) break
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
lastReadIndex = index
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package com.clawdbot.android.voice
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
|
|
||||||
private val directiveJson = Json { ignoreUnknownKeys = true }
|
|
||||||
|
|
||||||
data class TalkDirective(
|
|
||||||
val voiceId: String? = null,
|
|
||||||
val modelId: String? = null,
|
|
||||||
val speed: Double? = null,
|
|
||||||
val rateWpm: Int? = null,
|
|
||||||
val stability: Double? = null,
|
|
||||||
val similarity: Double? = null,
|
|
||||||
val style: Double? = null,
|
|
||||||
val speakerBoost: Boolean? = null,
|
|
||||||
val seed: Long? = null,
|
|
||||||
val normalize: String? = null,
|
|
||||||
val language: String? = null,
|
|
||||||
val outputFormat: String? = null,
|
|
||||||
val latencyTier: Int? = null,
|
|
||||||
val once: Boolean? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TalkDirectiveParseResult(
|
|
||||||
val directive: TalkDirective?,
|
|
||||||
val stripped: String,
|
|
||||||
val unknownKeys: List<String>,
|
|
||||||
)
|
|
||||||
|
|
||||||
object TalkDirectiveParser {
|
|
||||||
fun parse(text: String): TalkDirectiveParseResult {
|
|
||||||
val normalized = text.replace("\r\n", "\n")
|
|
||||||
val lines = normalized.split("\n").toMutableList()
|
|
||||||
if (lines.isEmpty()) return TalkDirectiveParseResult(null, text, emptyList())
|
|
||||||
|
|
||||||
val firstNonEmpty = lines.indexOfFirst { it.trim().isNotEmpty() }
|
|
||||||
if (firstNonEmpty == -1) return TalkDirectiveParseResult(null, text, emptyList())
|
|
||||||
|
|
||||||
val head = lines[firstNonEmpty].trim()
|
|
||||||
if (!head.startsWith("{") || !head.endsWith("}")) {
|
|
||||||
return TalkDirectiveParseResult(null, text, emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
val obj = parseJsonObject(head) ?: return TalkDirectiveParseResult(null, text, emptyList())
|
|
||||||
|
|
||||||
val speakerBoost =
|
|
||||||
boolValue(obj, listOf("speaker_boost", "speakerBoost"))
|
|
||||||
?: boolValue(obj, listOf("no_speaker_boost", "noSpeakerBoost"))?.not()
|
|
||||||
|
|
||||||
val directive = TalkDirective(
|
|
||||||
voiceId = stringValue(obj, listOf("voice", "voice_id", "voiceId")),
|
|
||||||
modelId = stringValue(obj, listOf("model", "model_id", "modelId")),
|
|
||||||
speed = doubleValue(obj, listOf("speed")),
|
|
||||||
rateWpm = intValue(obj, listOf("rate", "wpm")),
|
|
||||||
stability = doubleValue(obj, listOf("stability")),
|
|
||||||
similarity = doubleValue(obj, listOf("similarity", "similarity_boost", "similarityBoost")),
|
|
||||||
style = doubleValue(obj, listOf("style")),
|
|
||||||
speakerBoost = speakerBoost,
|
|
||||||
seed = longValue(obj, listOf("seed")),
|
|
||||||
normalize = stringValue(obj, listOf("normalize", "apply_text_normalization")),
|
|
||||||
language = stringValue(obj, listOf("lang", "language_code", "language")),
|
|
||||||
outputFormat = stringValue(obj, listOf("output_format", "format")),
|
|
||||||
latencyTier = intValue(obj, listOf("latency", "latency_tier", "latencyTier")),
|
|
||||||
once = boolValue(obj, listOf("once")),
|
|
||||||
)
|
|
||||||
|
|
||||||
val hasDirective = listOf(
|
|
||||||
directive.voiceId,
|
|
||||||
directive.modelId,
|
|
||||||
directive.speed,
|
|
||||||
directive.rateWpm,
|
|
||||||
directive.stability,
|
|
||||||
directive.similarity,
|
|
||||||
directive.style,
|
|
||||||
directive.speakerBoost,
|
|
||||||
directive.seed,
|
|
||||||
directive.normalize,
|
|
||||||
directive.language,
|
|
||||||
directive.outputFormat,
|
|
||||||
directive.latencyTier,
|
|
||||||
directive.once,
|
|
||||||
).any { it != null }
|
|
||||||
|
|
||||||
if (!hasDirective) return TalkDirectiveParseResult(null, text, emptyList())
|
|
||||||
|
|
||||||
val knownKeys = setOf(
|
|
||||||
"voice", "voice_id", "voiceid",
|
|
||||||
"model", "model_id", "modelid",
|
|
||||||
"speed", "rate", "wpm",
|
|
||||||
"stability", "similarity", "similarity_boost", "similarityboost",
|
|
||||||
"style",
|
|
||||||
"speaker_boost", "speakerboost",
|
|
||||||
"no_speaker_boost", "nospeakerboost",
|
|
||||||
"seed",
|
|
||||||
"normalize", "apply_text_normalization",
|
|
||||||
"lang", "language_code", "language",
|
|
||||||
"output_format", "format",
|
|
||||||
"latency", "latency_tier", "latencytier",
|
|
||||||
"once",
|
|
||||||
)
|
|
||||||
val unknownKeys = obj.keys.filter { !knownKeys.contains(it.lowercase()) }.sorted()
|
|
||||||
|
|
||||||
lines.removeAt(firstNonEmpty)
|
|
||||||
if (firstNonEmpty < lines.size) {
|
|
||||||
if (lines[firstNonEmpty].trim().isEmpty()) {
|
|
||||||
lines.removeAt(firstNonEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return TalkDirectiveParseResult(directive, lines.joinToString("\n"), unknownKeys)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseJsonObject(line: String): JsonObject? {
|
|
||||||
return try {
|
|
||||||
directiveJson.parseToJsonElement(line) as? JsonObject
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stringValue(obj: JsonObject, keys: List<String>): String? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = obj[key].asStringOrNull()?.trim()
|
|
||||||
if (!value.isNullOrEmpty()) return value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun doubleValue(obj: JsonObject, keys: List<String>): Double? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = obj[key].asDoubleOrNull()
|
|
||||||
if (value != null) return value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun intValue(obj: JsonObject, keys: List<String>): Int? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = obj[key].asIntOrNull()
|
|
||||||
if (value != null) return value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun longValue(obj: JsonObject, keys: List<String>): Long? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = obj[key].asLongOrNull()
|
|
||||||
if (value != null) return value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun boolValue(obj: JsonObject, keys: List<String>): Boolean? {
|
|
||||||
for (key in keys) {
|
|
||||||
val value = obj[key].asBooleanOrNull()
|
|
||||||
if (value != null) return value
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asStringOrNull(): String? =
|
|
||||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
|
||||||
|
|
||||||
private fun JsonElement?.asDoubleOrNull(): Double? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
return primitive.content.toDoubleOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asIntOrNull(): Int? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
return primitive.content.toIntOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asLongOrNull(): Long? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
return primitive.content.toLongOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonElement?.asBooleanOrNull(): Boolean? {
|
|
||||||
val primitive = this as? JsonPrimitive ?: return null
|
|
||||||
val content = primitive.content.trim().lowercase()
|
|
||||||
return when (content) {
|
|
||||||
"true", "yes", "1" -> true
|
|
||||||
"false", "no", "0" -> false
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
enum class CameraHudKind {
|
enum class CameraHudKind {
|
||||||
Photo,
|
Photo,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
@@ -18,8 +18,8 @@ import androidx.core.view.WindowInsetsControllerCompat
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import com.clawdbot.android.ui.RootScreen
|
import com.steipete.clawdis.node.ui.RootScreen
|
||||||
import com.clawdbot.android.ui.ClawdbotTheme
|
import com.steipete.clawdis.node.ui.ClawdisTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -39,7 +39,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
screenCaptureRequester = ScreenCaptureRequester(this)
|
screenCaptureRequester = ScreenCaptureRequester(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
viewModel.camera.attachLifecycleOwner(this)
|
||||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
viewModel.camera.attachPermissionRequester(permissionRequester)
|
||||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
|
||||||
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
|
||||||
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ClawdbotTheme {
|
ClawdisTheme {
|
||||||
Surface(modifier = Modifier) {
|
Surface(modifier = Modifier) {
|
||||||
RootScreen(viewModel = viewModel)
|
RootScreen(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.CanvasController
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import com.clawdbot.android.node.ScreenRecordManager
|
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||||
import com.clawdbot.android.node.SmsManager
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
@@ -16,7 +15,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val canvas: CanvasController = runtime.canvas
|
val canvas: CanvasController = runtime.canvas
|
||||||
val camera: CameraCaptureManager = runtime.camera
|
val camera: CameraCaptureManager = runtime.camera
|
||||||
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
|
||||||
val sms: SmsManager = runtime.sms
|
|
||||||
|
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = runtime.bridges
|
||||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
||||||
@@ -25,28 +23,18 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val statusText: StateFlow<String> = runtime.statusText
|
val statusText: StateFlow<String> = runtime.statusText
|
||||||
val serverName: StateFlow<String?> = runtime.serverName
|
val serverName: StateFlow<String?> = runtime.serverName
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
|
||||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
|
||||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
|
||||||
|
|
||||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
||||||
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
|
|
||||||
|
|
||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val instanceId: StateFlow<String> = runtime.instanceId
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val displayName: StateFlow<String> = runtime.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
||||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
|
||||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
||||||
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
val wakeWords: StateFlow<List<String>> = runtime.wakeWords
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = runtime.voiceWakeMode
|
||||||
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
val voiceWakeStatusText: StateFlow<String> = runtime.voiceWakeStatusText
|
||||||
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
val voiceWakeIsListening: StateFlow<Boolean> = runtime.voiceWakeIsListening
|
||||||
val talkEnabled: StateFlow<Boolean> = runtime.talkEnabled
|
|
||||||
val talkStatusText: StateFlow<String> = runtime.talkStatusText
|
|
||||||
val talkIsListening: StateFlow<Boolean> = runtime.talkIsListening
|
|
||||||
val talkIsSpeaking: StateFlow<Boolean> = runtime.talkIsSpeaking
|
|
||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
val manualHost: StateFlow<String> = runtime.manualHost
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||||
@@ -75,14 +63,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setCameraEnabled(value)
|
runtime.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
runtime.setLocationMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
runtime.setLocationPreciseEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
runtime.setPreventSleep(value)
|
runtime.setPreventSleep(value)
|
||||||
}
|
}
|
||||||
@@ -115,14 +95,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.setVoiceWakeMode(mode)
|
runtime.setVoiceWakeMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTalkEnabled(enabled: Boolean) {
|
|
||||||
runtime.setTalkEnabled(enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshBridgeHello() {
|
|
||||||
runtime.refreshBridgeHello()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
runtime.connect(endpoint)
|
runtime.connect(endpoint)
|
||||||
}
|
}
|
||||||
@@ -139,7 +111,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
runtime.loadChat(sessionKey)
|
runtime.loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class NodeApp : Application() {
|
||||||
|
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -29,7 +29,7 @@ class NodeForegroundService : Service() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val initial = buildNotification(title = "Clawdbot Node", text = "Starting…")
|
val initial = buildNotification(title = "Clawdis Node", text = "Starting…")
|
||||||
startForegroundWithTypes(notification = initial, requiresMic = false)
|
startForegroundWithTypes(notification = initial, requiresMic = false)
|
||||||
|
|
||||||
val runtime = (application as NodeApp).runtime
|
val runtime = (application as NodeApp).runtime
|
||||||
@@ -44,7 +44,7 @@ class NodeForegroundService : Service() {
|
|||||||
) { status, server, connected, voiceMode, voiceListening ->
|
) { status, server, connected, voiceMode, voiceListening ->
|
||||||
Quint(status, server, connected, voiceMode, voiceListening)
|
Quint(status, server, connected, voiceMode, voiceListening)
|
||||||
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
}.collect { (status, server, connected, voiceMode, voiceListening) ->
|
||||||
val title = if (connected) "Clawdbot Node · Connected" else "Clawdbot Node"
|
val title = if (connected) "Clawdis Node · Connected" else "Clawdis Node"
|
||||||
val voiceSuffix =
|
val voiceSuffix =
|
||||||
if (voiceMode == VoiceWakeMode.Always) {
|
if (voiceMode == VoiceWakeMode.Always) {
|
||||||
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
if (voiceListening) " · Voice Wake: Listening" else " · Voice Wake: Paused"
|
||||||
@@ -91,38 +91,21 @@ class NodeForegroundService : Service() {
|
|||||||
"Connection",
|
"Connection",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
).apply {
|
).apply {
|
||||||
description = "Clawdbot node connection status"
|
description = "Clawdis node connection status"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
}
|
}
|
||||||
mgr.createNotificationChannel(channel)
|
mgr.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNotification(title: String, text: String): Notification {
|
private fun buildNotification(title: String, text: String): Notification {
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
}
|
|
||||||
val launchPending =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
1,
|
|
||||||
launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
val stopIntent = Intent(this, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
val stopPending =
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
PendingIntent.getService(
|
val stopPending = PendingIntent.getService(this, 2, stopIntent, flags)
|
||||||
this,
|
|
||||||
2,
|
|
||||||
stopIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setContentIntent(launchPending)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
@@ -163,7 +146,7 @@ class NodeForegroundService : Service() {
|
|||||||
private const val CHANNEL_ID = "connection"
|
private const val CHANNEL_ID = "connection"
|
||||||
private const val NOTIFICATION_ID = 1
|
private const val NOTIFICATION_ID = 1
|
||||||
|
|
||||||
private const val ACTION_STOP = "com.clawdbot.android.action.STOP"
|
private const val ACTION_STOP = "com.steipete.clawdis.node.action.STOP"
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
val intent = Intent(context, NodeForegroundService::class.java)
|
val intent = Intent(context, NodeForegroundService::class.java)
|
||||||
@@ -1,43 +1,34 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.location.LocationManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdbot.android.chat.ChatController
|
import com.steipete.clawdis.node.chat.ChatController
|
||||||
import com.clawdbot.android.chat.ChatMessage
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
import com.clawdbot.android.bridge.BridgeDiscovery
|
import com.steipete.clawdis.node.bridge.BridgeDiscovery
|
||||||
import com.clawdbot.android.bridge.BridgeEndpoint
|
import com.steipete.clawdis.node.bridge.BridgeEndpoint
|
||||||
import com.clawdbot.android.bridge.BridgePairingClient
|
import com.steipete.clawdis.node.bridge.BridgePairingClient
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
import com.clawdbot.android.bridge.BridgeTlsParams
|
import com.steipete.clawdis.node.node.CameraCaptureManager
|
||||||
import com.clawdbot.android.node.CameraCaptureManager
|
import com.steipete.clawdis.node.node.CanvasController
|
||||||
import com.clawdbot.android.node.LocationCaptureManager
|
import com.steipete.clawdis.node.node.ScreenRecordManager
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.steipete.clawdis.node.protocol.ClawdisCapability
|
||||||
import com.clawdbot.android.node.CanvasController
|
import com.steipete.clawdis.node.protocol.ClawdisCameraCommand
|
||||||
import com.clawdbot.android.node.ScreenRecordManager
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UIAction
|
||||||
import com.clawdbot.android.node.SmsManager
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasA2UICommand
|
||||||
import com.clawdbot.android.protocol.ClawdbotCapability
|
import com.steipete.clawdis.node.protocol.ClawdisCanvasCommand
|
||||||
import com.clawdbot.android.protocol.ClawdbotCameraCommand
|
import com.steipete.clawdis.node.protocol.ClawdisScreenCommand
|
||||||
import com.clawdbot.android.protocol.ClawdbotCanvasA2UIAction
|
import com.steipete.clawdis.node.voice.VoiceWakeManager
|
||||||
import com.clawdbot.android.protocol.ClawdbotCanvasA2UICommand
|
|
||||||
import com.clawdbot.android.protocol.ClawdbotCanvasCommand
|
|
||||||
import com.clawdbot.android.protocol.ClawdbotScreenCommand
|
|
||||||
import com.clawdbot.android.protocol.ClawdbotLocationCommand
|
|
||||||
import com.clawdbot.android.protocol.ClawdbotSmsCommand
|
|
||||||
import com.clawdbot.android.voice.TalkModeManager
|
|
||||||
import com.clawdbot.android.voice.VoiceWakeManager
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -62,9 +53,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val prefs = SecurePrefs(appContext)
|
val prefs = SecurePrefs(appContext)
|
||||||
val canvas = CanvasController()
|
val canvas = CanvasController()
|
||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
val location = LocationCaptureManager(appContext)
|
|
||||||
val screenRecorder = ScreenRecordManager(appContext)
|
val screenRecorder = ScreenRecordManager(appContext)
|
||||||
val sms = SmsManager(appContext)
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||||
@@ -79,7 +68,7 @@ class NodeRuntime(context: Context) {
|
|||||||
payloadJson =
|
payloadJson =
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("message", JsonPrimitive(command))
|
put("message", JsonPrimitive(command))
|
||||||
put("sessionKey", JsonPrimitive(resolveMainSessionKey()))
|
put("sessionKey", JsonPrimitive("main"))
|
||||||
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
put("thinking", JsonPrimitive(chatThinkingLevel.value))
|
||||||
put("deliver", JsonPrimitive(false))
|
put("deliver", JsonPrimitive(false))
|
||||||
}.toString(),
|
}.toString(),
|
||||||
@@ -94,15 +83,6 @@ class NodeRuntime(context: Context) {
|
|||||||
val voiceWakeStatusText: StateFlow<String>
|
val voiceWakeStatusText: StateFlow<String>
|
||||||
get() = voiceWake.statusText
|
get() = voiceWake.statusText
|
||||||
|
|
||||||
val talkStatusText: StateFlow<String>
|
|
||||||
get() = talkMode.statusText
|
|
||||||
|
|
||||||
val talkIsListening: StateFlow<Boolean>
|
|
||||||
get() = talkMode.isListening
|
|
||||||
|
|
||||||
val talkIsSpeaking: StateFlow<Boolean>
|
|
||||||
get() = talkMode.isSpeaking
|
|
||||||
|
|
||||||
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
private val discovery = BridgeDiscovery(appContext, scope = scope)
|
||||||
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
val bridges: StateFlow<List<BridgeEndpoint>> = discovery.bridges
|
||||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||||
@@ -113,9 +93,6 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _statusText = MutableStateFlow("Offline")
|
private val _statusText = MutableStateFlow("Offline")
|
||||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||||
|
|
||||||
private val _mainSessionKey = MutableStateFlow("main")
|
|
||||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
|
||||||
|
|
||||||
private val cameraHudSeq = AtomicLong(0)
|
private val cameraHudSeq = AtomicLong(0)
|
||||||
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
private val _cameraHud = MutableStateFlow<CameraHudState?>(null)
|
||||||
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
val cameraHud: StateFlow<CameraHudState?> = _cameraHud.asStateFlow()
|
||||||
@@ -123,18 +100,12 @@ class NodeRuntime(context: Context) {
|
|||||||
private val _cameraFlashToken = MutableStateFlow(0L)
|
private val _cameraFlashToken = MutableStateFlow(0L)
|
||||||
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
|
||||||
|
|
||||||
private val _screenRecordActive = MutableStateFlow(false)
|
|
||||||
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
|
|
||||||
|
|
||||||
private val _serverName = MutableStateFlow<String?>(null)
|
private val _serverName = MutableStateFlow<String?>(null)
|
||||||
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
val serverName: StateFlow<String?> = _serverName.asStateFlow()
|
||||||
|
|
||||||
private val _remoteAddress = MutableStateFlow<String?>(null)
|
private val _remoteAddress = MutableStateFlow<String?>(null)
|
||||||
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
val remoteAddress: StateFlow<String?> = _remoteAddress.asStateFlow()
|
||||||
|
|
||||||
private val _seamColorArgb = MutableStateFlow(DEFAULT_SEAM_COLOR_ARGB)
|
|
||||||
val seamColorArgb: StateFlow<Long> = _seamColorArgb.asStateFlow()
|
|
||||||
|
|
||||||
private val _isForeground = MutableStateFlow(true)
|
private val _isForeground = MutableStateFlow(true)
|
||||||
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
|
||||||
|
|
||||||
@@ -143,14 +114,11 @@ class NodeRuntime(context: Context) {
|
|||||||
private val session =
|
private val session =
|
||||||
BridgeSession(
|
BridgeSession(
|
||||||
scope = scope,
|
scope = scope,
|
||||||
onConnected = { name, remote, mainSessionKey ->
|
onConnected = { name, remote ->
|
||||||
_statusText.value = "Connected"
|
_statusText.value = "Connected"
|
||||||
_serverName.value = name
|
_serverName.value = name
|
||||||
_remoteAddress.value = remote
|
_remoteAddress.value = remote
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
|
||||||
applyMainSessionKey(mainSessionKey)
|
|
||||||
scope.launch { refreshBrandingFromGateway() }
|
|
||||||
scope.launch { refreshWakeWordsFromGateway() }
|
scope.launch { refreshWakeWordsFromGateway() }
|
||||||
maybeNavigateToA2uiOnConnect()
|
maybeNavigateToA2uiOnConnect()
|
||||||
},
|
},
|
||||||
@@ -161,47 +129,19 @@ class NodeRuntime(context: Context) {
|
|||||||
onInvoke = { req ->
|
onInvoke = { req ->
|
||||||
handleInvoke(req.command, req.paramsJson)
|
handleInvoke(req.command, req.paramsJson)
|
||||||
},
|
},
|
||||||
onTlsFingerprint = { stableId, fingerprint ->
|
|
||||||
prefs.saveBridgeTlsFingerprint(stableId, fingerprint)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private val chat = ChatController(scope = scope, session = session, json = json)
|
private val chat = ChatController(scope = scope, session = session, json = json)
|
||||||
private val talkMode: TalkModeManager by lazy {
|
|
||||||
TalkModeManager(context = appContext, scope = scope).also { it.attachSession(session) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleSessionDisconnected(message: String) {
|
private fun handleSessionDisconnected(message: String) {
|
||||||
_statusText.value = message
|
_statusText.value = message
|
||||||
_serverName.value = null
|
_serverName.value = null
|
||||||
_remoteAddress.value = null
|
_remoteAddress.value = null
|
||||||
_isConnected.value = false
|
_isConnected.value = false
|
||||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
|
||||||
if (!isCanonicalMainSessionKey(_mainSessionKey.value)) {
|
|
||||||
_mainSessionKey.value = "main"
|
|
||||||
}
|
|
||||||
val mainKey = resolveMainSessionKey()
|
|
||||||
talkMode.setMainSessionKey(mainKey)
|
|
||||||
chat.applyMainSessionKey(mainKey)
|
|
||||||
chat.onDisconnected(message)
|
chat.onDisconnected(message)
|
||||||
showLocalCanvasOnDisconnect()
|
showLocalCanvasOnDisconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyMainSessionKey(candidate: String?) {
|
|
||||||
val trimmed = candidate?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
|
||||||
if (_mainSessionKey.value == trimmed) return
|
|
||||||
_mainSessionKey.value = trimmed
|
|
||||||
talkMode.setMainSessionKey(trimmed)
|
|
||||||
chat.applyMainSessionKey(trimmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveMainSessionKey(): String {
|
|
||||||
val trimmed = _mainSessionKey.value.trim()
|
|
||||||
return if (trimmed.isEmpty()) "main" else trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun maybeNavigateToA2uiOnConnect() {
|
private fun maybeNavigateToA2uiOnConnect() {
|
||||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||||
@@ -219,12 +159,9 @@ class NodeRuntime(context: Context) {
|
|||||||
val instanceId: StateFlow<String> = prefs.instanceId
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
val displayName: StateFlow<String> = prefs.displayName
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
|
||||||
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||||
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
val wakeWords: StateFlow<List<String>> = prefs.wakeWords
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = prefs.voiceWakeMode
|
||||||
val talkEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
|
||||||
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
val manualHost: StateFlow<String> = prefs.manualHost
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
@@ -280,13 +217,6 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
|
||||||
talkEnabled.collect { enabled ->
|
|
||||||
talkMode.setEnabled(enabled)
|
|
||||||
externalAudioCaptureActive.value = enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
bridges.collect { list ->
|
bridges.collect { list ->
|
||||||
if (list.isNotEmpty()) {
|
if (list.isNotEmpty()) {
|
||||||
@@ -347,14 +277,6 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setCameraEnabled(value)
|
prefs.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
prefs.setLocationMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
prefs.setLocationPreciseEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
prefs.setPreventSleep(value)
|
prefs.setPreventSleep(value)
|
||||||
}
|
}
|
||||||
@@ -388,129 +310,63 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.setVoiceWakeMode(mode)
|
prefs.setVoiceWakeMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTalkEnabled(value: Boolean) {
|
|
||||||
prefs.setTalkEnabled(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildInvokeCommands(): List<String> =
|
|
||||||
buildList {
|
|
||||||
add(ClawdbotCanvasCommand.Present.rawValue)
|
|
||||||
add(ClawdbotCanvasCommand.Hide.rawValue)
|
|
||||||
add(ClawdbotCanvasCommand.Navigate.rawValue)
|
|
||||||
add(ClawdbotCanvasCommand.Eval.rawValue)
|
|
||||||
add(ClawdbotCanvasCommand.Snapshot.rawValue)
|
|
||||||
add(ClawdbotCanvasA2UICommand.Push.rawValue)
|
|
||||||
add(ClawdbotCanvasA2UICommand.PushJSONL.rawValue)
|
|
||||||
add(ClawdbotCanvasA2UICommand.Reset.rawValue)
|
|
||||||
add(ClawdbotScreenCommand.Record.rawValue)
|
|
||||||
if (cameraEnabled.value) {
|
|
||||||
add(ClawdbotCameraCommand.Snap.rawValue)
|
|
||||||
add(ClawdbotCameraCommand.Clip.rawValue)
|
|
||||||
}
|
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdbotLocationCommand.Get.rawValue)
|
|
||||||
}
|
|
||||||
if (sms.canSendSms()) {
|
|
||||||
add(ClawdbotSmsCommand.Send.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildCapabilities(): List<String> =
|
|
||||||
buildList {
|
|
||||||
add(ClawdbotCapability.Canvas.rawValue)
|
|
||||||
add(ClawdbotCapability.Screen.rawValue)
|
|
||||||
if (cameraEnabled.value) add(ClawdbotCapability.Camera.rawValue)
|
|
||||||
if (sms.canSendSms()) add(ClawdbotCapability.Sms.rawValue)
|
|
||||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
|
||||||
add(ClawdbotCapability.VoiceWake.rawValue)
|
|
||||||
}
|
|
||||||
if (locationMode.value != LocationMode.Off) {
|
|
||||||
add(ClawdbotCapability.Location.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPairingHello(token: String?): BridgePairingClient.Hello {
|
|
||||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { null }
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
val advertisedVersion =
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
return BridgePairingClient.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = token,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = buildCapabilities(),
|
|
||||||
commands = buildInvokeCommands(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSessionHello(token: String?): BridgeSession.Hello {
|
|
||||||
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { null }
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
val advertisedVersion =
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
return BridgeSession.Hello(
|
|
||||||
nodeId = instanceId.value,
|
|
||||||
displayName = displayName.value,
|
|
||||||
token = token,
|
|
||||||
platform = "Android",
|
|
||||||
version = advertisedVersion,
|
|
||||||
deviceFamily = "Android",
|
|
||||||
modelIdentifier = modelIdentifier,
|
|
||||||
caps = buildCapabilities(),
|
|
||||||
commands = buildInvokeCommands(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshBridgeHello() {
|
|
||||||
scope.launch {
|
|
||||||
if (!_isConnected.value) return@launch
|
|
||||||
val token = prefs.loadBridgeToken()
|
|
||||||
if (token.isNullOrBlank()) return@launch
|
|
||||||
session.updateHello(buildSessionHello(token))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint) {
|
fun connect(endpoint: BridgeEndpoint) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_statusText.value = "Connecting…"
|
_statusText.value = "Connecting…"
|
||||||
val storedToken = prefs.loadBridgeToken()
|
val storedToken = prefs.loadBridgeToken()
|
||||||
val tls = resolveTlsParams(endpoint)
|
val modelIdentifier = listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||||
|
.joinToString(" ")
|
||||||
|
.trim()
|
||||||
|
.ifEmpty { null }
|
||||||
|
|
||||||
|
val invokeCommands =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCanvasCommand.Present.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Hide.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Navigate.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Eval.rawValue)
|
||||||
|
add(ClawdisCanvasCommand.Snapshot.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Push.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.PushJSONL.rawValue)
|
||||||
|
add(ClawdisCanvasA2UICommand.Reset.rawValue)
|
||||||
|
add(ClawdisScreenCommand.Record.rawValue)
|
||||||
|
if (cameraEnabled.value) {
|
||||||
|
add(ClawdisCameraCommand.Snap.rawValue)
|
||||||
|
add(ClawdisCameraCommand.Clip.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
val resolved =
|
val resolved =
|
||||||
if (storedToken.isNullOrBlank()) {
|
if (storedToken.isNullOrBlank()) {
|
||||||
_statusText.value = "Pairing…"
|
_statusText.value = "Pairing…"
|
||||||
|
val caps = buildList {
|
||||||
|
add(ClawdisCapability.Canvas.rawValue)
|
||||||
|
add(ClawdisCapability.Screen.rawValue)
|
||||||
|
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||||
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
BridgePairingClient().pairAndHello(
|
BridgePairingClient().pairAndHello(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildPairingHello(token = null),
|
hello =
|
||||||
tls = tls,
|
BridgePairingClient.Hello(
|
||||||
onTlsFingerprint = { fingerprint ->
|
nodeId = instanceId.value,
|
||||||
prefs.saveBridgeTlsFingerprint(endpoint.stableId, fingerprint)
|
displayName = displayName.value,
|
||||||
},
|
token = null,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps = caps,
|
||||||
|
commands = invokeCommands,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
BridgePairingClient.PairResult(ok = true, token = storedToken.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
if (!resolved.ok || resolved.token.isNullOrBlank()) {
|
||||||
val errorMessage = resolved.error?.trim().orEmpty().ifEmpty { "pairing required" }
|
_statusText.value = "Failed: pairing required"
|
||||||
_statusText.value = "Failed: $errorMessage"
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,8 +374,26 @@ class NodeRuntime(context: Context) {
|
|||||||
prefs.saveBridgeToken(authToken)
|
prefs.saveBridgeToken(authToken)
|
||||||
session.connect(
|
session.connect(
|
||||||
endpoint = endpoint,
|
endpoint = endpoint,
|
||||||
hello = buildSessionHello(token = authToken),
|
hello =
|
||||||
tls = tls,
|
BridgeSession.Hello(
|
||||||
|
nodeId = instanceId.value,
|
||||||
|
displayName = displayName.value,
|
||||||
|
token = authToken,
|
||||||
|
platform = "Android",
|
||||||
|
version = "dev",
|
||||||
|
deviceFamily = "Android",
|
||||||
|
modelIdentifier = modelIdentifier,
|
||||||
|
caps =
|
||||||
|
buildList {
|
||||||
|
add(ClawdisCapability.Canvas.rawValue)
|
||||||
|
add(ClawdisCapability.Screen.rawValue)
|
||||||
|
if (cameraEnabled.value) add(ClawdisCapability.Camera.rawValue)
|
||||||
|
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||||
|
add(ClawdisCapability.VoiceWake.rawValue)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commands = invokeCommands,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,27 +405,6 @@ class NodeRuntime(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hasFineLocationPermission(): Boolean {
|
|
||||||
return (
|
|
||||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasCoarseLocationPermission(): Boolean {
|
|
||||||
return (
|
|
||||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun hasBackgroundLocationPermission(): Boolean {
|
|
||||||
return (
|
|
||||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connectManual() {
|
fun connectManual() {
|
||||||
val host = manualHost.value.trim()
|
val host = manualHost.value.trim()
|
||||||
val port = manualPort.value
|
val port = manualPort.value
|
||||||
@@ -566,41 +419,6 @@ class NodeRuntime(context: Context) {
|
|||||||
session.disconnect()
|
session.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveTlsParams(endpoint: BridgeEndpoint): BridgeTlsParams? {
|
|
||||||
val stored = prefs.loadBridgeTlsFingerprint(endpoint.stableId)
|
|
||||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
|
||||||
val manual = endpoint.stableId.startsWith("manual|")
|
|
||||||
|
|
||||||
if (hinted) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
|
||||||
allowTOFU = stored == null,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!stored.isNullOrBlank()) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = true,
|
|
||||||
expectedFingerprint = stored,
|
|
||||||
allowTOFU = false,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manual) {
|
|
||||||
return BridgeTlsParams(
|
|
||||||
required = false,
|
|
||||||
expectedFingerprint = null,
|
|
||||||
allowTOFU = true,
|
|
||||||
stableId = endpoint.stableId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val trimmed = payloadJson.trim()
|
val trimmed = payloadJson.trim()
|
||||||
@@ -617,7 +435,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
val actionId = (userActionObj["id"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty {
|
||||||
java.util.UUID.randomUUID().toString()
|
java.util.UUID.randomUUID().toString()
|
||||||
}
|
}
|
||||||
val name = ClawdbotCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
val name = ClawdisCanvasA2UIAction.extractActionName(userActionObj) ?: return@launch
|
||||||
|
|
||||||
val surfaceId =
|
val surfaceId =
|
||||||
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
(userActionObj["surfaceId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "main" }
|
||||||
@@ -625,9 +443,9 @@ class NodeRuntime(context: Context) {
|
|||||||
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
(userActionObj["sourceComponentId"] as? JsonPrimitive)?.content?.trim().orEmpty().ifEmpty { "-" }
|
||||||
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
val contextJson = (userActionObj["context"] as? JsonObject)?.toString()
|
||||||
|
|
||||||
val sessionKey = resolveMainSessionKey()
|
val sessionKey = "main"
|
||||||
val message =
|
val message =
|
||||||
ClawdbotCanvasA2UIAction.formatAgentMessage(
|
ClawdisCanvasA2UIAction.formatAgentMessage(
|
||||||
actionName = name,
|
actionName = name,
|
||||||
sessionKey = sessionKey,
|
sessionKey = sessionKey,
|
||||||
surfaceId = surfaceId,
|
surfaceId = surfaceId,
|
||||||
@@ -661,7 +479,7 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
canvas.eval(
|
canvas.eval(
|
||||||
ClawdbotCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
ClawdisCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||||
actionId = actionId,
|
actionId = actionId,
|
||||||
ok = connected && error == null,
|
ok = connected && error == null,
|
||||||
error = error,
|
error = error,
|
||||||
@@ -673,9 +491,8 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { resolveMainSessionKey() }
|
chat.load(sessionKey)
|
||||||
chat.load(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChat() {
|
fun refreshChat() {
|
||||||
@@ -716,7 +533,6 @@ class NodeRuntime(context: Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
talkMode.handleBridgeEvent(event, payloadJson)
|
|
||||||
chat.handleBridgeEvent(event, payloadJson)
|
chat.handleBridgeEvent(event, payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,31 +574,12 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshBrandingFromGateway() {
|
|
||||||
if (!_isConnected.value) return
|
|
||||||
try {
|
|
||||||
val res = session.request("config.get", "{}")
|
|
||||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
|
||||||
val config = root?.get("config").asObjectOrNull()
|
|
||||||
val ui = config?.get("ui").asObjectOrNull()
|
|
||||||
val raw = ui?.get("seamColor").asStringOrNull()?.trim()
|
|
||||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
|
||||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
|
||||||
applyMainSessionKey(mainKey)
|
|
||||||
|
|
||||||
val parsed = parseHexColorArgb(raw)
|
|
||||||
_seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
private suspend fun handleInvoke(command: String, paramsJson: String?): BridgeSession.InvokeResult {
|
||||||
if (
|
if (
|
||||||
command.startsWith(ClawdbotCanvasCommand.NamespacePrefix) ||
|
command.startsWith(ClawdisCanvasCommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdbotCanvasA2UICommand.NamespacePrefix) ||
|
command.startsWith(ClawdisCanvasA2UICommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdbotCameraCommand.NamespacePrefix) ||
|
command.startsWith(ClawdisCameraCommand.NamespacePrefix) ||
|
||||||
command.startsWith(ClawdbotScreenCommand.NamespacePrefix)
|
command.startsWith(ClawdisScreenCommand.NamespacePrefix)
|
||||||
) {
|
) {
|
||||||
if (!isForeground.value) {
|
if (!isForeground.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
@@ -791,34 +588,26 @@ class NodeRuntime(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (command.startsWith(ClawdbotCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
if (command.startsWith(ClawdisCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||||
return BridgeSession.InvokeResult.error(
|
return BridgeSession.InvokeResult.error(
|
||||||
code = "CAMERA_DISABLED",
|
code = "CAMERA_DISABLED",
|
||||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (command.startsWith(ClawdbotLocationCommand.NamespacePrefix) &&
|
|
||||||
locationMode.value == LocationMode.Off
|
|
||||||
) {
|
|
||||||
return BridgeSession.InvokeResult.error(
|
|
||||||
code = "LOCATION_DISABLED",
|
|
||||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (command) {
|
return when (command) {
|
||||||
ClawdbotCanvasCommand.Present.rawValue -> {
|
ClawdisCanvasCommand.Present.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
BridgeSession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
ClawdisCanvasCommand.Hide.rawValue -> BridgeSession.InvokeResult.ok(null)
|
||||||
ClawdbotCanvasCommand.Navigate.rawValue -> {
|
ClawdisCanvasCommand.Navigate.rawValue -> {
|
||||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||||
canvas.navigate(url)
|
canvas.navigate(url)
|
||||||
BridgeSession.InvokeResult.ok(null)
|
BridgeSession.InvokeResult.ok(null)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Eval.rawValue -> {
|
ClawdisCanvasCommand.Eval.rawValue -> {
|
||||||
val js =
|
val js =
|
||||||
CanvasController.parseEvalJs(paramsJson)
|
CanvasController.parseEvalJs(paramsJson)
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return BridgeSession.InvokeResult.error(
|
||||||
@@ -836,7 +625,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
BridgeSession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||||
}
|
}
|
||||||
ClawdbotCanvasCommand.Snapshot.rawValue -> {
|
ClawdisCanvasCommand.Snapshot.rawValue -> {
|
||||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||||
val base64 =
|
val base64 =
|
||||||
try {
|
try {
|
||||||
@@ -853,7 +642,7 @@ class NodeRuntime(context: Context) {
|
|||||||
}
|
}
|
||||||
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
BridgeSession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||||
}
|
}
|
||||||
ClawdbotCanvasA2UICommand.Reset.rawValue -> {
|
ClawdisCanvasA2UICommand.Reset.rawValue -> {
|
||||||
val a2uiUrl = resolveA2uiHostUrl()
|
val a2uiUrl = resolveA2uiHostUrl()
|
||||||
?: return BridgeSession.InvokeResult.error(
|
?: return BridgeSession.InvokeResult.error(
|
||||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||||
@@ -869,7 +658,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val res = canvas.eval(a2uiResetJS)
|
val res = canvas.eval(a2uiResetJS)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
BridgeSession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdbotCanvasA2UICommand.Push.rawValue, ClawdbotCanvasA2UICommand.PushJSONL.rawValue -> {
|
ClawdisCanvasA2UICommand.Push.rawValue, ClawdisCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||||
val messages =
|
val messages =
|
||||||
try {
|
try {
|
||||||
decodeA2uiMessages(command, paramsJson)
|
decodeA2uiMessages(command, paramsJson)
|
||||||
@@ -892,7 +681,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val res = canvas.eval(js)
|
val res = canvas.eval(js)
|
||||||
BridgeSession.InvokeResult.ok(res)
|
BridgeSession.InvokeResult.ok(res)
|
||||||
}
|
}
|
||||||
ClawdbotCameraCommand.Snap.rawValue -> {
|
ClawdisCameraCommand.Snap.rawValue -> {
|
||||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||||
triggerCameraFlash()
|
triggerCameraFlash()
|
||||||
val res =
|
val res =
|
||||||
@@ -906,7 +695,7 @@ class NodeRuntime(context: Context) {
|
|||||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
}
|
}
|
||||||
ClawdbotCameraCommand.Clip.rawValue -> {
|
ClawdisCameraCommand.Clip.rawValue -> {
|
||||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||||
if (includeAudio) externalAudioCaptureActive.value = true
|
if (includeAudio) externalAudioCaptureActive.value = true
|
||||||
try {
|
try {
|
||||||
@@ -925,85 +714,15 @@ class NodeRuntime(context: Context) {
|
|||||||
if (includeAudio) externalAudioCaptureActive.value = false
|
if (includeAudio) externalAudioCaptureActive.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ClawdbotLocationCommand.Get.rawValue -> {
|
ClawdisScreenCommand.Record.rawValue -> {
|
||||||
val mode = locationMode.value
|
val res =
|
||||||
if (!isForeground.value && mode != LocationMode.Always) {
|
try {
|
||||||
return BridgeSession.InvokeResult.error(
|
screenRecorder.record(paramsJson)
|
||||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
} catch (err: Throwable) {
|
||||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
val (code, message) = invokeErrorFromThrowable(err)
|
||||||
)
|
return BridgeSession.InvokeResult.error(code = code, message = message)
|
||||||
}
|
|
||||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
|
||||||
return BridgeSession.InvokeResult.error(
|
|
||||||
code = "LOCATION_PERMISSION_REQUIRED",
|
|
||||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
|
||||||
return BridgeSession.InvokeResult.error(
|
|
||||||
code = "LOCATION_PERMISSION_REQUIRED",
|
|
||||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
|
||||||
val preciseEnabled = locationPreciseEnabled.value
|
|
||||||
val accuracy =
|
|
||||||
when (desiredAccuracy) {
|
|
||||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
|
||||||
"coarse" -> "coarse"
|
|
||||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
|
||||||
}
|
}
|
||||||
val providers =
|
BridgeSession.InvokeResult.ok(res.payloadJson)
|
||||||
when (accuracy) {
|
|
||||||
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
|
||||||
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
|
||||||
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val payload =
|
|
||||||
location.getLocation(
|
|
||||||
desiredProviders = providers,
|
|
||||||
maxAgeMs = maxAgeMs,
|
|
||||||
timeoutMs = timeoutMs,
|
|
||||||
isPrecise = accuracy == "precise",
|
|
||||||
)
|
|
||||||
BridgeSession.InvokeResult.ok(payload.payloadJson)
|
|
||||||
} catch (err: TimeoutCancellationException) {
|
|
||||||
BridgeSession.InvokeResult.error(
|
|
||||||
code = "LOCATION_TIMEOUT",
|
|
||||||
message = "LOCATION_TIMEOUT: no fix in time",
|
|
||||||
)
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
|
||||||
BridgeSession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClawdbotScreenCommand.Record.rawValue -> {
|
|
||||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
|
||||||
_screenRecordActive.value = true
|
|
||||||
try {
|
|
||||||
val res =
|
|
||||||
try {
|
|
||||||
screenRecorder.record(paramsJson)
|
|
||||||
} catch (err: Throwable) {
|
|
||||||
val (code, message) = invokeErrorFromThrowable(err)
|
|
||||||
return BridgeSession.InvokeResult.error(code = code, message = message)
|
|
||||||
}
|
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
|
||||||
} finally {
|
|
||||||
_screenRecordActive.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ClawdbotSmsCommand.Send.rawValue -> {
|
|
||||||
val res = sms.send(paramsJson)
|
|
||||||
if (res.ok) {
|
|
||||||
BridgeSession.InvokeResult.ok(res.payloadJson)
|
|
||||||
} else {
|
|
||||||
val error = res.error ?: "SMS_SEND_FAILED"
|
|
||||||
val idx = error.indexOf(':')
|
|
||||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
|
||||||
BridgeSession.InvokeResult.error(code = code, message = error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else ->
|
else ->
|
||||||
BridgeSession.InvokeResult.error(
|
BridgeSession.InvokeResult.error(
|
||||||
@@ -1042,30 +761,11 @@ class NodeRuntime(context: Context) {
|
|||||||
return code to "$code: $message"
|
return code to "$code: $message"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
|
|
||||||
if (paramsJson.isNullOrBlank()) {
|
|
||||||
return Triple(null, 10_000L, null)
|
|
||||||
}
|
|
||||||
val root =
|
|
||||||
try {
|
|
||||||
json.parseToJsonElement(paramsJson).asObjectOrNull()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
|
|
||||||
val timeoutMs =
|
|
||||||
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
|
|
||||||
?: 10_000L
|
|
||||||
val desiredAccuracy =
|
|
||||||
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
|
|
||||||
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveA2uiHostUrl(): String? {
|
private fun resolveA2uiHostUrl(): String? {
|
||||||
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
val raw = session.currentCanvasHostUrl()?.trim().orEmpty()
|
||||||
if (raw.isBlank()) return null
|
if (raw.isBlank()) return null
|
||||||
val base = raw.trimEnd('/')
|
val base = raw.trimEnd('/')
|
||||||
return "${base}/__clawdbot__/a2ui/?platform=android"
|
return "${base}/__clawdis__/a2ui/"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||||
@@ -1100,7 +800,7 @@ class NodeRuntime(context: Context) {
|
|||||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||||
val hasMessagesArray = obj["messages"] is JsonArray
|
val hasMessagesArray = obj["messages"] is JsonArray
|
||||||
|
|
||||||
if (command == ClawdbotCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
if (command == ClawdisCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||||
val jsonl = jsonlField
|
val jsonl = jsonlField
|
||||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||||
val messages =
|
val messages =
|
||||||
@@ -1151,13 +851,11 @@ class NodeRuntime(context: Context) {
|
|||||||
|
|
||||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||||
|
|
||||||
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
|
||||||
|
|
||||||
private const val a2uiReadyCheckJS: String =
|
private const val a2uiReadyCheckJS: String =
|
||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
return !!globalThis.clawdbotA2UI && typeof globalThis.clawdbotA2UI.applyMessages === 'function';
|
return !!globalThis.clawdisA2UI && typeof globalThis.clawdisA2UI.applyMessages === 'function';
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1168,8 +866,8 @@ private const val a2uiResetJS: String =
|
|||||||
"""
|
"""
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||||
return globalThis.clawdbotA2UI.reset();
|
return globalThis.clawdisA2UI.reset();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
@@ -1180,9 +878,9 @@ private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
|||||||
return """
|
return """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
if (!globalThis.clawdbotA2UI) return { ok: false, error: "missing clawdbotA2UI" };
|
if (!globalThis.clawdisA2UI) return { ok: false, error: "missing clawdisA2UI" };
|
||||||
const messages = $messagesJson;
|
const messages = $messagesJson;
|
||||||
return globalThis.clawdbotA2UI.applyMessages(messages);
|
return globalThis.clawdisA2UI.applyMessages(messages);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: String(e?.message ?? e) };
|
return { ok: false, error: String(e?.message ?? e) };
|
||||||
}
|
}
|
||||||
@@ -1207,12 +905,3 @@ private fun JsonElement?.asStringOrNull(): String? =
|
|||||||
is JsonPrimitive -> content
|
is JsonPrimitive -> content
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseHexColorArgb(raw: String?): Long? {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
if (trimmed.isEmpty()) return null
|
|
||||||
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
|
|
||||||
if (hex.length != 6) return null
|
|
||||||
val rgb = hex.toLongOrNull(16) ?: return null
|
|
||||||
return 0xFF000000L or rgb
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -115,7 +115,7 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
|
|
||||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||||
val labels = permissions.map { permissionLabel(it) }
|
val labels = permissions.map { permissionLabel(it) }
|
||||||
return "Clawdbot needs ${labels.joinToString(", ")} permissions to continue."
|
return "Clawdis needs ${labels.joinToString(", ")} to capture camera media."
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildSettingsMessage(permissions: List<String>): String {
|
private fun buildSettingsMessage(permissions: List<String>): String {
|
||||||
@@ -127,7 +127,6 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
|||||||
when (permission) {
|
when (permission) {
|
||||||
Manifest.permission.CAMERA -> "Camera"
|
Manifest.permission.CAMERA -> "Camera"
|
||||||
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
Manifest.permission.RECORD_AUDIO -> "Microphone"
|
||||||
Manifest.permission.SEND_SMS -> "SMS"
|
|
||||||
else -> permission
|
else -> permission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -55,7 +55,7 @@ class ScreenCaptureRequester(private val activity: ComponentActivity) {
|
|||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
AlertDialog.Builder(activity)
|
AlertDialog.Builder(activity)
|
||||||
.setTitle("Screen recording required")
|
.setTitle("Screen recording required")
|
||||||
.setMessage("Clawdbot needs to record the screen for this command.")
|
.setMessage("Clawdis needs to record the screen for this command.")
|
||||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||||
.setOnCancelListener { cont.resume(false) }
|
.setOnCancelListener { cont.resume(false) }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@file:Suppress("DEPRECATION")
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -31,7 +31,7 @@ class SecurePrefs(context: Context) {
|
|||||||
private val prefs =
|
private val prefs =
|
||||||
EncryptedSharedPreferences.create(
|
EncryptedSharedPreferences.create(
|
||||||
context,
|
context,
|
||||||
"clawdbot.node.secure",
|
"clawdis.node.secure",
|
||||||
masterKey,
|
masterKey,
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
@@ -47,14 +47,6 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
private val _cameraEnabled = MutableStateFlow(prefs.getBoolean("camera.enabled", true))
|
||||||
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
|
||||||
|
|
||||||
private val _locationMode =
|
|
||||||
MutableStateFlow(LocationMode.fromRawValue(prefs.getString("location.enabledMode", "off")))
|
|
||||||
val locationMode: StateFlow<LocationMode> = _locationMode
|
|
||||||
|
|
||||||
private val _locationPreciseEnabled =
|
|
||||||
MutableStateFlow(prefs.getBoolean("location.preciseEnabled", true))
|
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = _locationPreciseEnabled
|
|
||||||
|
|
||||||
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
private val _preventSleep = MutableStateFlow(prefs.getBoolean("screen.preventSleep", true))
|
||||||
val preventSleep: StateFlow<Boolean> = _preventSleep
|
val preventSleep: StateFlow<Boolean> = _preventSleep
|
||||||
|
|
||||||
@@ -81,9 +73,6 @@ class SecurePrefs(context: Context) {
|
|||||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||||
|
|
||||||
private val _talkEnabled = MutableStateFlow(prefs.getBoolean("talk.enabled", false))
|
|
||||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
|
||||||
|
|
||||||
fun setLastDiscoveredStableId(value: String) {
|
fun setLastDiscoveredStableId(value: String) {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
prefs.edit { putString("bridge.lastDiscoveredStableId", trimmed) }
|
||||||
@@ -101,16 +90,6 @@ class SecurePrefs(context: Context) {
|
|||||||
_cameraEnabled.value = value
|
_cameraEnabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
|
||||||
prefs.edit { putString("location.enabledMode", mode.rawValue) }
|
|
||||||
_locationMode.value = mode
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("location.preciseEnabled", value) }
|
|
||||||
_locationPreciseEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
prefs.edit { putBoolean("screen.preventSleep", value) }
|
prefs.edit { putBoolean("screen.preventSleep", value) }
|
||||||
_preventSleep.value = value
|
_preventSleep.value = value
|
||||||
@@ -147,16 +126,6 @@ class SecurePrefs(context: Context) {
|
|||||||
prefs.edit { putString(key, token.trim()) }
|
prefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBridgeTlsFingerprint(stableId: String): String? {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
return prefs.getString(key, null)?.trim()?.takeIf { it.isNotEmpty() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveBridgeTlsFingerprint(stableId: String, fingerprint: String) {
|
|
||||||
val key = "bridge.tls.$stableId"
|
|
||||||
prefs.edit { putString(key, fingerprint.trim()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadOrCreateInstanceId(): String {
|
private fun loadOrCreateInstanceId(): String {
|
||||||
val existing = prefs.getString("node.instanceId", null)?.trim()
|
val existing = prefs.getString("node.instanceId", null)?.trim()
|
||||||
if (!existing.isNullOrBlank()) return existing
|
if (!existing.isNullOrBlank()) return existing
|
||||||
@@ -189,11 +158,6 @@ class SecurePrefs(context: Context) {
|
|||||||
_voiceWakeMode.value = mode
|
_voiceWakeMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setTalkEnabled(value: Boolean) {
|
|
||||||
prefs.edit { putBoolean("talk.enabled", value) }
|
|
||||||
_talkEnabled.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
private fun loadVoiceWakeMode(): VoiceWakeMode {
|
||||||
val raw = prefs.getString(voiceWakeModeKey, null)
|
val raw = prefs.getString(voiceWakeModeKey, null)
|
||||||
val resolved = VoiceWakeMode.fromRawValue(raw)
|
val resolved = VoiceWakeMode.fromRawValue(raw)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
enum class VoiceWakeMode(val rawValue: String) {
|
enum class VoiceWakeMode(val rawValue: String) {
|
||||||
Off("off"),
|
Off("off"),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android
|
package com.steipete.clawdis.node
|
||||||
|
|
||||||
object WakeWords {
|
object WakeWords {
|
||||||
const val maxWords: Int = 32
|
const val maxWords: Int = 32
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
object BonjourEscapes {
|
object BonjourEscapes {
|
||||||
fun decode(input: String): String {
|
fun decode(input: String): String {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
@@ -51,9 +51,9 @@ class BridgeDiscovery(
|
|||||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||||
private val dns = DnsResolver.getInstance()
|
private val dns = DnsResolver.getInstance()
|
||||||
private val serviceType = "_clawdbot-bridge._tcp."
|
private val serviceType = "_clawdis-bridge._tcp."
|
||||||
private val wideAreaDomain = "clawdbot.internal."
|
private val wideAreaDomain = "clawdis.internal."
|
||||||
private val logTag = "Clawdbot/BridgeDiscovery"
|
private val logTag = "Clawdis/BridgeDiscovery"
|
||||||
|
|
||||||
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
private val localById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
private val unicastById = ConcurrentHashMap<String, BridgeEndpoint>()
|
||||||
@@ -130,40 +130,20 @@ class BridgeDiscovery(
|
|||||||
object : NsdManager.ResolveListener {
|
object : NsdManager.ResolveListener {
|
||||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||||
|
|
||||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||||
val host = resolved.host?.hostAddress ?: return
|
val host = resolved.host?.hostAddress ?: return
|
||||||
val port = resolved.port
|
val port = resolved.port
|
||||||
if (port <= 0) return
|
if (port <= 0) return
|
||||||
|
|
||||||
val rawServiceName = resolved.serviceName
|
val rawServiceName = resolved.serviceName
|
||||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||||
val lanHost = txt(resolved, "lanHost")
|
val id = stableId(serviceName, "local.")
|
||||||
val tailnetDns = txt(resolved, "tailnetDns")
|
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
publish()
|
||||||
val bridgePort = txtInt(resolved, "bridgePort")
|
}
|
||||||
val canvasPort = txtInt(resolved, "canvasPort")
|
},
|
||||||
val tlsEnabled = txtBool(resolved, "bridgeTls")
|
)
|
||||||
val tlsFingerprint = txt(resolved, "bridgeTlsSha256")
|
|
||||||
val id = stableId(serviceName, "local.")
|
|
||||||
localById[id] =
|
|
||||||
BridgeEndpoint(
|
|
||||||
stableId = id,
|
|
||||||
name = displayName,
|
|
||||||
host = host,
|
|
||||||
port = port,
|
|
||||||
lanHost = lanHost,
|
|
||||||
tailnetDns = tailnetDns,
|
|
||||||
gatewayPort = gatewayPort,
|
|
||||||
bridgePort = bridgePort,
|
|
||||||
canvasPort = canvasPort,
|
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
@@ -209,15 +189,6 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtInt(info: NsdServiceInfo, key: String): Int? {
|
|
||||||
return txt(info, key)?.toIntOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
|
|
||||||
val raw = txt(info, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refreshUnicast(domain: String) {
|
private suspend fun refreshUnicast(domain: String) {
|
||||||
val ptrName = "${serviceType}${domain}"
|
val ptrName = "${serviceType}${domain}"
|
||||||
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
val ptrMsg = lookupUnicastMessage(ptrName, Type.PTR) ?: return
|
||||||
@@ -256,28 +227,8 @@ class BridgeDiscovery(
|
|||||||
}
|
}
|
||||||
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
|
||||||
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
|
||||||
val lanHost = txtValue(txt, "lanHost")
|
|
||||||
val tailnetDns = txtValue(txt, "tailnetDns")
|
|
||||||
val gatewayPort = txtIntValue(txt, "gatewayPort")
|
|
||||||
val bridgePort = txtIntValue(txt, "bridgePort")
|
|
||||||
val canvasPort = txtIntValue(txt, "canvasPort")
|
|
||||||
val tlsEnabled = txtBoolValue(txt, "bridgeTls")
|
|
||||||
val tlsFingerprint = txtValue(txt, "bridgeTlsSha256")
|
|
||||||
val id = stableId(instanceName, domain)
|
val id = stableId(instanceName, domain)
|
||||||
next[id] =
|
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
|
||||||
BridgeEndpoint(
|
|
||||||
stableId = id,
|
|
||||||
name = displayName,
|
|
||||||
host = host,
|
|
||||||
port = port,
|
|
||||||
lanHost = lanHost,
|
|
||||||
tailnetDns = tailnetDns,
|
|
||||||
gatewayPort = gatewayPort,
|
|
||||||
bridgePort = bridgePort,
|
|
||||||
canvasPort = canvasPort,
|
|
||||||
tlsEnabled = tlsEnabled,
|
|
||||||
tlsFingerprintSha256 = tlsFingerprint,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unicastById.clear()
|
unicastById.clear()
|
||||||
@@ -483,15 +434,6 @@ class BridgeDiscovery(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun txtIntValue(records: List<TXTRecord>, key: String): Int? {
|
|
||||||
return txtValue(records, key)?.toIntOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun txtBoolValue(records: List<TXTRecord>, key: String): Boolean {
|
|
||||||
val raw = txtValue(records, key)?.trim()?.lowercase() ?: return false
|
|
||||||
return raw == "1" || raw == "true" || raw == "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeDnsTxtString(raw: String): String {
|
private fun decodeDnsTxtString(raw: String): String {
|
||||||
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
// dnsjava treats TXT as opaque bytes and decodes as ISO-8859-1 to preserve bytes.
|
||||||
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
// Our TXT payload is UTF-8 (written by the gateway), so re-decode when possible.
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
data class BridgeEndpoint(
|
data class BridgeEndpoint(
|
||||||
val stableId: String,
|
val stableId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val lanHost: String? = null,
|
|
||||||
val tailnetDns: String? = null,
|
|
||||||
val gatewayPort: Int? = null,
|
|
||||||
val bridgePort: Int? = null,
|
|
||||||
val canvasPort: Int? = null,
|
|
||||||
val tlsEnabled: Boolean = false,
|
|
||||||
val tlsFingerprintSha256: String? = null,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun manual(host: String, port: Int): BridgeEndpoint =
|
fun manual(host: String, port: Int): BridgeEndpoint =
|
||||||
@@ -20,8 +13,7 @@ data class BridgeEndpoint(
|
|||||||
name = "$host:$port",
|
name = "$host:$port",
|
||||||
host = host,
|
host = host,
|
||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
|
||||||
tlsFingerprintSha256 = null,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.JsonNull
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.BufferedWriter
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
class BridgePairingClient {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
data class Hello(
|
||||||
|
val nodeId: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val token: String?,
|
||||||
|
val platform: String?,
|
||||||
|
val version: String?,
|
||||||
|
val deviceFamily: String?,
|
||||||
|
val modelIdentifier: String?,
|
||||||
|
val caps: List<String>?,
|
||||||
|
val commands: List<String>?,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PairResult(val ok: Boolean, val token: String?, val error: String? = null)
|
||||||
|
|
||||||
|
suspend fun pairAndHello(endpoint: BridgeEndpoint, hello: Hello): PairResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val socket = Socket()
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
|
socket.soTimeout = 60_000
|
||||||
|
|
||||||
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun send(line: String) {
|
||||||
|
writer.write(line)
|
||||||
|
writer.write("\n")
|
||||||
|
writer.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendJson(obj: JsonObject) = send(obj.toString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstObj = json.parseToJsonElement(reader.readLine()).asObjectOrNull()
|
||||||
|
?: return@withContext PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
when (firstObj["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> PairResult(ok = true, token = hello.token)
|
||||||
|
"error" -> {
|
||||||
|
val code = firstObj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val message = firstObj["message"].asStringOrNull() ?: "pairing required"
|
||||||
|
if (code != "NOT_PAIRED" && code != "UNAUTHORIZED") {
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$code: $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("pair-request"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val nextLine = reader.readLine() ?: break
|
||||||
|
val next = json.parseToJsonElement(nextLine).asObjectOrNull() ?: continue
|
||||||
|
when (next["type"].asStringOrNull()) {
|
||||||
|
"pair-ok" -> {
|
||||||
|
val token = next["token"].asStringOrNull()
|
||||||
|
return@withContext PairResult(ok = !token.isNullOrBlank(), token = token)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val c = next["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val m = next["message"].asStringOrNull() ?: "pairing failed"
|
||||||
|
return@withContext PairResult(ok = false, token = null, error = "$c: $m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PairResult(ok = false, token = null, error = "pairing failed")
|
||||||
|
}
|
||||||
|
else -> PairResult(ok = false, token = null, error = "unexpected bridge response")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
|
private fun JsonElement?.asStringOrNull(): String? =
|
||||||
|
when (this) {
|
||||||
|
is JsonNull -> null
|
||||||
|
is JsonPrimitive -> content
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.bridge
|
package com.steipete.clawdis.node.bridge
|
||||||
|
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -11,7 +11,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import com.clawdbot.android.BuildConfig
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonArray
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -24,18 +23,16 @@ import java.io.BufferedWriter
|
|||||||
import java.io.InputStreamReader
|
import java.io.InputStreamReader
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.URI
|
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
class BridgeSession(
|
class BridgeSession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val onConnected: (serverName: String, remoteAddress: String?, mainSessionKey: String?) -> Unit,
|
private val onConnected: (serverName: String, remoteAddress: String?) -> Unit,
|
||||||
private val onDisconnected: (message: String) -> Unit,
|
private val onDisconnected: (message: String) -> Unit,
|
||||||
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
private val onEvent: (event: String, payloadJson: String?) -> Unit,
|
||||||
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
private val onInvoke: suspend (InvokeRequest) -> InvokeResult,
|
||||||
private val onTlsFingerprint: ((stableId: String, fingerprint: String) -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
data class Hello(
|
data class Hello(
|
||||||
val nodeId: String,
|
val nodeId: String,
|
||||||
@@ -65,46 +62,28 @@ class BridgeSession(
|
|||||||
private val writeLock = Mutex()
|
private val writeLock = Mutex()
|
||||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||||
@Volatile private var canvasHostUrl: String? = null
|
@Volatile private var canvasHostUrl: String? = null
|
||||||
@Volatile private var mainSessionKey: String? = null
|
|
||||||
|
|
||||||
private data class DesiredConnection(
|
private var desired: Pair<BridgeEndpoint, Hello>? = null
|
||||||
val endpoint: BridgeEndpoint,
|
|
||||||
val hello: Hello,
|
|
||||||
val tls: BridgeTlsParams?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private var desired: DesiredConnection? = null
|
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
|
|
||||||
fun connect(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams? = null) {
|
fun connect(endpoint: BridgeEndpoint, hello: Hello) {
|
||||||
desired = DesiredConnection(endpoint, hello, tls)
|
desired = endpoint to hello
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updateHello(hello: Hello) {
|
|
||||||
val target = desired ?: return
|
|
||||||
desired = target.copy(hello = hello)
|
|
||||||
val conn = currentConnection ?: return
|
|
||||||
conn.sendJson(buildHelloJson(hello))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
desired = null
|
desired = null
|
||||||
// Unblock connectOnce() read loop. Coroutine cancellation alone won't interrupt BufferedReader.readLine().
|
|
||||||
currentConnection?.closeQuietly()
|
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
job?.cancelAndJoin()
|
job?.cancelAndJoin()
|
||||||
job = null
|
job = null
|
||||||
canvasHostUrl = null
|
canvasHostUrl = null
|
||||||
mainSessionKey = null
|
|
||||||
onDisconnected("Offline")
|
onDisconnected("Offline")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
fun currentCanvasHostUrl(): String? = canvasHostUrl
|
||||||
fun currentMainSessionKey(): String? = mainSessionKey
|
|
||||||
|
|
||||||
suspend fun sendEvent(event: String, payloadJson: String?) {
|
suspend fun sendEvent(event: String, payloadJson: String?) {
|
||||||
val conn = currentConnection ?: return
|
val conn = currentConnection ?: return
|
||||||
@@ -172,10 +151,10 @@ class BridgeSession(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val (endpoint, hello, tls) = target
|
val (endpoint, hello) = target
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
connectOnce(endpoint, hello, tls)
|
connectOnce(endpoint, hello)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -199,76 +178,61 @@ class BridgeSession(
|
|||||||
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
return InvokeResult.error(code = "UNAVAILABLE", message = msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) =
|
private suspend fun connectOnce(endpoint: BridgeEndpoint, hello: Hello) =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (tls != null) {
|
val socket = Socket()
|
||||||
try {
|
socket.tcpNoDelay = true
|
||||||
connectWithSocket(endpoint, hello, tls)
|
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
||||||
return@withContext
|
socket.soTimeout = 0
|
||||||
} catch (err: Throwable) {
|
|
||||||
if (tls.required) throw err
|
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
||||||
|
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
||||||
|
|
||||||
|
val conn = Connection(socket, reader, writer, writeLock)
|
||||||
|
currentConnection = conn
|
||||||
|
|
||||||
|
try {
|
||||||
|
conn.sendJson(
|
||||||
|
buildJsonObject {
|
||||||
|
put("type", JsonPrimitive("hello"))
|
||||||
|
put("nodeId", JsonPrimitive(hello.nodeId))
|
||||||
|
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
||||||
|
hello.token?.let { put("token", JsonPrimitive(it)) }
|
||||||
|
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
||||||
|
hello.version?.let { put("version", JsonPrimitive(it)) }
|
||||||
|
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
||||||
|
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
|
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
||||||
|
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
||||||
|
?: throw IllegalStateException("unexpected bridge response")
|
||||||
|
when (first["type"].asStringOrNull()) {
|
||||||
|
"hello-ok" -> {
|
||||||
|
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
||||||
|
canvasHostUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
||||||
|
onConnected(name, conn.remoteAddress)
|
||||||
|
}
|
||||||
|
"error" -> {
|
||||||
|
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
|
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
||||||
|
throw IllegalStateException("$code: $msg")
|
||||||
|
}
|
||||||
|
else -> throw IllegalStateException("unexpected bridge response")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
connectWithSocket(endpoint, hello, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun connectWithSocket(endpoint: BridgeEndpoint, hello: Hello, tls: BridgeTlsParams?) {
|
while (scope.isActive) {
|
||||||
val socket =
|
val line = reader.readLine() ?: break
|
||||||
createBridgeSocket(tls) { fingerprint ->
|
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
||||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
when (frame["type"].asStringOrNull()) {
|
||||||
}
|
"event" -> {
|
||||||
socket.tcpNoDelay = true
|
val event = frame["event"].asStringOrNull() ?: return@withContext
|
||||||
socket.connect(InetSocketAddress(endpoint.host, endpoint.port), 8_000)
|
val payload = frame["payloadJSON"].asStringOrNull()
|
||||||
socket.soTimeout = 0
|
onEvent(event, payload)
|
||||||
startTlsHandshakeIfNeeded(socket)
|
|
||||||
|
|
||||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
|
|
||||||
val writer = BufferedWriter(OutputStreamWriter(socket.getOutputStream(), Charsets.UTF_8))
|
|
||||||
|
|
||||||
val conn = Connection(socket, reader, writer, writeLock)
|
|
||||||
currentConnection = conn
|
|
||||||
|
|
||||||
try {
|
|
||||||
conn.sendJson(buildHelloJson(hello))
|
|
||||||
|
|
||||||
val firstLine = reader.readLine() ?: throw IllegalStateException("bridge closed connection")
|
|
||||||
val first = json.parseToJsonElement(firstLine).asObjectOrNull()
|
|
||||||
?: throw IllegalStateException("unexpected bridge response")
|
|
||||||
when (first["type"].asStringOrNull()) {
|
|
||||||
"hello-ok" -> {
|
|
||||||
val name = first["serverName"].asStringOrNull() ?: "Bridge"
|
|
||||||
val rawCanvasUrl = first["canvasHostUrl"].asStringOrNull()?.trim()?.ifEmpty { null }
|
|
||||||
val rawMainSessionKey = first["mainSessionKey"].asStringOrNull()?.trim()?.ifEmpty { null }
|
|
||||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvasUrl, endpoint)
|
|
||||||
mainSessionKey = rawMainSessionKey
|
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
// Local JVM unit tests use android.jar stubs; Log.d can throw "not mocked".
|
|
||||||
runCatching {
|
|
||||||
android.util.Log.d(
|
|
||||||
"ClawdbotBridge",
|
|
||||||
"canvasHostUrl resolved=${canvasHostUrl ?: "none"} (raw=${rawCanvasUrl ?: "none"})",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
onConnected(name, conn.remoteAddress, rawMainSessionKey)
|
|
||||||
}
|
|
||||||
"error" -> {
|
|
||||||
val code = first["code"].asStringOrNull() ?: "UNAVAILABLE"
|
|
||||||
val msg = first["message"].asStringOrNull() ?: "connect failed"
|
|
||||||
throw IllegalStateException("$code: $msg")
|
|
||||||
}
|
|
||||||
else -> throw IllegalStateException("unexpected bridge response")
|
|
||||||
}
|
|
||||||
|
|
||||||
while (scope.isActive) {
|
|
||||||
val line = reader.readLine() ?: break
|
|
||||||
val frame = json.parseToJsonElement(line).asObjectOrNull() ?: continue
|
|
||||||
when (frame["type"].asStringOrNull()) {
|
|
||||||
"event" -> {
|
|
||||||
val event = frame["event"].asStringOrNull() ?: continue
|
|
||||||
val payload = frame["payloadJSON"].asStringOrNull()
|
|
||||||
onEvent(event, payload)
|
|
||||||
}
|
|
||||||
"ping" -> {
|
"ping" -> {
|
||||||
val id = frame["id"].asStringOrNull() ?: ""
|
val id = frame["id"].asStringOrNull() ?: ""
|
||||||
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
conn.sendJson(buildJsonObject { put("type", JsonPrimitive("pong")); put("id", JsonPrimitive(id)) })
|
||||||
@@ -314,65 +278,20 @@ class BridgeSession(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"invoke-res" -> {
|
"invoke-res" -> {
|
||||||
// gateway->node only (ignore)
|
// gateway->node only (ignore)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
currentConnection = null
|
||||||
|
for ((_, waiter) in pending) {
|
||||||
|
waiter.cancel()
|
||||||
|
}
|
||||||
|
pending.clear()
|
||||||
|
conn.closeQuietly()
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
currentConnection = null
|
|
||||||
for ((_, waiter) in pending) {
|
|
||||||
waiter.cancel()
|
|
||||||
}
|
|
||||||
pending.clear()
|
|
||||||
conn.closeQuietly()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildHelloJson(hello: Hello): JsonObject =
|
|
||||||
buildJsonObject {
|
|
||||||
put("type", JsonPrimitive("hello"))
|
|
||||||
put("nodeId", JsonPrimitive(hello.nodeId))
|
|
||||||
hello.displayName?.let { put("displayName", JsonPrimitive(it)) }
|
|
||||||
hello.token?.let { put("token", JsonPrimitive(it)) }
|
|
||||||
hello.platform?.let { put("platform", JsonPrimitive(it)) }
|
|
||||||
hello.version?.let { put("version", JsonPrimitive(it)) }
|
|
||||||
hello.deviceFamily?.let { put("deviceFamily", JsonPrimitive(it)) }
|
|
||||||
hello.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
|
||||||
hello.caps?.let { put("caps", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
hello.commands?.let { put("commands", JsonArray(it.map(::JsonPrimitive))) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeCanvasHostUrl(raw: String?, endpoint: BridgeEndpoint): String? {
|
|
||||||
val trimmed = raw?.trim().orEmpty()
|
|
||||||
val parsed = trimmed.takeIf { it.isNotBlank() }?.let { runCatching { URI(it) }.getOrNull() }
|
|
||||||
val host = parsed?.host?.trim().orEmpty()
|
|
||||||
val port = parsed?.port ?: -1
|
|
||||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
|
||||||
|
|
||||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
val fallbackHost =
|
|
||||||
endpoint.tailnetDns?.trim().takeIf { !it.isNullOrEmpty() }
|
|
||||||
?: endpoint.lanHost?.trim().takeIf { !it.isNullOrEmpty() }
|
|
||||||
?: endpoint.host.trim()
|
|
||||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
|
||||||
|
|
||||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
|
||||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
|
||||||
return "$scheme://$formattedHost:$fallbackPort"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isLoopbackHost(raw: String?): Boolean {
|
|
||||||
val host = raw?.trim()?.lowercase().orEmpty()
|
|
||||||
if (host.isEmpty()) return false
|
|
||||||
if (host == "localhost") return true
|
|
||||||
if (host == "::1") return true
|
|
||||||
if (host == "0.0.0.0" || host == "::") return true
|
|
||||||
return host.startsWith("127.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.clawdbot.android.chat
|
package com.steipete.clawdis.node.chat
|
||||||
|
|
||||||
import com.clawdbot.android.bridge.BridgeSession
|
import com.steipete.clawdis.node.bridge.BridgeSession
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -71,21 +71,12 @@ class ChatController(
|
|||||||
_sessionId.value = null
|
_sessionId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(sessionKey: String) {
|
fun load(sessionKey: String = "main") {
|
||||||
val key = sessionKey.trim().ifEmpty { "main" }
|
val key = sessionKey.trim().ifEmpty { "main" }
|
||||||
_sessionKey.value = key
|
_sessionKey.value = key
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyMainSessionKey(mainSessionKey: String) {
|
|
||||||
val trimmed = mainSessionKey.trim()
|
|
||||||
if (trimmed.isEmpty()) return
|
|
||||||
if (_sessionKey.value == trimmed) return
|
|
||||||
if (_sessionKey.value != "main") return
|
|
||||||
_sessionKey.value = trimmed
|
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
scope.launch { bootstrap(forceHealth = true) }
|
scope.launch { bootstrap(forceHealth = true) }
|
||||||
}
|
}
|
||||||
@@ -370,12 +361,10 @@ class ChatController(
|
|||||||
|
|
||||||
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
val ts = payload["ts"].asLongOrNull() ?: System.currentTimeMillis()
|
||||||
if (phase == "start") {
|
if (phase == "start") {
|
||||||
val args = data?.get("args").asObjectOrNull()
|
|
||||||
pendingToolCallsById[toolCallId] =
|
pendingToolCallsById[toolCallId] =
|
||||||
ChatPendingToolCall(
|
ChatPendingToolCall(
|
||||||
toolCallId = toolCallId,
|
toolCallId = toolCallId,
|
||||||
name = name,
|
name = name,
|
||||||
args = args,
|
|
||||||
startedAtMs = ts,
|
startedAtMs = ts,
|
||||||
isError = null,
|
isError = null,
|
||||||
)
|
)
|
||||||
@@ -480,8 +469,7 @@ class ChatController(
|
|||||||
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
val key = obj["key"].asStringOrNull()?.trim().orEmpty()
|
||||||
if (key.isEmpty()) return@mapNotNull null
|
if (key.isEmpty()) return@mapNotNull null
|
||||||
val updatedAt = obj["updatedAt"].asLongOrNull()
|
val updatedAt = obj["updatedAt"].asLongOrNull()
|
||||||
val displayName = obj["displayName"].asStringOrNull()?.trim()
|
ChatSessionEntry(key = key, updatedAtMs = updatedAt)
|
||||||
ChatSessionEntry(key = key, updatedAtMs = updatedAt, displayName = displayName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.chat
|
package com.steipete.clawdis.node.chat
|
||||||
|
|
||||||
data class ChatMessage(
|
data class ChatMessage(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -18,7 +18,6 @@ data class ChatMessageContent(
|
|||||||
data class ChatPendingToolCall(
|
data class ChatPendingToolCall(
|
||||||
val toolCallId: String,
|
val toolCallId: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val args: kotlinx.serialization.json.JsonObject? = null,
|
|
||||||
val startedAtMs: Long,
|
val startedAtMs: Long,
|
||||||
val isError: Boolean? = null,
|
val isError: Boolean? = null,
|
||||||
)
|
)
|
||||||
@@ -26,7 +25,6 @@ data class ChatPendingToolCall(
|
|||||||
data class ChatSessionEntry(
|
data class ChatSessionEntry(
|
||||||
val key: String,
|
val key: String,
|
||||||
val updatedAtMs: Long?,
|
val updatedAtMs: Long?,
|
||||||
val displayName: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ChatHistory(
|
data class ChatHistory(
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
package com.clawdbot.android.node
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Matrix
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
@@ -22,7 +20,7 @@ import androidx.camera.video.VideoRecordEvent
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.ContextCompat.checkSelfPermission
|
import androidx.core.content.ContextCompat.checkSelfPermission
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
import com.clawdbot.android.PermissionRequester
|
import com.steipete.clawdis.node.PermissionRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -30,7 +28,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.math.roundToInt
|
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@@ -88,51 +85,28 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(owner, selector, capture)
|
provider.bindToLifecycle(owner, selector, capture)
|
||||||
|
|
||||||
val (bytes, orientation) = capture.takeJpegWithExif(context.mainExecutor())
|
val bytes = capture.takeJpegBytes(context.mainExecutor())
|
||||||
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
|
||||||
val scaled =
|
val scaled =
|
||||||
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
if (maxWidth != null && maxWidth > 0 && decoded.width > maxWidth) {
|
||||||
val h =
|
val h =
|
||||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
(decoded.height.toDouble() * (maxWidth.toDouble() / decoded.width.toDouble()))
|
||||||
.toInt()
|
.toInt()
|
||||||
.coerceAtLeast(1)
|
.coerceAtLeast(1)
|
||||||
rotated.scale(maxWidth, h)
|
decoded.scale(maxWidth, h)
|
||||||
} else {
|
} else {
|
||||||
rotated
|
decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
val maxPayloadBytes = 5 * 1024 * 1024
|
val out = ByteArrayOutputStream()
|
||||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
val jpegQuality = (quality * 100.0).toInt().coerceIn(10, 100)
|
||||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
if (!scaled.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)) {
|
||||||
val result =
|
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||||
JpegSizeLimiter.compressToLimit(
|
}
|
||||||
initialWidth = scaled.width,
|
val base64 = Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||||
initialHeight = scaled.height,
|
|
||||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
|
||||||
maxBytes = maxEncodedBytes,
|
|
||||||
encode = { width, height, q ->
|
|
||||||
val bitmap =
|
|
||||||
if (width == scaled.width && height == scaled.height) {
|
|
||||||
scaled
|
|
||||||
} else {
|
|
||||||
scaled.scale(width, height)
|
|
||||||
}
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
|
||||||
if (bitmap !== scaled) bitmap.recycle()
|
|
||||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
|
||||||
}
|
|
||||||
if (bitmap !== scaled) {
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
|
||||||
out.toByteArray()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
|
||||||
Payload(
|
Payload(
|
||||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
"""{"format":"jpg","base64":"$base64","width":${scaled.width},"height":${scaled.height}}""",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +129,7 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||||
|
|
||||||
val file = File.createTempFile("clawdbot-clip-", ".mp4")
|
val file = File.createTempFile("clawdis-clip-", ".mp4")
|
||||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||||
|
|
||||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||||
@@ -197,31 +171,6 @@ class CameraCaptureManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
|
||||||
val matrix = Matrix()
|
|
||||||
when (orientation) {
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
|
||||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
||||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
|
||||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
||||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
||||||
matrix.postRotate(90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
||||||
matrix.postRotate(-90f)
|
|
||||||
matrix.postScale(-1f, 1f)
|
|
||||||
}
|
|
||||||
else -> return bitmap
|
|
||||||
}
|
|
||||||
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
||||||
if (rotated !== bitmap) {
|
|
||||||
bitmap.recycle()
|
|
||||||
}
|
|
||||||
return rotated
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseFacing(paramsJson: String?): String? =
|
private fun parseFacing(paramsJson: String?): String? =
|
||||||
when {
|
when {
|
||||||
paramsJson?.contains("\"front\"") == true -> "front"
|
paramsJson?.contains("\"front\"") == true -> "front"
|
||||||
@@ -282,29 +231,22 @@ private suspend fun Context.cameraProvider(): ProcessCameraProvider =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns (jpegBytes, exifOrientation) so caller can rotate the decoded bitmap. */
|
private suspend fun ImageCapture.takeJpegBytes(executor: Executor): ByteArray =
|
||||||
private suspend fun ImageCapture.takeJpegWithExif(executor: Executor): Pair<ByteArray, Int> =
|
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val file = File.createTempFile("clawdbot-snap-", ".jpg")
|
val file = File.createTempFile("clawdis-snap-", ".jpg")
|
||||||
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
val options = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
takePicture(
|
takePicture(
|
||||||
options,
|
options,
|
||||||
executor,
|
executor,
|
||||||
object : ImageCapture.OnImageSavedCallback {
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
override fun onError(exception: ImageCaptureException) {
|
override fun onError(exception: ImageCaptureException) {
|
||||||
file.delete()
|
|
||||||
cont.resumeWithException(exception)
|
cont.resumeWithException(exception)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
|
||||||
try {
|
try {
|
||||||
val exif = ExifInterface(file.absolutePath)
|
|
||||||
val orientation = exif.getAttributeInt(
|
|
||||||
ExifInterface.TAG_ORIENTATION,
|
|
||||||
ExifInterface.ORIENTATION_NORMAL,
|
|
||||||
)
|
|
||||||
val bytes = file.readBytes()
|
val bytes = file.readBytes()
|
||||||
cont.resume(Pair(bytes, orientation))
|
cont.resume(bytes)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
cont.resumeWithException(e)
|
cont.resumeWithException(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package com.clawdbot.android.node
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import androidx.core.graphics.createBitmap
|
import androidx.core.graphics.createBitmap
|
||||||
import androidx.core.graphics.scale
|
import androidx.core.graphics.scale
|
||||||
@@ -17,7 +16,6 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import com.clawdbot.android.BuildConfig
|
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CanvasController {
|
class CanvasController {
|
||||||
@@ -83,14 +81,8 @@ class CanvasController {
|
|||||||
val currentUrl = url
|
val currentUrl = url
|
||||||
withWebViewOnMain { wv ->
|
withWebViewOnMain { wv ->
|
||||||
if (currentUrl == null) {
|
if (currentUrl == null) {
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.d("ClawdbotCanvas", "load scaffold: $scaffoldAssetUrl")
|
|
||||||
}
|
|
||||||
wv.loadUrl(scaffoldAssetUrl)
|
wv.loadUrl(scaffoldAssetUrl)
|
||||||
} else {
|
} else {
|
||||||
if (BuildConfig.DEBUG) {
|
|
||||||
Log.d("ClawdbotCanvas", "load url: $currentUrl")
|
|
||||||
}
|
|
||||||
wv.loadUrl(currentUrl)
|
wv.loadUrl(currentUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +98,7 @@ class CanvasController {
|
|||||||
val js = """
|
val js = """
|
||||||
(() => {
|
(() => {
|
||||||
try {
|
try {
|
||||||
const api = globalThis.__clawdbot;
|
const api = globalThis.__clawdis;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||||
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
api.setDebugStatusEnabled(${if (enabled) "true" else "false"});
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
package com.clawdbot.android.node
|
package com.steipete.clawdis.node.node
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.MediaRecorder
|
import android.media.MediaRecorder
|
||||||
import android.media.projection.MediaProjectionManager
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Build
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import com.clawdbot.android.ScreenCaptureRequester
|
import com.steipete.clawdis.node.ScreenCaptureRequester
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -17,13 +16,13 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
data class Payload(val payloadJson: String)
|
data class Payload(val payloadJson: String)
|
||||||
|
|
||||||
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
|
||||||
@Volatile private var permissionRequester: com.clawdbot.android.PermissionRequester? = null
|
@Volatile private var permissionRequester: com.steipete.clawdis.node.PermissionRequester? = null
|
||||||
|
|
||||||
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
|
||||||
screenCaptureRequester = requester
|
screenCaptureRequester = requester
|
||||||
}
|
}
|
||||||
|
|
||||||
fun attachPermissionRequester(requester: com.clawdbot.android.PermissionRequester) {
|
fun attachPermissionRequester(requester: com.steipete.clawdis.node.PermissionRequester) {
|
||||||
permissionRequester = requester
|
permissionRequester = requester
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,10 +62,10 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
val height = metrics.heightPixels
|
val height = metrics.heightPixels
|
||||||
val densityDpi = metrics.densityDpi
|
val densityDpi = metrics.densityDpi
|
||||||
|
|
||||||
val file = File.createTempFile("clawdbot-screen-", ".mp4")
|
val file = File.createTempFile("clawdis-screen-", ".mp4")
|
||||||
if (includeAudio) ensureMicPermission()
|
if (includeAudio) ensureMicPermission()
|
||||||
|
|
||||||
val recorder = createMediaRecorder()
|
val recorder = MediaRecorder()
|
||||||
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
|
||||||
try {
|
try {
|
||||||
if (includeAudio) {
|
if (includeAudio) {
|
||||||
@@ -90,7 +89,7 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
val surface = recorder.surface
|
val surface = recorder.surface
|
||||||
virtualDisplay =
|
virtualDisplay =
|
||||||
projection.createVirtualDisplay(
|
projection.createVirtualDisplay(
|
||||||
"clawdbot-screen",
|
"clawdis-screen",
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
densityDpi,
|
densityDpi,
|
||||||
@@ -122,8 +121,6 @@ class ScreenRecordManager(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
|
|
||||||
|
|
||||||
private suspend fun ensureMicPermission() {
|
private suspend fun ensureMicPermission() {
|
||||||
val granted =
|
val granted =
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.clawdbot.android.protocol
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
object ClawdbotCanvasA2UIAction {
|
object ClawdisCanvasA2UIAction {
|
||||||
fun extractActionName(userAction: JsonObject): String? {
|
fun extractActionName(userAction: JsonObject): String? {
|
||||||
val name =
|
val name =
|
||||||
(userAction["name"] as? JsonPrimitive)
|
(userAction["name"] as? JsonPrimitive)
|
||||||
@@ -61,6 +61,6 @@ object ClawdbotCanvasA2UIAction {
|
|||||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
val okLiteral = if (ok) "true" else "false"
|
val okLiteral = if (ok) "true" else "false"
|
||||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||||
return "window.dispatchEvent(new CustomEvent('clawdbot:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
return "window.dispatchEvent(new CustomEvent('clawdis:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
package com.clawdbot.android.protocol
|
package com.steipete.clawdis.node.protocol
|
||||||
|
|
||||||
enum class ClawdbotCapability(val rawValue: String) {
|
enum class ClawdisCapability(val rawValue: String) {
|
||||||
Canvas("canvas"),
|
Canvas("canvas"),
|
||||||
Camera("camera"),
|
Camera("camera"),
|
||||||
Screen("screen"),
|
Screen("screen"),
|
||||||
Sms("sms"),
|
|
||||||
VoiceWake("voiceWake"),
|
VoiceWake("voiceWake"),
|
||||||
Location("location"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdbotCanvasCommand(val rawValue: String) {
|
enum class ClawdisCanvasCommand(val rawValue: String) {
|
||||||
Present("canvas.present"),
|
Present("canvas.present"),
|
||||||
Hide("canvas.hide"),
|
Hide("canvas.hide"),
|
||||||
Navigate("canvas.navigate"),
|
Navigate("canvas.navigate"),
|
||||||
@@ -22,7 +20,7 @@ enum class ClawdbotCanvasCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
|
enum class ClawdisCanvasA2UICommand(val rawValue: String) {
|
||||||
Push("canvas.a2ui.push"),
|
Push("canvas.a2ui.push"),
|
||||||
PushJSONL("canvas.a2ui.pushJSONL"),
|
PushJSONL("canvas.a2ui.pushJSONL"),
|
||||||
Reset("canvas.a2ui.reset"),
|
Reset("canvas.a2ui.reset"),
|
||||||
@@ -33,7 +31,7 @@ enum class ClawdbotCanvasA2UICommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdbotCameraCommand(val rawValue: String) {
|
enum class ClawdisCameraCommand(val rawValue: String) {
|
||||||
Snap("camera.snap"),
|
Snap("camera.snap"),
|
||||||
Clip("camera.clip"),
|
Clip("camera.clip"),
|
||||||
;
|
;
|
||||||
@@ -43,7 +41,7 @@ enum class ClawdbotCameraCommand(val rawValue: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdbotScreenCommand(val rawValue: String) {
|
enum class ClawdisScreenCommand(val rawValue: String) {
|
||||||
Record("screen.record"),
|
Record("screen.record"),
|
||||||
;
|
;
|
||||||
|
|
||||||
@@ -51,21 +49,3 @@ enum class ClawdbotScreenCommand(val rawValue: String) {
|
|||||||
const val NamespacePrefix: String = "screen."
|
const val NamespacePrefix: String = "screen."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ClawdbotSmsCommand(val rawValue: String) {
|
|
||||||
Send("sms.send"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NamespacePrefix: String = "sms."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ClawdbotLocationCommand(val rawValue: String) {
|
|
||||||
Get("location.get"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NamespacePrefix: String = "location."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
|
import androidx.compose.material.icons.filled.FiberManualRecord
|
||||||
|
import androidx.compose.material.icons.filled.PhotoCamera
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.steipete.clawdis.node.CameraHudKind
|
||||||
|
import com.steipete.clawdis.node.CameraHudState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CameraHudOverlay(
|
||||||
|
hud: CameraHudState?,
|
||||||
|
flashToken: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
CameraFlash(token = flashToken)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = hud != null,
|
||||||
|
enter = slideInVertically(initialOffsetY = { -it / 2 }) + fadeIn(),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { -it / 2 }) + fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(start = 12.dp, top = 58.dp),
|
||||||
|
) {
|
||||||
|
if (hud != null) {
|
||||||
|
Toast(hud = hud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CameraFlash(token: Long) {
|
||||||
|
var alpha by remember { mutableFloatStateOf(0f) }
|
||||||
|
LaunchedEffect(token) {
|
||||||
|
if (token == 0L) return@LaunchedEffect
|
||||||
|
alpha = 0.85f
|
||||||
|
delay(110)
|
||||||
|
alpha = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.alpha(alpha)
|
||||||
|
.background(Color.White),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Toast(hud: CameraHudState) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||||
|
tonalElevation = 2.dp,
|
||||||
|
shadowElevation = 8.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
when (hud.kind) {
|
||||||
|
CameraHudKind.Photo -> {
|
||||||
|
Icon(Icons.Default.PhotoCamera, contentDescription = null)
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(14.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
CameraHudKind.Recording -> {
|
||||||
|
Icon(Icons.Default.FiberManualRecord, contentDescription = null, tint = Color.Red)
|
||||||
|
}
|
||||||
|
CameraHudKind.Success -> {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||||
|
}
|
||||||
|
CameraHudKind.Error -> {
|
||||||
|
Icon(Icons.Default.Error, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(10.dp))
|
||||||
|
Text(
|
||||||
|
text = hud.message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.clawdbot.android.ui
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import com.clawdbot.android.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
import com.clawdbot.android.ui.chat.ChatSheetContent
|
import com.steipete.clawdis.node.ui.chat.ChatSheetContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatSheet(viewModel: MainViewModel) {
|
fun ChatSheet(viewModel: MainViewModel) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -9,7 +9,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClawdbotTheme(content: @Composable () -> Unit) {
|
fun ClawdisTheme(content: @Composable () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isSystemInDarkTheme()
|
||||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ChatBubble
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.compose.ui.window.Popup
|
||||||
|
import androidx.compose.ui.window.PopupProperties
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RootScreen(viewModel: MainViewModel) {
|
||||||
|
var sheet by remember { mutableStateOf<Sheet?>(null) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val safeOverlayInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||||
|
val context = LocalContext.current
|
||||||
|
val serverName by viewModel.serverName.collectAsState()
|
||||||
|
val statusText by viewModel.statusText.collectAsState()
|
||||||
|
val cameraHud by viewModel.cameraHud.collectAsState()
|
||||||
|
val cameraFlashToken by viewModel.cameraFlashToken.collectAsState()
|
||||||
|
|
||||||
|
val bridgeState =
|
||||||
|
remember(serverName, statusText) {
|
||||||
|
when {
|
||||||
|
serverName != null -> BridgeState.Connected
|
||||||
|
statusText.contains("connecting", ignoreCase = true) ||
|
||||||
|
statusText.contains("reconnecting", ignoreCase = true) -> BridgeState.Connecting
|
||||||
|
statusText.contains("error", ignoreCase = true) -> BridgeState.Error
|
||||||
|
else -> BridgeState.Disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val voiceEnabled =
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
CanvasView(viewModel = viewModel, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Camera HUD (flash + toast) must be in a Popup to render above the WebView.
|
||||||
|
Popup(alignment = Alignment.Center, properties = PopupProperties(focusable = false)) {
|
||||||
|
CameraHudOverlay(hud = cameraHud, flashToken = cameraFlashToken, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the overlay buttons above the WebView canvas (AndroidView), otherwise they may not receive touches.
|
||||||
|
Popup(alignment = Alignment.TopStart, properties = PopupProperties(focusable = false)) {
|
||||||
|
StatusPill(
|
||||||
|
bridge = bridgeState,
|
||||||
|
voiceEnabled = voiceEnabled,
|
||||||
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(start = 12.dp, top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Popup(alignment = Alignment.TopEnd, properties = PopupProperties(focusable = false)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.windowInsetsPadding(safeOverlayInsets).padding(end = 12.dp, top = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
) {
|
||||||
|
OverlayIconButton(
|
||||||
|
onClick = { sheet = Sheet.Chat },
|
||||||
|
icon = { Icon(Icons.Default.ChatBubble, contentDescription = "Chat") },
|
||||||
|
)
|
||||||
|
|
||||||
|
OverlayIconButton(
|
||||||
|
onClick = { sheet = Sheet.Settings },
|
||||||
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentSheet = sheet
|
||||||
|
if (currentSheet != null) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { sheet = null },
|
||||||
|
sheetState = sheetState,
|
||||||
|
) {
|
||||||
|
when (currentSheet) {
|
||||||
|
Sheet.Chat -> ChatSheet(viewModel = viewModel)
|
||||||
|
Sheet.Settings -> SettingsSheet(viewModel = viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Sheet {
|
||||||
|
Chat,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OverlayIconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
icon: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
colors =
|
||||||
|
IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
|
containerColor = overlayContainerColor(),
|
||||||
|
contentColor = overlayIconColor(),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
private fun CanvasView(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isDebuggable = (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = {
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
// Some embedded web UIs (incl. the "background website") use localStorage/sessionStorage.
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
webViewClient =
|
||||||
|
object : WebViewClient() {
|
||||||
|
override fun onReceivedError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
error: WebResourceError,
|
||||||
|
) {
|
||||||
|
if (!isDebuggable) return
|
||||||
|
if (!request.isForMainFrame) return
|
||||||
|
Log.e("ClawdisWebView", "onReceivedError: ${error.errorCode} ${error.description} ${request.url}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedHttpError(
|
||||||
|
view: WebView,
|
||||||
|
request: WebResourceRequest,
|
||||||
|
errorResponse: WebResourceResponse,
|
||||||
|
) {
|
||||||
|
if (!isDebuggable) return
|
||||||
|
if (!request.isForMainFrame) return
|
||||||
|
Log.e(
|
||||||
|
"ClawdisWebView",
|
||||||
|
"onReceivedHttpError: ${errorResponse.statusCode} ${errorResponse.reasonPhrase} ${request.url}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String?) {
|
||||||
|
viewModel.canvas.onPageFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
setLayerType(View.LAYER_TYPE_HARDWARE, null)
|
||||||
|
|
||||||
|
val a2uiBridge =
|
||||||
|
CanvasA2UIActionBridge { payload ->
|
||||||
|
viewModel.handleCanvasA2UIActionFromWebView(payload)
|
||||||
|
}
|
||||||
|
addJavascriptInterface(a2uiBridge, CanvasA2UIActionBridge.interfaceName)
|
||||||
|
addJavascriptInterface(
|
||||||
|
CanvasA2UIActionLegacyBridge(a2uiBridge),
|
||||||
|
CanvasA2UIActionLegacyBridge.interfaceName,
|
||||||
|
)
|
||||||
|
viewModel.canvas.attach(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CanvasA2UIActionBridge(private val onMessage: (String) -> Unit) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun postMessage(payload: String?) {
|
||||||
|
val msg = payload?.trim().orEmpty()
|
||||||
|
if (msg.isEmpty()) return
|
||||||
|
onMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val interfaceName: String = "clawdisCanvasA2UIAction"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CanvasA2UIActionLegacyBridge(private val bridge: CanvasA2UIActionBridge) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun canvasAction(payload: String?) {
|
||||||
|
bridge.postMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun postMessage(payload: String?) {
|
||||||
|
bridge.postMessage(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val interfaceName: String = "Android"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
package com.clawdbot.android.ui
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -46,17 +41,14 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.clawdbot.android.BuildConfig
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
import com.clawdbot.android.LocationMode
|
import com.steipete.clawdis.node.NodeForegroundService
|
||||||
import com.clawdbot.android.MainViewModel
|
import com.steipete.clawdis.node.VoiceWakeMode
|
||||||
import com.clawdbot.android.NodeForegroundService
|
|
||||||
import com.clawdbot.android.VoiceWakeMode
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsSheet(viewModel: MainViewModel) {
|
fun SettingsSheet(viewModel: MainViewModel) {
|
||||||
@@ -64,8 +56,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val instanceId by viewModel.instanceId.collectAsState()
|
val instanceId by viewModel.instanceId.collectAsState()
|
||||||
val displayName by viewModel.displayName.collectAsState()
|
val displayName by viewModel.displayName.collectAsState()
|
||||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||||
val locationMode by viewModel.locationMode.collectAsState()
|
|
||||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
|
||||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||||
val wakeWords by viewModel.wakeWords.collectAsState()
|
val wakeWords by viewModel.wakeWords.collectAsState()
|
||||||
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
val voiceWakeMode by viewModel.voiceWakeMode.collectAsState()
|
||||||
@@ -84,22 +74,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||||
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
|
||||||
val deviceModel =
|
|
||||||
remember {
|
|
||||||
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
|
||||||
.joinToString(" ")
|
|
||||||
.trim()
|
|
||||||
.ifEmpty { "Android" }
|
|
||||||
}
|
|
||||||
val appVersion =
|
|
||||||
remember {
|
|
||||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
|
||||||
if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
|
||||||
"$versionName-dev"
|
|
||||||
} else {
|
|
||||||
versionName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||||
|
|
||||||
@@ -109,63 +83,11 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
viewModel.setCameraEnabled(cameraOk)
|
viewModel.setCameraEnabled(cameraOk)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
|
|
||||||
var pendingPreciseToggle by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val locationPermissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
|
|
||||||
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
|
|
||||||
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
|
|
||||||
val granted = fineOk || coarseOk
|
|
||||||
val requestedMode = pendingLocationMode
|
|
||||||
pendingLocationMode = null
|
|
||||||
|
|
||||||
if (pendingPreciseToggle) {
|
|
||||||
pendingPreciseToggle = false
|
|
||||||
viewModel.setLocationPreciseEnabled(fineOk)
|
|
||||||
return@rememberLauncherForActivityResult
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!granted) {
|
|
||||||
viewModel.setLocationMode(LocationMode.Off)
|
|
||||||
return@rememberLauncherForActivityResult
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedMode != null) {
|
|
||||||
viewModel.setLocationMode(requestedMode)
|
|
||||||
if (requestedMode == LocationMode.Always) {
|
|
||||||
val backgroundOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!backgroundOk) {
|
|
||||||
openAppSettings(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val audioPermissionLauncher =
|
val audioPermissionLauncher =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { _ ->
|
||||||
// Status text is handled by NodeRuntime.
|
// Status text is handled by NodeRuntime.
|
||||||
}
|
}
|
||||||
|
|
||||||
val smsPermissionAvailable =
|
|
||||||
remember {
|
|
||||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
|
||||||
}
|
|
||||||
var smsPermissionGranted by
|
|
||||||
remember {
|
|
||||||
mutableStateOf(
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val smsPermissionLauncher =
|
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
smsPermissionGranted = granted
|
|
||||||
viewModel.refreshBridgeHello()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setCameraEnabledChecked(checked: Boolean) {
|
fun setCameraEnabledChecked(checked: Boolean) {
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
viewModel.setCameraEnabled(false)
|
viewModel.setCameraEnabled(false)
|
||||||
@@ -182,47 +104,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestLocationPermissions(targetMode: LocationMode) {
|
|
||||||
val fineOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
val coarseOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (fineOk || coarseOk) {
|
|
||||||
viewModel.setLocationMode(targetMode)
|
|
||||||
if (targetMode == LocationMode.Always) {
|
|
||||||
val backgroundOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!backgroundOk) {
|
|
||||||
openAppSettings(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
pendingLocationMode = targetMode
|
|
||||||
locationPermissionLauncher.launch(
|
|
||||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPreciseLocationChecked(checked: Boolean) {
|
|
||||||
if (!checked) {
|
|
||||||
viewModel.setLocationPreciseEnabled(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val fineOk =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
if (fineOk) {
|
|
||||||
viewModel.setLocationPreciseEnabled(true)
|
|
||||||
} else {
|
|
||||||
pendingPreciseToggle = true
|
|
||||||
locationPermissionLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val visibleBridges =
|
val visibleBridges =
|
||||||
if (isConnected && remoteAddress != null) {
|
if (isConnected && remoteAddress != null) {
|
||||||
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
bridges.filterNot { "${it.host}:${it.port}" == remoteAddress }
|
||||||
@@ -250,7 +131,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
// Order parity: Node → Bridge → Voice → Camera → Messaging → Location → Screen.
|
// Order parity: Node → Bridge → Voice → Camera → Screen.
|
||||||
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Node", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -261,8 +142,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("Instance ID: $instanceId", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
item { Text("Device: $deviceModel", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
|
||||||
item { Text("Version: $appVersion", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
@@ -302,27 +181,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
item { Text("No bridges found yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
} else {
|
} else {
|
||||||
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
items(items = visibleBridges, key = { it.stableId }) { bridge ->
|
||||||
val detailLines =
|
|
||||||
buildList {
|
|
||||||
add("IP: ${bridge.host}:${bridge.port}")
|
|
||||||
bridge.lanHost?.let { add("LAN: $it") }
|
|
||||||
bridge.tailnetDns?.let { add("Tailnet: $it") }
|
|
||||||
if (bridge.gatewayPort != null || bridge.bridgePort != null || bridge.canvasPort != null) {
|
|
||||||
val gw = bridge.gatewayPort?.toString() ?: "—"
|
|
||||||
val br = (bridge.bridgePort ?: bridge.port).toString()
|
|
||||||
val canvas = bridge.canvasPort?.toString() ?: "—"
|
|
||||||
add("Ports: gw $gw · bridge $br · canvas $canvas")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(bridge.name) },
|
headlineContent = { Text(bridge.name) },
|
||||||
supportingContent = {
|
supportingContent = { Text("${bridge.host}:${bridge.port}") },
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
|
||||||
detailLines.forEach { line ->
|
|
||||||
Text(line, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -436,7 +297,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Foreground Only") },
|
headlineContent = { Text("Foreground Only") },
|
||||||
supportingContent = { Text("Listens only while Clawdbot is open.") },
|
supportingContent = { Text("Listens only while Clawdis is open.") },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
RadioButton(
|
RadioButton(
|
||||||
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
selected = voiceWakeMode == VoiceWakeMode.Foreground,
|
||||||
@@ -482,7 +343,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
|
val parsed = com.steipete.clawdis.node.WakeWords.parseCommaSeparated(wakeWordsText)
|
||||||
viewModel.setWakeWords(parsed)
|
viewModel.setWakeWords(parsed)
|
||||||
},
|
},
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
@@ -524,110 +385,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item { HorizontalDivider() }
|
item { HorizontalDivider() }
|
||||||
|
|
||||||
// Messaging
|
|
||||||
item { Text("Messaging", style = MaterialTheme.typography.titleSmall) }
|
|
||||||
item {
|
|
||||||
val buttonLabel =
|
|
||||||
when {
|
|
||||||
!smsPermissionAvailable -> "Unavailable"
|
|
||||||
smsPermissionGranted -> "Manage"
|
|
||||||
else -> "Grant"
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("SMS Permission") },
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
if (smsPermissionAvailable) {
|
|
||||||
"Allow the bridge to send SMS from this device."
|
|
||||||
} else {
|
|
||||||
"SMS requires a device with telephony hardware."
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (!smsPermissionAvailable) return@Button
|
|
||||||
if (smsPermissionGranted) {
|
|
||||||
openAppSettings(context)
|
|
||||||
} else {
|
|
||||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = smsPermissionAvailable,
|
|
||||||
) {
|
|
||||||
Text(buttonLabel)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
|
||||||
|
|
||||||
// Location
|
|
||||||
item { Text("Location", style = MaterialTheme.typography.titleSmall) }
|
|
||||||
item {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth()) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Off") },
|
|
||||||
supportingContent = { Text("Disable location sharing.") },
|
|
||||||
trailingContent = {
|
|
||||||
RadioButton(
|
|
||||||
selected = locationMode == LocationMode.Off,
|
|
||||||
onClick = { viewModel.setLocationMode(LocationMode.Off) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("While Using") },
|
|
||||||
supportingContent = { Text("Only while Clawdbot is open.") },
|
|
||||||
trailingContent = {
|
|
||||||
RadioButton(
|
|
||||||
selected = locationMode == LocationMode.WhileUsing,
|
|
||||||
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Always") },
|
|
||||||
supportingContent = { Text("Allow background location (requires system permission).") },
|
|
||||||
trailingContent = {
|
|
||||||
RadioButton(
|
|
||||||
selected = locationMode == LocationMode.Always,
|
|
||||||
onClick = { requestLocationPermissions(LocationMode.Always) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Precise Location") },
|
|
||||||
supportingContent = { Text("Use precise GPS when available.") },
|
|
||||||
trailingContent = {
|
|
||||||
Switch(
|
|
||||||
checked = locationPreciseEnabled,
|
|
||||||
onCheckedChange = ::setPreciseLocationChecked,
|
|
||||||
enabled = locationMode != LocationMode.Off,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"Always may require Android Settings to allow background location.",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item { HorizontalDivider() }
|
|
||||||
|
|
||||||
// Screen
|
// Screen
|
||||||
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
item { Text("Screen", style = MaterialTheme.typography.titleSmall) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Prevent Sleep") },
|
headlineContent = { Text("Prevent Sleep") },
|
||||||
supportingContent = { Text("Keeps the screen awake while Clawdbot is open.") },
|
supportingContent = { Text("Keeps the screen awake while Clawdis is open.") },
|
||||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -652,12 +415,3 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
|||||||
item { Spacer(modifier = Modifier.height(20.dp)) }
|
item { Spacer(modifier = Modifier.height(20.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAppSettings(context: Context) {
|
|
||||||
val intent =
|
|
||||||
Intent(
|
|
||||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
|
||||||
Uri.fromParts("package", context.packageName, null),
|
|
||||||
)
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui
|
package com.steipete.clawdis.node.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -30,7 +30,6 @@ fun StatusPill(
|
|||||||
voiceEnabled: Boolean,
|
voiceEnabled: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
activity: StatusActivity? = null,
|
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -63,49 +62,23 @@ fun StatusPill(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (activity != null) {
|
Icon(
|
||||||
Row(
|
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
tint =
|
||||||
) {
|
if (voiceEnabled) {
|
||||||
Icon(
|
overlayIconColor()
|
||||||
imageVector = activity.icon,
|
} else {
|
||||||
contentDescription = activity.contentDescription,
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
tint = activity.tint ?: overlayIconColor(),
|
},
|
||||||
modifier = Modifier.size(18.dp),
|
modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = activity.title,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (voiceEnabled) Icons.Default.Mic else Icons.Default.MicOff,
|
|
||||||
contentDescription = if (voiceEnabled) "Voice enabled" else "Voice disabled",
|
|
||||||
tint =
|
|
||||||
if (voiceEnabled) {
|
|
||||||
overlayIconColor()
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StatusActivity(
|
|
||||||
val title: String,
|
|
||||||
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
val contentDescription: String,
|
|
||||||
val tint: Color? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class BridgeState(val title: String, val color: Color) {
|
enum class BridgeState(val title: String, val color: Color) {
|
||||||
Connected("Connected", Color(0xFF2ECC71)),
|
Connected("Connected", Color(0xFF2ECC71)),
|
||||||
Connecting("Connecting…", Color(0xFFF1C40F)),
|
Connecting("Connecting…", Color(0xFFF1C40F)),
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.horizontalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
import androidx.compose.material.icons.filled.AttachFile
|
||||||
|
import androidx.compose.material.icons.filled.FolderOpen
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Stop
|
import androidx.compose.material.icons.filled.Stop
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -38,13 +39,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatComposer(
|
fun ChatComposer(
|
||||||
sessionKey: String,
|
sessionKey: String,
|
||||||
sessions: List<ChatSessionEntry>,
|
|
||||||
mainSessionKey: String,
|
|
||||||
healthOk: Boolean,
|
healthOk: Boolean,
|
||||||
thinkingLevel: String,
|
thinkingLevel: String,
|
||||||
pendingRunCount: Int,
|
pendingRunCount: Int,
|
||||||
@@ -53,18 +51,13 @@ fun ChatComposer(
|
|||||||
onPickImages: () -> Unit,
|
onPickImages: () -> Unit,
|
||||||
onRemoveAttachment: (id: String) -> Unit,
|
onRemoveAttachment: (id: String) -> Unit,
|
||||||
onSetThinkingLevel: (level: String) -> Unit,
|
onSetThinkingLevel: (level: String) -> Unit,
|
||||||
onSelectSession: (sessionKey: String) -> Unit,
|
onShowSessions: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onAbort: () -> Unit,
|
onAbort: () -> Unit,
|
||||||
onSend: (text: String) -> Unit,
|
onSend: (text: String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var input by rememberSaveable { mutableStateOf("") }
|
var input by rememberSaveable { mutableStateOf("") }
|
||||||
var showThinkingMenu by remember { mutableStateOf(false) }
|
var showThinkingMenu by remember { mutableStateOf(false) }
|
||||||
var showSessionMenu by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
|
||||||
val currentSessionLabel =
|
|
||||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
|
||||||
|
|
||||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||||
|
|
||||||
@@ -80,34 +73,6 @@ fun ChatComposer(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Box {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { showSessionMenu = true },
|
|
||||||
contentPadding = ButtonDefaults.ContentPadding,
|
|
||||||
) {
|
|
||||||
Text("Session: $currentSessionLabel")
|
|
||||||
}
|
|
||||||
|
|
||||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
|
||||||
for (entry in sessionOptions) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(entry.displayName ?: entry.key) },
|
|
||||||
onClick = {
|
|
||||||
onSelectSession(entry.key)
|
|
||||||
showSessionMenu = false
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
if (entry.key == sessionKey) {
|
|
||||||
Text("✓")
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { showThinkingMenu = true },
|
onClick = { showThinkingMenu = true },
|
||||||
@@ -126,6 +91,10 @@ fun ChatComposer(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
FilledTonalIconButton(onClick = onShowSessions, modifier = Modifier.size(42.dp)) {
|
||||||
|
Icon(Icons.Default.FolderOpen, contentDescription = "Sessions")
|
||||||
|
}
|
||||||
|
|
||||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||||
}
|
}
|
||||||
@@ -149,7 +118,7 @@ fun ChatComposer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
ConnectionPill(sessionLabel = currentSessionLabel, healthOk = healthOk)
|
ConnectionPill(sessionKey = sessionKey, healthOk = healthOk)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
if (pendingRunCount > 0) {
|
if (pendingRunCount > 0) {
|
||||||
@@ -187,7 +156,7 @@ fun ChatComposer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
private fun ConnectionPill(sessionKey: String, healthOk: Boolean) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(999.dp),
|
shape = RoundedCornerShape(999.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
@@ -202,7 +171,7 @@ private fun ConnectionPill(sessionLabel: String, healthOk: Boolean) {
|
|||||||
shape = androidx.compose.foundation.shape.CircleShape,
|
shape = androidx.compose.foundation.shape.CircleShape,
|
||||||
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
color = if (healthOk) Color(0xFF2ECC71) else Color(0xFFF39C12),
|
||||||
) {}
|
) {}
|
||||||
Text(sessionLabel, style = MaterialTheme.typography.labelSmall)
|
Text(sessionKey, style = MaterialTheme.typography.labelSmall)
|
||||||
Text(
|
Text(
|
||||||
if (healthOk) "Connected" else "Connecting…",
|
if (healthOk) "Connected" else "Connecting…",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -17,7 +17,6 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -32,7 +31,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMarkdown(text: String, textColor: Color) {
|
fun ChatMarkdown(text: String) {
|
||||||
val blocks = remember(text) { splitMarkdown(text) }
|
val blocks = remember(text) { splitMarkdown(text) }
|
||||||
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
val inlineCodeBg = MaterialTheme.colorScheme.surfaceContainerLow
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ fun ChatMarkdown(text: String, textColor: Color) {
|
|||||||
Text(
|
Text(
|
||||||
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
text = parseInlineMarkdown(trimmed, inlineCodeBg = inlineCodeBg),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = textColor,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ChatMarkdownBlock.Code -> {
|
is ChatMarkdownBlock.Code -> {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdbot.android.chat.ChatMessage
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageListCard(
|
fun ChatMessageListCard(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
@@ -7,9 +7,11 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -31,13 +33,11 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import com.clawdbot.android.chat.ChatMessage
|
import com.steipete.clawdis.node.chat.ChatMessage
|
||||||
import com.clawdbot.android.chat.ChatMessageContent
|
import com.steipete.clawdis.node.chat.ChatMessageContent
|
||||||
import com.clawdbot.android.chat.ChatPendingToolCall
|
import com.steipete.clawdis.node.chat.ChatPendingToolCall
|
||||||
import com.clawdbot.android.tools.ToolDisplayRegistry
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatMessageBubble(message: ChatMessage) {
|
fun ChatMessageBubble(message: ChatMessage) {
|
||||||
@@ -60,21 +60,20 @@ fun ChatMessageBubble(message: ChatMessage) {
|
|||||||
.background(bubbleBackground(isUser))
|
.background(bubbleBackground(isUser))
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
val textColor = textColorOverBubble(isUser)
|
ChatMessageBody(content = message.content)
|
||||||
ChatMessageBody(content = message.content, textColor = textColor)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageBody(content: List<ChatMessageContent>, textColor: Color) {
|
private fun ChatMessageBody(content: List<ChatMessageContent>) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
for (part in content) {
|
for (part in content) {
|
||||||
when (part.type) {
|
when (part.type) {
|
||||||
"text" -> {
|
"text" -> {
|
||||||
val text = part.text ?: continue
|
val text = part.text ?: continue
|
||||||
ChatMarkdown(text = text, textColor = textColor)
|
ChatMarkdown(text = text)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val b64 = part.base64 ?: continue
|
val b64 = part.base64 ?: continue
|
||||||
@@ -106,42 +105,18 @@ fun ChatTypingIndicatorBubble() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||||
val context = LocalContext.current
|
|
||||||
val displays =
|
|
||||||
remember(toolCalls, context) {
|
|
||||||
toolCalls.map { ToolDisplayRegistry.resolve(context, it.name, it.args) }
|
|
||||||
}
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text("Running tools…", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
Text("Tools", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface)
|
||||||
for (display in displays.take(6)) {
|
for (t in toolCalls.take(6)) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Text("· ${t.name}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(
|
|
||||||
"${display.emoji} ${display.label}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
display.detailLine?.let { detail ->
|
|
||||||
Text(
|
|
||||||
detail,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (toolCalls.size > 6) {
|
if (toolCalls.size > 6) {
|
||||||
Text(
|
Text("… +${toolCalls.size - 6} more", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
"… +${toolCalls.size - 6} more",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +131,7 @@ fun ChatStreamingAssistantBubble(text: String) {
|
|||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
Box(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||||
ChatMarkdown(text = text, textColor = MaterialTheme.colorScheme.onSurface)
|
ChatMarkdown(text = text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,15 +150,6 @@ private fun bubbleBackground(isUser: Boolean): Brush {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun textColorOverBubble(isUser: Boolean): Color {
|
|
||||||
return if (isUser) {
|
|
||||||
MaterialTheme.colorScheme.onPrimary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -20,7 +20,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdbot.android.chat.ChatSessionEntry
|
import com.steipete.clawdis.node.chat.ChatSessionEntry
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatSessionsDialog(
|
fun ChatSessionsDialog(
|
||||||
@@ -82,7 +82,7 @@ private fun SessionRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
Text(entry.displayName ?: entry.key, style = MaterialTheme.typography.bodyMedium)
|
Text(entry.key, style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("Current", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
@@ -90,3 +90,4 @@ private fun SessionRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.ui.chat
|
package com.steipete.clawdis.node.ui.chat
|
||||||
|
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -14,13 +14,15 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.clawdbot.android.MainViewModel
|
import com.steipete.clawdis.node.MainViewModel
|
||||||
import com.clawdbot.android.chat.OutgoingAttachment
|
import com.steipete.clawdis.node.chat.OutgoingAttachment
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -33,15 +35,15 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
|
||||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||||
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState()
|
||||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||||
val sessions by viewModel.chatSessions.collectAsState()
|
val sessions by viewModel.chatSessions.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(mainSessionKey) {
|
var showSessions by remember { mutableStateOf(false) }
|
||||||
viewModel.loadChat(mainSessionKey)
|
|
||||||
viewModel.refreshChatSessions(limit = 200)
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadChat("main")
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -85,8 +87,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
ChatComposer(
|
ChatComposer(
|
||||||
sessionKey = sessionKey,
|
sessionKey = sessionKey,
|
||||||
sessions = sessions,
|
|
||||||
mainSessionKey = mainSessionKey,
|
|
||||||
healthOk = healthOk,
|
healthOk = healthOk,
|
||||||
thinkingLevel = thinkingLevel,
|
thinkingLevel = thinkingLevel,
|
||||||
pendingRunCount = pendingRunCount,
|
pendingRunCount = pendingRunCount,
|
||||||
@@ -95,11 +95,8 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
onPickImages = { pickImages.launch("image/*") },
|
onPickImages = { pickImages.launch("image/*") },
|
||||||
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
onRemoveAttachment = { id -> attachments.removeAll { it.id == id } },
|
||||||
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
onSetThinkingLevel = { level -> viewModel.setChatThinkingLevel(level) },
|
||||||
onSelectSession = { key -> viewModel.switchChatSession(key) },
|
onShowSessions = { showSessions = true },
|
||||||
onRefresh = {
|
onRefresh = { viewModel.refreshChat() },
|
||||||
viewModel.refreshChat()
|
|
||||||
viewModel.refreshChatSessions(limit = 200)
|
|
||||||
},
|
|
||||||
onAbort = { viewModel.abortChat() },
|
onAbort = { viewModel.abortChat() },
|
||||||
onSend = { text ->
|
onSend = { text ->
|
||||||
val outgoing =
|
val outgoing =
|
||||||
@@ -116,6 +113,19 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSessions) {
|
||||||
|
ChatSessionsDialog(
|
||||||
|
currentSessionKey = sessionKey,
|
||||||
|
sessions = sessions,
|
||||||
|
onDismiss = { showSessions = false },
|
||||||
|
onRefresh = { viewModel.refreshChatSessions(limit = 50) },
|
||||||
|
onSelect = { key ->
|
||||||
|
viewModel.switchChatSession(key)
|
||||||
|
showSessions = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class PendingImageAttachment(
|
data class PendingImageAttachment(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.voice
|
package com.steipete.clawdis.node.voice
|
||||||
|
|
||||||
object VoiceWakeCommandExtractor {
|
object VoiceWakeCommandExtractor {
|
||||||
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
fun extractCommand(text: String, triggerWords: List<String>): String? {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.clawdbot.android.voice
|
package com.steipete.clawdis.node.voice
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |