Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667bee0de0 | ||
|
|
cd48aac32b | ||
|
|
a4f6b3528a | ||
|
|
9f8e66359e | ||
|
|
8a2720db4c | ||
|
|
5330595a5a | ||
|
|
2c5141d7df | ||
|
|
483fba41b9 | ||
|
|
fe7436a1f6 | ||
|
|
a1ed671636 | ||
|
|
8c47d226ad | ||
|
|
e1942603e9 | ||
|
|
926c2647b8 | ||
|
|
c427f4a2fc | ||
|
|
f99f9a6b64 | ||
|
|
39d8c441eb | ||
|
|
40ef3b5d30 | ||
|
|
390b730b37 | ||
|
|
71457fa100 | ||
|
|
da7a45b3a5 | ||
|
|
15a9c21203 | ||
|
|
6d79c6cd26 | ||
|
|
bcedeb4e1f | ||
|
|
f076eba98a | ||
|
|
f3bd6bf342 | ||
|
|
c29c9a1e3e | ||
|
|
7a384ea07c | ||
|
|
5fc866e8fe | ||
|
|
437535ee94 | ||
|
|
3b929ff843 | ||
|
|
93737ee152 | ||
|
|
765626b492 | ||
|
|
42b8fce4e5 | ||
|
|
c27294133e | ||
|
|
94095386b3 | ||
|
|
7a524e8667 | ||
|
|
876bbb742a | ||
|
|
ef7971e3a4 | ||
|
|
9d742ba51f | ||
|
|
386d21b6d1 | ||
|
|
c8afa8207c | ||
|
|
d0e21f05a6 | ||
|
|
11f039ef85 | ||
|
|
e90e3ba954 | ||
|
|
0de7852d46 | ||
|
|
834663dfef | ||
|
|
a72d7a9f36 | ||
|
|
67e57e7c99 | ||
|
|
4c98d6c121 | ||
|
|
174a1cb68a | ||
|
|
a4d56bd06e | ||
|
|
c9e98376b3 | ||
|
|
62c9255b6a | ||
|
|
8b4e40c602 | ||
|
|
6a9d7f7a01 | ||
|
|
39d8e9be0f | ||
|
|
ac45c8b404 | ||
|
|
fa746b05de | ||
|
|
49c518951c | ||
|
|
0dca8acbe2 | ||
|
|
c42e9b1d19 | ||
|
|
298901208d | ||
|
|
ef9ba66798 | ||
|
|
4b6cdd1d3c | ||
|
|
eaeb52f70a | ||
|
|
be1cdc9370 | ||
|
|
8002143d92 | ||
|
|
4a9123d415 | ||
|
|
dbf139d14e | ||
|
|
d905ca0e02 | ||
|
|
ab000398be | ||
|
|
1bbbb10abf | ||
|
|
c02204fd1e | ||
|
|
5482803547 | ||
|
|
3dcaa70531 | ||
|
|
a6ddd82a14 | ||
|
|
585e20b72e | ||
|
|
d8a6317dfc | ||
|
|
c8c58c0537 | ||
|
|
cfdd5a8c2e | ||
|
|
6765fd15eb | ||
|
|
4074fa0471 | ||
|
|
ea2ccd8ae6 | ||
|
|
b1ac7e0501 | ||
|
|
b4a2dc81a2 | ||
|
|
d73e8ecca3 | ||
|
|
faa90fc206 | ||
|
|
f1083cd52c | ||
|
|
7f7550e53c | ||
|
|
d4d17025cf | ||
|
|
7b76db2841 | ||
|
|
f9cf508cff | ||
|
|
9b12275fe1 | ||
|
|
f70ac0c7c2 |
No files matched your search
@@ -0,0 +1,143 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Build amd64 image
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
image-metadata: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 image
|
||||
build-arm64:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
image-metadata: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifest
|
||||
create-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for manifest
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
${{ needs.build-amd64.outputs.image-digest }} \
|
||||
${{ needs.build-arm64.outputs.image-digest }}
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
@@ -106,6 +106,7 @@
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
|
||||
+86
-70
@@ -2,69 +2,85 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.23 (Unreleased)
|
||||
## 2026.1.24
|
||||
|
||||
### Changes
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Contacts/Search: add the contacts-search plugin for unified contacts + cross-platform message search (CLI + /search). (#1438) Thanks @bluzername. https://docs.clawd.bot/plugins/contacts-search https://docs.clawd.bot/contact https://docs.clawd.bot/cli/contacts https://docs.clawd.bot/cli/search
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
|
||||
### Fixes
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
### Fixes
|
||||
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Highlights
|
||||
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
|
||||
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
|
||||
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
|
||||
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
|
||||
|
||||
### Changes
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
|
||||
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
|
||||
|
||||
### Fixes
|
||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
||||
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
||||
- CLI: move auth probe errors below the table to reduce wrapping.
|
||||
- CLI: prevent ANSI color bleed when table cells wrap.
|
||||
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
||||
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
|
||||
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
|
||||
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
|
||||
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
@@ -112,22 +128,22 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
|
||||
+87
-190
@@ -2,6 +2,93 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.23</title>
|
||||
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7750</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
||||
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
||||
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
||||
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
||||
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
||||
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
||||
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
||||
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
||||
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
||||
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
|
||||
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
||||
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
||||
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
||||
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
||||
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
||||
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
|
||||
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
||||
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
||||
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
|
||||
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
||||
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
||||
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
||||
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
||||
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
||||
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
||||
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
||||
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
||||
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
||||
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
||||
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
||||
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
|
||||
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
||||
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
||||
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
||||
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
||||
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
||||
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
||||
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
||||
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
||||
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
||||
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
||||
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
||||
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
||||
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
||||
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
||||
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
||||
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
||||
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
||||
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
||||
<li>Telegram: render markdown in media captions. (#1478)</li>
|
||||
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
||||
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
||||
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
||||
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
||||
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
||||
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
||||
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
||||
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
||||
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
||||
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</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.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.22</title>
|
||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||
@@ -124,195 +211,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7116</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||
<li>Build: update workspace + core/plugin deps.</li>
|
||||
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||
</ul>
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
<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.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601230
|
||||
versionName = "2026.1.23"
|
||||
versionCode = 202601240
|
||||
versionName = "2026.1.24"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260123</string>
|
||||
<string>20260124</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260123</string>
|
||||
<string>20260124</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.23"
|
||||
CFBundleVersion: "20260123"
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.23"
|
||||
CFBundleVersion: "20260123"
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
@@ -24,6 +24,11 @@ final class AppState {
|
||||
case remote
|
||||
}
|
||||
|
||||
enum RemoteTransport: String {
|
||||
case ssh
|
||||
case direct
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
@@ -166,6 +171,10 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTransport: RemoteTransport {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
@@ -200,6 +209,10 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteUrl: String {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var remoteIdentity: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
@@ -263,13 +276,15 @@ final class AppState {
|
||||
}
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
@@ -277,6 +292,7 @@ final class AppState {
|
||||
} else {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteUrl = configRemoteUrl ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -354,10 +370,11 @@ final class AppState {
|
||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -378,8 +395,17 @@ final class AppState {
|
||||
self.connectionMode = .remote
|
||||
}
|
||||
|
||||
if remoteTransport != self.remoteTransport {
|
||||
self.remoteTransport = remoteTransport
|
||||
}
|
||||
let remoteUrlText = remoteUrl ?? ""
|
||||
if remoteUrlText != self.remoteUrl {
|
||||
self.remoteUrl = remoteUrlText
|
||||
}
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
remoteTransport != .direct,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
@@ -402,6 +428,8 @@ final class AppState {
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -435,39 +463,63 @@ final class AppState {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
var remoteChanged = false
|
||||
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
if remoteTransport == .direct {
|
||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedUrl.isEmpty {
|
||||
if remote["url"] != nil {
|
||||
remote.removeValue(forKey: "url")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||
if (remote["url"] as? String) != normalizedUrl {
|
||||
remote["url"] = normalizedUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||
remote["transport"] = RemoteTransport.direct.rawValue
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if remote["transport"] != nil {
|
||||
remote.removeValue(forKey: "transport")
|
||||
remoteChanged = true
|
||||
}
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
@@ -621,8 +673,10 @@ extension AppState {
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.remoteTransport = .ssh
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
enum GatewayDiscoveryHelpers {
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if gateway.sshPort != 22 {
|
||||
target += ":\(gateway.sshPort)"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
tailnetDns: gateway.tailnetDns,
|
||||
lanHost: gateway.lanHost,
|
||||
gatewayPort: gateway.gatewayPort)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
tailnetDns: String?,
|
||||
lanHost: String?,
|
||||
gatewayPort: Int?) -> String?
|
||||
{
|
||||
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||
return "wss://\(tailnetDns)"
|
||||
}
|
||||
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||
let port = gatewayPort ?? 18789
|
||||
return "ws://\(lanHost):\(port)"
|
||||
}
|
||||
|
||||
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: String?) -> String? {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import SwiftUI
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var currentTarget: String?
|
||||
var currentUrl: String?
|
||||
var transport: AppState.RemoteTransport
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||
|
||||
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
let display = self.displayInfo(for: gateway)
|
||||
let selected = display.selected
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(target ?? "Gateway pairing only")
|
||||
Text(display.label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help("Click a discovered gateway to fill the SSH target.")
|
||||
.help(self.transport == .direct
|
||||
? "Click a discovered gateway to fill the gateway URL."
|
||||
: "Click a discovered gateway to fill the SSH target.")
|
||||
}
|
||||
|
||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host else { return nil }
|
||||
let user = NSUserName()
|
||||
return GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
private func displayInfo(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||
{
|
||||
switch self.transport {
|
||||
case .direct:
|
||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||
let label = url ?? "Gateway pairing only"
|
||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||
return (label, selected)
|
||||
case .ssh:
|
||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||
let label = target ?? "Gateway pairing only"
|
||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||
return (label, selected)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDiscoveryMenu: View {
|
||||
|
||||
@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(
|
||||
mode: .remote,
|
||||
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||
return
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return
|
||||
}
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
@@ -341,6 +354,25 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||
let portInt = UInt16(exactly: port)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||
}
|
||||
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||
return portInt
|
||||
}
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(
|
||||
@@ -401,6 +433,21 @@ actor GatewayEndpointStore {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
}
|
||||
|
||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["transport"] as? String
|
||||
else {
|
||||
return .ssh
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||
}
|
||||
|
||||
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let urlRaw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", url.port == nil {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url
|
||||
}
|
||||
components.port = 18789
|
||||
return components.url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
static func defaultPort(for url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
switch scheme {
|
||||
case "wss":
|
||||
return 443
|
||||
case "ws":
|
||||
return 18789
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
private var remoteLabelWidth: CGFloat { 88 }
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 48, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget)
|
||||
currentTarget: self.state.remoteTarget,
|
||||
currentUrl: self.state.remoteUrl,
|
||||
transport: self.state.remoteTransport)
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, 58)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, 58)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var remoteTransportRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteSshRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -458,24 +523,36 @@ extension GeneralSettings {
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
if self.state.remoteTransport == .direct {
|
||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedUrl.isEmpty else {
|
||||
self.remoteStatus = .failed("Set a gateway URL first")
|
||||
return
|
||||
}
|
||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: control channel health over tunnel
|
||||
// Step 2: control channel health check
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
@@ -502,6 +579,14 @@ extension GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !host.isEmpty
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
@@ -570,12 +655,18 @@ extension GeneralSettings {
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,7 +689,9 @@ extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||
state.remoteCliPath = "/tmp/clawdbot"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
if AppStateStore.shared.remoteTransport == .direct {
|
||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
||||
if let port = url.port {
|
||||
host = "\(urlHost):\(port)"
|
||||
} else {
|
||||
host = urlHost
|
||||
}
|
||||
} else {
|
||||
host = trimmedUrl.nonEmpty
|
||||
}
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
|
||||
@@ -25,7 +25,11 @@ extension OnboardingView {
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
|
||||
@@ -177,42 +177,67 @@ extension OnboardingView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
if self.state.remoteTransport == .direct {
|
||||
GridRow {
|
||||
Text("Gateway URL")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
Text(self.state.remoteTransport == .direct
|
||||
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -225,7 +250,10 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
if self.state.remoteTransport == .direct {
|
||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||
}
|
||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601230</string>
|
||||
<string>202601240</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
@@ -175,4 +175,10 @@ import Testing
|
||||
customBindHost: "192.168.1.10")
|
||||
#expect(host == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||
#expect(url?.port == 18789)
|
||||
#expect(url?.absoluteString == "ws://gateway:18789")
|
||||
}
|
||||
}
|
||||
@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
|
||||
|
||||
public struct SessionsResolveParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let sessionid: String?
|
||||
public let label: String?
|
||||
public let agentid: String?
|
||||
public let spawnedby: String?
|
||||
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String?,
|
||||
sessionid: String?,
|
||||
label: String?,
|
||||
agentid: String?,
|
||||
spawnedby: String?,
|
||||
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
includeunknown: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.sessionid = sessionid
|
||||
self.label = label
|
||||
self.agentid = agentid
|
||||
self.spawnedby = spawnedby
|
||||
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case sessionid = "sessionId"
|
||||
case label
|
||||
case agentid = "agentId"
|
||||
case spawnedby = "spawnedBy"
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);min-height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease)}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-height:1}.topbar-status{display:flex;align-items:center;gap:8px}.toLine truncated
|
||||
+1
@@ -0,0 +1 @@
|
||||
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
|
||||
+470
-458
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<title>Clawdbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
|
||||
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
||||
</head>
|
||||
<body>
|
||||
<clawdbot-app></clawdbot-app>
|
||||
|
||||
+67
-4
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
auth: "aws-sdk",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
name: "Claude Opus 4.5 (Bedrock)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
@@ -89,12 +89,75 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## EC2 Instance Roles
|
||||
|
||||
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
|
||||
will automatically use the instance metadata service (IMDS) for authentication.
|
||||
However, Clawdbot's credential detection currently only checks for environment
|
||||
variables, not IMDS credentials.
|
||||
|
||||
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
|
||||
available. The actual authentication still uses the instance role via IMDS.
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or your shell profile
|
||||
export AWS_PROFILE=default
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
**Required IAM permissions** for the EC2 instance role:
|
||||
- `bedrock:InvokeModel`
|
||||
- `bedrock:InvokeModelWithResponseStream`
|
||||
- `bedrock:ListFoundationModels` (for automatic discovery)
|
||||
|
||||
Or attach the managed policy `AmazonBedrockFullAccess`.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
```bash
|
||||
# 1. Create IAM role and instance profile
|
||||
aws iam create-role --role-name EC2-Bedrock-Access \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ec2.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}'
|
||||
|
||||
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
|
||||
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
|
||||
|
||||
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
|
||||
aws iam add-role-to-instance-profile \
|
||||
--instance-profile-name EC2-Bedrock-Access \
|
||||
--role-name EC2-Bedrock-Access
|
||||
|
||||
# 2. Attach to your EC2 instance
|
||||
aws ec2 associate-iam-instance-profile \
|
||||
--instance-id i-xxxxx \
|
||||
--iam-instance-profile Name=EC2-Bedrock-Access
|
||||
|
||||
# 3. On the EC2 instance, enable discovery
|
||||
clawdbot config set models.bedrockDiscovery.enabled true
|
||||
clawdbot config set models.bedrockDiscovery.region us-east-1
|
||||
|
||||
# 4. Set the workaround env vars
|
||||
echo 'export AWS_PROFILE=default' >> ~/.bashrc
|
||||
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 5. Verify models are discovered
|
||||
clawdbot models list
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot contacts` (unified contact graph)"
|
||||
read_when:
|
||||
- You want to list or link contacts across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot contacts`
|
||||
|
||||
Unified contact graph and identity linking.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
Concept overview: [Contact graph](/contact).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts list --query "sarah" --platform slack
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot contacts search "alice"
|
||||
clawdbot contacts link <primary-id> <secondary-id>
|
||||
clawdbot contacts unlink slack U12345678
|
||||
clawdbot contacts suggestions
|
||||
clawdbot contacts auto-link --dry-run
|
||||
clawdbot contacts stats
|
||||
clawdbot contacts alias <contact-id> "Alias Name"
|
||||
clawdbot contacts alias <contact-id> "Old Alias" --remove
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `list`: list contacts (supports `--query`, `--platform`, `--limit`, `--json`).
|
||||
- `show <id>`: show a contact + identities (accepts a canonical id or a search query).
|
||||
- `search <query>`: search contacts by name/alias/username.
|
||||
- `link <primary> <secondary>`: merge two contacts.
|
||||
- `unlink <platform> <platformId>`: detach an identity into a new contact.
|
||||
- `suggestions`: show link suggestions.
|
||||
- `auto-link`: link high-confidence matches (use `--dry-run` to preview).
|
||||
- `stats`: store statistics by platform.
|
||||
- `alias <contactId> <alias>`: add or remove aliases (`--remove`).
|
||||
|
||||
## Notes
|
||||
|
||||
- `--platform` expects a channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `unlink` uses the platform id stored on the identity (not the contact id).
|
||||
@@ -32,8 +32,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`contacts`](/cli/contacts) (plugin; if enabled)
|
||||
- [`search`](/cli/search) (plugin; if enabled)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`devices`](/cli/devices)
|
||||
- [`node`](/cli/node)
|
||||
@@ -124,8 +122,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
status
|
||||
index
|
||||
search
|
||||
contacts
|
||||
search
|
||||
message
|
||||
agent
|
||||
agents
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot search` (cross-platform message search)"
|
||||
read_when:
|
||||
- You want to search indexed messages across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot search`
|
||||
|
||||
Search indexed messages across channels.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot search "meeting tomorrow"
|
||||
clawdbot search "deadline" --from alice
|
||||
clawdbot search "project" --platform slack --since 1w
|
||||
clawdbot search "invoice" --since 2025-12-01 --until 2025-12-31
|
||||
clawdbot search "handoff" --limit 50 --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--from <contact>`: filter by sender name/alias/username or contact id.
|
||||
- `--platform <name>`: filter by channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `--since <time>`: start time (`1h`, `2d`, `1w`, `1m`, or ISO date).
|
||||
- `--until <time>`: end time (same formats as `--since`).
|
||||
- `--limit <n>`: limit results (default `20`).
|
||||
- `--json`: raw JSON output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Results come from the local contacts store (`~/.clawdbot/contacts/contacts.sqlite`).
|
||||
- Only inbound messages are indexed (no backfill).
|
||||
- Concept overview: [Contact graph](/contact).
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
summary: "Alias for compaction docs"
|
||||
read_when:
|
||||
- You looked for /compaction; canonical doc lives in /concepts/compaction
|
||||
---
|
||||
# Compaction
|
||||
|
||||
Canonical compaction docs live in [Compaction](/concepts/compaction).
|
||||
@@ -56,19 +56,20 @@ Row shape (JSON):
|
||||
Fetch transcript for one session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
|
||||
- `limit?: number` max messages (server clamps)
|
||||
- `includeTools?: boolean` (default false)
|
||||
|
||||
Behavior:
|
||||
- `includeTools=false` filters `role: "toolResult"` messages.
|
||||
- Returns messages array in the raw transcript format.
|
||||
- When given a `sessionId`, Clawdbot resolves it to the corresponding session key (missing ids error).
|
||||
|
||||
## sessions_send
|
||||
Send a message into another session.
|
||||
|
||||
Parameters:
|
||||
- `sessionKey` (required)
|
||||
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
|
||||
- `message` (required)
|
||||
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
|
||||
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
---
|
||||
summary: "Unified contacts: contact graph, identity linking, and message indexing"
|
||||
read_when:
|
||||
- You want to understand how Clawdbot merges identities across channels
|
||||
- You are using the Contacts + Search plugin
|
||||
---
|
||||
|
||||
# Contact graph
|
||||
|
||||
Clawdbot can maintain a **unified contact graph** that links the same person across multiple channels (Slack, Discord, WhatsApp, etc.).
|
||||
This powers cross-platform message search and manual identity linking.
|
||||
|
||||
The contact graph is provided by the **Contacts + Search** plugin and is **disabled by default**.
|
||||
|
||||
## Enable
|
||||
|
||||
Install/enable the plugin on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Config equivalent:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
- [Contacts + Search plugin](/plugins/contacts-search)
|
||||
- [Plugins overview](/plugin)
|
||||
|
||||
## Data model
|
||||
|
||||
The contact graph has three layers:
|
||||
|
||||
1) **Canonical contact**
|
||||
- One logical person.
|
||||
- Has a `canonicalId`, display name, and optional aliases.
|
||||
|
||||
2) **Platform identity**
|
||||
- One account on one channel (e.g. `slack:U123...`).
|
||||
- Links back to a canonical contact.
|
||||
- Optional username, phone, display name, and last-seen time.
|
||||
|
||||
3) **Indexed message**
|
||||
- Text of inbound messages tied to a platform identity.
|
||||
- Used by cross-platform search.
|
||||
|
||||
## How contacts are created
|
||||
|
||||
Contacts are created automatically when **inbound messages** arrive:
|
||||
|
||||
- The plugin extracts sender identity details from the inbound message.
|
||||
- If the platform identity is new, a new canonical contact is created.
|
||||
- If it already exists, the identity metadata is refreshed.
|
||||
|
||||
There is **no backfill** step today; indexing starts when the plugin is enabled.
|
||||
|
||||
## Linking identities
|
||||
|
||||
You can link identities that belong to the same person:
|
||||
|
||||
- **Manual link**: merge two contacts into one canonical contact.
|
||||
- **Suggestions**: name/phone similarity hints (preview-only).
|
||||
- **Auto-link**: high-confidence matches (same phone number).
|
||||
|
||||
CLI reference: [Contacts CLI](/cli/contacts)
|
||||
|
||||
## Searching messages
|
||||
|
||||
Use the CLI or slash command:
|
||||
|
||||
- `clawdbot search "query"` (CLI)
|
||||
- `/search <query>` (chat)
|
||||
|
||||
Search uses SQLite FTS when available; otherwise it falls back to SQL `LIKE`.
|
||||
|
||||
CLI reference: [Search CLI](/cli/search)
|
||||
Slash commands: [Slash commands](/tools/slash-commands)
|
||||
|
||||
## Storage + privacy
|
||||
|
||||
- Stored locally on the Gateway host at `~/.clawdbot/contacts/contacts.sqlite`.
|
||||
- No cloud sync by default.
|
||||
- Treat this file as **sensitive** (names, handles, phone numbers).
|
||||
|
||||
To reset the graph, disable the plugin and move the SQLite file to Trash, then restart the Gateway.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No results**: the plugin only indexes **new inbound messages**.
|
||||
- **Missing contacts**: ensure the plugin is enabled and the Gateway restarted.
|
||||
- **Search feels shallow**: FTS may be unavailable; check that SQLite FTS5 is supported on your runtime.
|
||||
+4
-19
@@ -387,7 +387,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/faq",
|
||||
"destination": "/start/faq"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/gateway-lock",
|
||||
@@ -449,10 +449,6 @@
|
||||
"source": "/location-command",
|
||||
"destination": "/nodes/location-command"
|
||||
},
|
||||
{
|
||||
"source": "/logging",
|
||||
"destination": "/gateway/logging"
|
||||
},
|
||||
{
|
||||
"source": "/lore",
|
||||
"destination": "/start/lore"
|
||||
@@ -761,21 +757,13 @@
|
||||
"source": "/wizard",
|
||||
"destination": "/start/wizard"
|
||||
},
|
||||
{
|
||||
"source": "/install/node",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/install/node/",
|
||||
"destination": "/install#nodejs--npm-path-sanity"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq",
|
||||
"destination": "/help"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/start/faq/",
|
||||
"destination": "/help"
|
||||
"destination": "/help/faq"
|
||||
},
|
||||
{
|
||||
"source": "/oauth",
|
||||
@@ -848,8 +836,6 @@
|
||||
"cli/skills",
|
||||
"cli/plugins",
|
||||
"cli/memory",
|
||||
"cli/contacts",
|
||||
"cli/search",
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/system",
|
||||
@@ -885,7 +871,6 @@
|
||||
"concepts/session",
|
||||
"concepts/session-pruning",
|
||||
"concepts/sessions",
|
||||
"contact",
|
||||
"concepts/session-tool",
|
||||
"concepts/presence",
|
||||
"concepts/channel-routing",
|
||||
@@ -919,6 +904,7 @@
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/openai-http-api",
|
||||
"gateway/tools-invoke-http-api",
|
||||
"gateway/cli-backends",
|
||||
"gateway/local-models",
|
||||
"gateway/background-process",
|
||||
@@ -1006,7 +992,6 @@
|
||||
"tools/lobster",
|
||||
"tools/llm-task",
|
||||
"plugin",
|
||||
"plugins/contacts-search",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
|
||||
+1
-1
@@ -74,5 +74,5 @@ See [Configuration: Env var substitution](/gateway/configuration#env-var-substit
|
||||
## Related
|
||||
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
- [FAQ: env vars and .env loading](/start/faq#env-vars-and-env-loading)
|
||||
- [FAQ: env vars and .env loading](/help/faq#env-vars-and-env-loading)
|
||||
- [Models overview](/concepts/models)
|
||||
@@ -1459,13 +1459,28 @@ voice notes; other channels send MP3 audio.
|
||||
enabled: true,
|
||||
mode: "final", // final | all (include tool/block replies)
|
||||
provider: "elevenlabs",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
enabled: true
|
||||
},
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json",
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
baseUrl: "https://api.elevenlabs.io",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2"
|
||||
modelId: "eleven_multilingual_v2",
|
||||
seed: 42,
|
||||
applyTextNormalization: "auto",
|
||||
languageCode: "en",
|
||||
voiceSettings: {
|
||||
stability: 0.5,
|
||||
similarityBoost: 0.75,
|
||||
style: 0.0,
|
||||
useSpeakerBoost: true,
|
||||
speed: 1.0
|
||||
}
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
@@ -1478,11 +1493,17 @@ voice notes; other channels send MP3 audio.
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts_on`, `/tts_off`).
|
||||
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts on`, `/tts off`).
|
||||
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
|
||||
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
|
||||
- `/tts_limit` and `/tts_summary` control per-user summarization settings.
|
||||
- `summaryModel` overrides `agents.defaults.model.primary` for auto-summary.
|
||||
- Accepts `provider/model` or an alias from `agents.defaults.models`.
|
||||
- `modelOverrides` enables model-driven overrides like `[[tts:...]]` tags (on by default).
|
||||
- `/tts limit` and `/tts summary` control per-user summarization settings.
|
||||
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
|
||||
- `elevenlabs.baseUrl` overrides the ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings` supports `stability`/`similarityBoost`/`style` (0..1),
|
||||
`useSpeakerBoost`, and `speed` (0.5..2.0).
|
||||
|
||||
### `talk`
|
||||
|
||||
@@ -2804,13 +2825,14 @@ Auth and Tailscale:
|
||||
|
||||
Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2825,6 +2847,21 @@ macOS app behavior:
|
||||
}
|
||||
```
|
||||
|
||||
Direct transport example (macOS app):
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
transport: "direct",
|
||||
url: "wss://gateway.example.ts.net",
|
||||
token: "your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||
|
||||
@@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
every: "30m", // default: 30m (0m disables)
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
@@ -92,6 +92,14 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
}
|
||||
```
|
||||
|
||||
### Scope and precedence
|
||||
|
||||
- `agents.defaults.heartbeat` sets global heartbeat behavior.
|
||||
- `agents.list[].heartbeat` merges on top; if any agent has a `heartbeat` block, **only those agents** run heartbeats.
|
||||
- `channels.defaults.heartbeat` sets visibility defaults for all channels.
|
||||
- `channels.<channel>.heartbeat` overrides channel defaults.
|
||||
- `channels.<channel>.accounts.<id>.heartbeat` (multi-account channels) overrides per-channel settings.
|
||||
|
||||
### Per-agent heartbeats
|
||||
|
||||
If any `agents.list[]` entry includes a `heartbeat` block, **only those agents**
|
||||
@@ -156,6 +164,68 @@ Example: two agents, only the second agent runs heartbeats.
|
||||
- Heartbeat-only replies do **not** keep the session alive; the last `updatedAt`
|
||||
is restored so idle expiry behaves normally.
|
||||
|
||||
## Visibility controls
|
||||
|
||||
By default, `HEARTBEAT_OK` acknowledgments are suppressed while alert content is
|
||||
delivered. You can adjust this per channel or per account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
defaults:
|
||||
heartbeat:
|
||||
showOk: false # Hide HEARTBEAT_OK (default)
|
||||
showAlerts: true # Show alert messages (default)
|
||||
useIndicator: true # Emit indicator events (default)
|
||||
telegram:
|
||||
heartbeat:
|
||||
showOk: true # Show OK acknowledgments on Telegram
|
||||
whatsapp:
|
||||
accounts:
|
||||
work:
|
||||
heartbeat:
|
||||
showAlerts: false # Suppress alert delivery for this account
|
||||
```
|
||||
|
||||
Precedence: per-account → per-channel → channel defaults → built-in defaults.
|
||||
|
||||
### What each flag does
|
||||
|
||||
- `showOk`: sends a `HEARTBEAT_OK` acknowledgment when the model returns an OK-only reply.
|
||||
- `showAlerts`: sends the alert content when the model returns a non-OK reply.
|
||||
- `useIndicator`: emits indicator events for UI status surfaces.
|
||||
|
||||
If **all three** are false, Clawdbot skips the heartbeat run entirely (no model call).
|
||||
|
||||
### Per-channel vs per-account examples
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
defaults:
|
||||
heartbeat:
|
||||
showOk: false
|
||||
showAlerts: true
|
||||
useIndicator: true
|
||||
slack:
|
||||
heartbeat:
|
||||
showOk: true # all Slack accounts
|
||||
accounts:
|
||||
ops:
|
||||
heartbeat:
|
||||
showAlerts: false # suppress alerts for the ops account only
|
||||
telegram:
|
||||
heartbeat:
|
||||
showOk: true
|
||||
```
|
||||
|
||||
### Common patterns
|
||||
|
||||
| Goal | Config |
|
||||
| --- | --- |
|
||||
| Default behavior (silent OKs, alerts on) | *(no config needed)* |
|
||||
| Fully silent (no messages, no indicator) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: false }` |
|
||||
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
|
||||
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
|
||||
|
||||
## HEARTBEAT.md (optional)
|
||||
|
||||
If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
|
||||
@@ -30,6 +30,7 @@ pnpm gateway:watch
|
||||
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
|
||||
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
|
||||
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
|
||||
- Tools Invoke (HTTP): [`/tools/invoke`](/gateway/tools-invoke-http-api).
|
||||
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__clawdbot__/canvas/` from `~/clawd/canvas`. Disable with `canvasHost.enabled=false` or `CLAWDBOT_SKIP_CANVAS_HOST=1`.
|
||||
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
|
||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
summary: "Invoke a single tool directly via the Gateway HTTP endpoint"
|
||||
read_when:
|
||||
- Calling tools without running a full agent turn
|
||||
- Building automations that need tool policy enforcement
|
||||
---
|
||||
# Tools Invoke (HTTP)
|
||||
|
||||
Clawdbot’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled, but gated by Gateway auth and tool policy.
|
||||
|
||||
- `POST /tools/invoke`
|
||||
- Same port as the Gateway (WS + HTTP multiplex): `http://<gateway-host>:<port>/tools/invoke`
|
||||
|
||||
Default max payload size is 2 MB.
|
||||
|
||||
## Authentication
|
||||
|
||||
Uses the Gateway auth configuration. Send a bearer token:
|
||||
|
||||
- `Authorization: Bearer <token>`
|
||||
|
||||
Notes:
|
||||
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `CLAWDBOT_GATEWAY_PASSWORD`).
|
||||
|
||||
## Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {},
|
||||
"sessionKey": "main",
|
||||
"dryRun": false
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `tool` (string, required): tool name to invoke.
|
||||
- `action` (string, optional): mapped into args if the tool schema supports `action` and the args payload omitted it.
|
||||
- `args` (object, optional): tool-specific arguments.
|
||||
- `sessionKey` (string, optional): target session key. If omitted or `"main"`, the Gateway uses the configured main session key (honors `session.mainKey` and default agent, or `global` in global scope).
|
||||
- `dryRun` (boolean, optional): reserved for future use; currently ignored.
|
||||
|
||||
## Policy + routing behavior
|
||||
|
||||
Tool availability is filtered through the same policy chain used by Gateway agents:
|
||||
- `tools.profile` / `tools.byProvider.profile`
|
||||
- `tools.allow` / `tools.byProvider.allow`
|
||||
- `agents.<id>.tools.allow` / `agents.<id>.tools.byProvider.allow`
|
||||
- group policies (if the session key maps to a group or channel)
|
||||
- subagent policy (when invoking with a subagent session key)
|
||||
|
||||
If a tool is not allowed by policy, the endpoint returns **404**.
|
||||
|
||||
To help group policies resolve context, you can optionally set:
|
||||
- `x-clawdbot-message-channel: <channel>` (example: `slack`, `telegram`)
|
||||
- `x-clawdbot-account-id: <accountId>` (when multiple accounts exist)
|
||||
|
||||
## Responses
|
||||
|
||||
- `200` → `{ ok: true, result }`
|
||||
- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error)
|
||||
- `401` → unauthorized
|
||||
- `404` → tool not available (not found or not allowlisted)
|
||||
- `405` → method not allowed
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:18789/tools/invoke \
|
||||
-H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"tool": "sessions_list",
|
||||
"action": "json",
|
||||
"args": {}
|
||||
}'
|
||||
```
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
When Clawdbot misbehaves, here's how to fix it.
|
||||
|
||||
Start with the FAQ’s [First 60 seconds](/start/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
|
||||
Start with the FAQ’s [First 60 seconds](/help/faq#first-60-seconds-if-somethings-broken) if you just want a quick triage recipe. This page goes deeper on runtime failures and diagnostics.
|
||||
|
||||
Provider-specific shortcuts: [/channels/troubleshooting](/channels/troubleshooting)
|
||||
|
||||
@@ -31,6 +31,34 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### OAuth token refresh failed (Anthropic Claude subscription)
|
||||
|
||||
This means the stored Anthropic OAuth token expired and the refresh failed.
|
||||
If you’re on a Claude subscription (no API key), the most reliable fix is to
|
||||
switch to a **Claude Code setup-token** or re-sync Claude Code CLI OAuth on the
|
||||
**gateway host**.
|
||||
|
||||
**Recommended (setup-token):**
|
||||
|
||||
```bash
|
||||
# Run on the gateway host (runs Claude Code CLI)
|
||||
clawdbot models auth setup-token --provider anthropic
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
If you generated the token elsewhere:
|
||||
|
||||
```bash
|
||||
clawdbot models auth paste-token --provider anthropic
|
||||
clawdbot models status
|
||||
```
|
||||
|
||||
**If you want to keep OAuth reuse:**
|
||||
log in with Claude Code CLI on the gateway host, then run `clawdbot models status`
|
||||
to sync the refreshed token into Clawdbot’s auth store.
|
||||
|
||||
More detail: [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
|
||||
### Control UI fails on HTTP ("device identity required" / "connect failed")
|
||||
|
||||
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
|
||||
|
||||
+1980
-12
File diff suppressed because it is too large.
Load diff
@@ -33,6 +33,22 @@ Almost always a Node/npm PATH issue. Start here:
|
||||
|
||||
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
|
||||
|
||||
### Installer fails (or you need full logs)
|
||||
|
||||
Re-run the installer in verbose mode to see the full trace and npm output:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --verbose
|
||||
```
|
||||
|
||||
For beta installs:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --beta --verbose
|
||||
```
|
||||
|
||||
You can also set `CLAWDBOT_VERBOSE=1` instead of the flag.
|
||||
|
||||
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
|
||||
@@ -11,10 +11,13 @@ Last updated: 2026-01-21
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
|
||||
- **stable**: npm dist-tag `latest`.
|
||||
- **beta**: npm dist-tag `beta` (builds under test).
|
||||
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
|
||||
|
||||
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
|
||||
without changing the version number — dist-tags are the source of truth for npm installs.
|
||||
|
||||
## Switching channels
|
||||
|
||||
Git checkout:
|
||||
@@ -25,7 +28,7 @@ clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
```
|
||||
|
||||
- `stable`/`beta` check out the latest matching tag.
|
||||
- `stable`/`beta` check out the latest matching tag (often the same tag).
|
||||
- `dev` switches to `main` and rebases on the upstream.
|
||||
|
||||
npm/pnpm global install:
|
||||
@@ -56,12 +59,11 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
|
||||
|
||||
## Tagging best practices
|
||||
|
||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
|
||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Keep tags immutable: never move or reuse a tag.
|
||||
- Publish dist-tags alongside git tags:
|
||||
- npm dist-tags remain the source of truth for npm installs:
|
||||
- `latest` → stable
|
||||
- `beta` → prerelease
|
||||
- `beta` → candidate build
|
||||
- `dev` → main snapshot (optional)
|
||||
|
||||
## macOS app availability
|
||||
|
||||
+87
-14
@@ -32,7 +32,7 @@ cd clawdbot
|
||||
fly apps create my-clawdbot
|
||||
|
||||
# Create a persistent volume (1GB is usually enough)
|
||||
fly volumes create clawdbot_data --size 1 --region lhr
|
||||
fly volumes create clawdbot_data --size 1 --region iad
|
||||
```
|
||||
|
||||
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
|
||||
@@ -43,7 +43,7 @@ Edit `fly.toml` to match your app name and requirements:
|
||||
|
||||
```toml
|
||||
app = "my-clawdbot" # Your app name
|
||||
primary_region = "lhr"
|
||||
primary_region = "iad"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
@@ -134,17 +134,14 @@ fly ssh console
|
||||
|
||||
Create the config directory and file:
|
||||
```bash
|
||||
mkdir -p /data/.clawdbot
|
||||
cat > /data/.clawdbot/clawdbot.json << 'EOF'
|
||||
mkdir -p /data
|
||||
cat > /data/clawdbot.json << 'EOF'
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": {
|
||||
"primary": "anthropic/claude-opus-4-5"
|
||||
},
|
||||
"models": {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"anthropic/claude-sonnet-4-5": {}
|
||||
"primary": "anthropic/claude-opus-4-5",
|
||||
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
|
||||
},
|
||||
"maxConcurrent": 4
|
||||
},
|
||||
@@ -155,15 +152,49 @@ cat > /data/.clawdbot/clawdbot.json << 'EOF'
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"profiles": {
|
||||
"anthropic:default": { "mode": "token", "provider": "anthropic" },
|
||||
"openai:default": { "mode": "token", "provider": "openai" }
|
||||
}
|
||||
},
|
||||
"bindings": [
|
||||
{
|
||||
"agentId": "main",
|
||||
"match": { "channel": "discord" }
|
||||
}
|
||||
],
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true
|
||||
"enabled": true,
|
||||
"groupPolicy": "allowlist",
|
||||
"guilds": {
|
||||
"YOUR_GUILD_ID": {
|
||||
"channels": { "general": { "allow": true } },
|
||||
"requireMention": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"mode": "local",
|
||||
"bind": "auto"
|
||||
},
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.1.24"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
**Note:** With `CLAWDBOT_STATE_DIR=/data`, the config path is `/data/clawdbot.json`.
|
||||
|
||||
**Note:** The Discord token can come from either:
|
||||
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)
|
||||
- Config file: `channels.discord.token`
|
||||
|
||||
If using env var, no need to add token to config. The gateway reads `DISCORD_BOT_TOKEN` automatically.
|
||||
|
||||
Restart to apply:
|
||||
```bash
|
||||
exit
|
||||
@@ -206,7 +237,7 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
|
||||
|
||||
### OOM / Memory Issues
|
||||
|
||||
Container keeps restarting or getting killed.
|
||||
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
|
||||
|
||||
**Fix:** Increase memory in `fly.toml`:
|
||||
```toml
|
||||
@@ -214,6 +245,13 @@ Container keeps restarting or getting killed.
|
||||
memory = "2048mb"
|
||||
```
|
||||
|
||||
Or update an existing machine:
|
||||
```bash
|
||||
fly machine update <machine-id> --vm-memory 2048 -y
|
||||
```
|
||||
|
||||
**Note:** 512MB is too small. 1GB may work but can OOM under load or with verbose logging. **2GB is recommended.**
|
||||
|
||||
### Gateway Lock Issues
|
||||
|
||||
Gateway refuses to start with "already running" errors.
|
||||
@@ -222,12 +260,12 @@ This happens when the container restarts but the PID lock file persists on the v
|
||||
|
||||
**Fix:** Delete the lock file:
|
||||
```bash
|
||||
fly ssh console
|
||||
rm /data/.clawdbot/run/gateway.*.lock
|
||||
exit
|
||||
fly ssh console --command "rm -f /data/gateway.*.lock"
|
||||
fly machine restart <machine-id>
|
||||
```
|
||||
|
||||
The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
|
||||
|
||||
### Config Not Being Read
|
||||
|
||||
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
|
||||
@@ -237,6 +275,24 @@ Verify the config exists:
|
||||
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
|
||||
```
|
||||
|
||||
### Writing Config via SSH
|
||||
|
||||
The `fly ssh console -C` command doesn't support shell redirection. To write a config file:
|
||||
|
||||
```bash
|
||||
# Use echo + tee (pipe from local to remote)
|
||||
echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
|
||||
|
||||
# Or use sftp
|
||||
fly sftp shell
|
||||
> put /local/path/config.json /data/.clawdbot/clawdbot.json
|
||||
```
|
||||
|
||||
**Note:** `fly sftp` may fail if the file already exists. Delete first:
|
||||
```bash
|
||||
fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
|
||||
```
|
||||
|
||||
## Updates
|
||||
|
||||
```bash
|
||||
@@ -251,6 +307,23 @@ fly status
|
||||
fly logs
|
||||
```
|
||||
|
||||
### Updating Machine Command
|
||||
|
||||
If you need to change the startup command without a full redeploy:
|
||||
|
||||
```bash
|
||||
# Get machine ID
|
||||
fly machines list
|
||||
|
||||
# Update command
|
||||
fly machine update <machine-id> --command "node dist/index.js gateway --port 3000 --bind lan" -y
|
||||
|
||||
# Or with memory increase
|
||||
fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js gateway --port 3000 --bind lan" -y
|
||||
```
|
||||
|
||||
**Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy.
|
||||
|
||||
## Notes
|
||||
|
||||
- Fly.io uses **x86 architecture** (not ARM)
|
||||
|
||||
@@ -30,17 +30,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.23 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.23 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
|
||||
- Upload `Clawdbot-2026.1.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -38,7 +38,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||
- [Contacts + Search](/plugins/contacts-search) — bundled unified contacts + cross-platform search (disabled by default)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
---
|
||||
summary: "Contacts + Search plugin: unified contacts and cross-platform message search"
|
||||
read_when:
|
||||
- You want unified contacts or cross-platform message search
|
||||
- You are enabling the contacts-search plugin
|
||||
---
|
||||
|
||||
# Contacts + Search (plugin)
|
||||
|
||||
Unified contact graph + cross-platform message search.
|
||||
Indexes incoming messages, links platform identities, and exposes `/search` plus CLI tools.
|
||||
|
||||
## What it adds
|
||||
|
||||
- `clawdbot contacts ...` (link, list, search, stats)
|
||||
- `clawdbot search ...` (message search)
|
||||
- `/search ...` slash command (text surfaces)
|
||||
|
||||
## Where it runs
|
||||
|
||||
Runs inside the Gateway process. Enable it on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
## Enable (bundled)
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Or in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Data location
|
||||
|
||||
The contact store lives under the Clawdbot state directory:
|
||||
|
||||
- `~/.clawdbot/contacts/contacts.sqlite`
|
||||
|
||||
If you run with `--profile <name>` or `--dev`, the state root changes accordingly.
|
||||
|
||||
## Indexing notes
|
||||
|
||||
- Messages are indexed as they arrive (no backfill).
|
||||
- Search uses SQLite FTS when available; otherwise falls back to SQL `LIKE` queries.
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts search "sarah"
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot search "meeting notes" --from sarah --since 1w
|
||||
```
|
||||
|
||||
Related:
|
||||
- CLI: [contacts](/cli/contacts)
|
||||
- CLI: [search](/cli/search)
|
||||
- Concept: [Contact graph](/contact)
|
||||
- Slash commands: [Slash commands](/tools/slash-commands)
|
||||
- Plugins: [Plugins](/plugin)
|
||||
@@ -100,6 +100,7 @@ clawdbot onboard --auth-choice claude-cli
|
||||
## Notes
|
||||
|
||||
- Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host.
|
||||
- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token or resync Claude Code CLI OAuth on the gateway host. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription).
|
||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Outbound Session Mirroring Refactor (Issue #1520)
|
||||
description: Track outbound session mirroring refactor notes, decisions, tests, and open items.
|
||||
---
|
||||
|
||||
# Outbound Session Mirroring Refactor (Issue #1520)
|
||||
|
||||
## Status
|
||||
- In progress.
|
||||
- Core + plugin channel routing updated for outbound mirroring.
|
||||
- Gateway send now derives target session when sessionKey is omitted.
|
||||
|
||||
## Context
|
||||
Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries.
|
||||
|
||||
## Goals
|
||||
- Mirror outbound messages into the target channel session key.
|
||||
- Create session entries on outbound when missing.
|
||||
- Keep thread/topic scoping aligned with inbound session keys.
|
||||
- Cover core channels plus bundled extensions.
|
||||
|
||||
## Implementation Summary
|
||||
- New outbound session routing helper:
|
||||
- `src/infra/outbound/outbound-session.ts`
|
||||
- `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks).
|
||||
- `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`.
|
||||
- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring.
|
||||
- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key.
|
||||
- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey.
|
||||
- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry.
|
||||
|
||||
## Thread/Topic Handling
|
||||
- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix).
|
||||
- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session).
|
||||
- Telegram: topic IDs map to `chatId:topic:<id>` via `buildTelegramGroupPeerId`.
|
||||
|
||||
## Extensions Covered
|
||||
- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon.
|
||||
- Notes:
|
||||
- Mattermost targets now strip `@` for DM session key routing.
|
||||
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
|
||||
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
|
||||
- Slack auto-thread mirroring matches channel ids case-insensitively.
|
||||
- Gateway send lowercases provided session keys before mirroring.
|
||||
|
||||
## Decisions
|
||||
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
|
||||
- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats.
|
||||
- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available.
|
||||
- **Session key casing**: canonicalize session keys to lowercase on write and during migrations.
|
||||
|
||||
## Tests Added/Updated
|
||||
- `src/infra/outbound/outbound-session.test.ts`
|
||||
- Slack thread session key.
|
||||
- Telegram topic session key.
|
||||
- dmScope identityLinks with Discord.
|
||||
- `src/agents/tools/message-tool.test.ts`
|
||||
- Derives agentId from session key (no sessionKey passed through).
|
||||
- `src/gateway/server-methods/send.test.ts`
|
||||
- Derives session key when omitted and creates session entry.
|
||||
|
||||
## Open Items / Follow-ups
|
||||
- Voice-call plugin uses custom `voice:<phone>` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping.
|
||||
- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set.
|
||||
|
||||
## Files Touched
|
||||
- `src/infra/outbound/outbound-session.ts`
|
||||
- `src/infra/outbound/outbound-send-service.ts`
|
||||
- `src/infra/outbound/message-action-runner.ts`
|
||||
- `src/agents/tools/message-tool.ts`
|
||||
- `src/gateway/server-methods/send.ts`
|
||||
- Tests in:
|
||||
- `src/infra/outbound/outbound-session.test.ts`
|
||||
- `src/agents/tools/message-tool.test.ts`
|
||||
- `src/gateway/server-methods/send.test.ts`
|
||||
@@ -78,3 +78,30 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
- [ ] Announce/share release notes.
|
||||
|
||||
## Plugin publish scope (npm)
|
||||
|
||||
We only publish **existing npm plugins** under the `@clawdbot/*` scope. Bundled
|
||||
plugins that are not on npm stay **disk-tree only** (still shipped in
|
||||
`extensions/**`).
|
||||
|
||||
Process to derive the list:
|
||||
1) `npm search @clawdbot --json` and capture the package names.
|
||||
2) Compare with `extensions/*/package.json` names.
|
||||
3) Publish only the **intersection** (already on npm).
|
||||
|
||||
Current npm plugin list (update as needed):
|
||||
- @clawdbot/bluebubbles
|
||||
- @clawdbot/diagnostics-otel
|
||||
- @clawdbot/discord
|
||||
- @clawdbot/lobster
|
||||
- @clawdbot/matrix
|
||||
- @clawdbot/msteams
|
||||
- @clawdbot/nextcloud-talk
|
||||
- @clawdbot/nostr
|
||||
- @clawdbot/voice-call
|
||||
- @clawdbot/zalo
|
||||
- @clawdbot/zalouser
|
||||
|
||||
Release notes must also call out **new optional bundled plugins** that are **not
|
||||
on by default** (example: `tlon`).
|
||||
@@ -92,6 +92,21 @@ In group chats where you receive every message, be **smart about when to contrib
|
||||
|
||||
Participate, don't dominate.
|
||||
|
||||
### 😊 React Like a Human!
|
||||
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
|
||||
|
||||
**React when:**
|
||||
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
|
||||
- Something made you laugh (😂, 💀)
|
||||
- You find it interesting or thought-provoking (🤔, 💡)
|
||||
- You want to acknowledge without interrupting the flow
|
||||
- It's a simple yes/no or approval situation (✅, 👀)
|
||||
|
||||
**Why it matters:**
|
||||
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
|
||||
|
||||
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
|
||||
|
||||
## Tools
|
||||
|
||||
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
|
||||
|
||||
-1652
File diff suppressed because it is too large.
Load diff
@@ -6,9 +6,10 @@ read_when:
|
||||
# Elevated Mode (/elevated directives)
|
||||
|
||||
## What it does
|
||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
||||
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
|
||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
||||
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
||||
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
|
||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||
@@ -18,8 +19,8 @@ read_when:
|
||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
|
||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||
|
||||
|
||||
@@ -157,6 +157,36 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
## Approval forwarding to chat channels
|
||||
|
||||
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
|
||||
them with `/approve`. This uses the normal outbound delivery pipeline.
|
||||
|
||||
Config:
|
||||
```json5
|
||||
{
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session", // "session" | "targets" | "both"
|
||||
agentFilter: ["main"],
|
||||
sessionFilter: ["discord"], // substring or regex
|
||||
targets: [
|
||||
{ channel: "slack", to: "U12345678" },
|
||||
{ channel: "telegram", to: "123456789" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reply in chat:
|
||||
```
|
||||
/approve <id> allow-once
|
||||
/approve <id> allow-always
|
||||
/approve <id> deny
|
||||
```
|
||||
|
||||
### macOS IPC flow
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
||||
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||
- `node` (string): node id/name for `host=node`
|
||||
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
||||
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
|
||||
|
||||
Notes:
|
||||
- `host` defaults to `sandbox`.
|
||||
|
||||
+3
-3
@@ -379,10 +379,10 @@ List sessions, inspect transcript history, or send to another session.
|
||||
|
||||
Core parameters:
|
||||
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
|
||||
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
|
||||
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
|
||||
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
|
||||
- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override)
|
||||
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
|
||||
|
||||
Notes:
|
||||
- `main` is the canonical direct-chat key; global/unknown are hidden.
|
||||
|
||||
@@ -61,20 +61,15 @@ Text + native (when enabled):
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/search <query> [--from <contact>] [--platform <name>] [--since <time>]` (search messages across platforms; requires [Contacts + Search](/plugins/contacts-search))
|
||||
- `/context [list|detail|json]` (explain "context"; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts_on` (enable TTS replies)
|
||||
- `/tts_off` (disable TTS replies)
|
||||
- `/tts_provider [openai|elevenlabs]` (set or show TTS provider)
|
||||
- `/tts_limit <chars>` (max chars before TTS summarization)
|
||||
- `/tts_summary on|off` (toggle TTS auto-summary)
|
||||
- `/tts_status` (show TTS status)
|
||||
- `/audio <text>` (convert text to a TTS audio reply)
|
||||
- `/tts on|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
|
||||
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
|
||||
- `/stop`
|
||||
- `/restart`
|
||||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ current limits and pricing.
|
||||
|
||||
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
|
||||
environment. For a gateway install, put it in `~/.clawdbot/.env` (or your
|
||||
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
|
||||
service environment). See [Env vars](/help/faq#how-does-clawdbot-load-environment-variables).
|
||||
|
||||
## Using Perplexity (direct or via OpenRouter)
|
||||
|
||||
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
---
|
||||
summary: "Text-to-speech (TTS) for outbound replies"
|
||||
read_when:
|
||||
- Enabling text-to-speech for replies
|
||||
- Configuring TTS providers or limits
|
||||
- Using /tts commands
|
||||
---
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
Clawdbot can convert outbound replies into audio using ElevenLabs or OpenAI.
|
||||
It works anywhere Clawdbot can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
|
||||
## Required keys
|
||||
|
||||
At least one of:
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
If both are configured, the selected provider is used first and the other is a fallback.
|
||||
Auto-summary uses the configured `summaryModel` (or `agents.defaults.model.primary`),
|
||||
so that provider must also be authenticated if you enable summaries.
|
||||
|
||||
## Service links
|
||||
|
||||
- [OpenAI Text-to-Speech guide](https://platform.openai.com/docs/guides/text-to-speech)
|
||||
- [OpenAI Audio API reference](https://platform.openai.com/docs/api-reference/audio)
|
||||
- [ElevenLabs Text to Speech](https://elevenlabs.io/docs/api-reference/text-to-speech)
|
||||
- [ElevenLabs Authentication](https://elevenlabs.io/docs/api-reference/authentication)
|
||||
|
||||
## Is it enabled by default?
|
||||
|
||||
No. TTS is **disabled** by default. Enable it in config or with `/tts on`,
|
||||
which writes a local preference override.
|
||||
|
||||
## Config
|
||||
|
||||
TTS config lives under `messages.tts` in `clawdbot.json`.
|
||||
Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
|
||||
### Minimal config (enable + provider)
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: "elevenlabs"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI primary with ElevenLabs fallback
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
provider: "openai",
|
||||
summaryModel: "openai/gpt-4.1-mini",
|
||||
modelOverrides: {
|
||||
enabled: true
|
||||
},
|
||||
openai: {
|
||||
apiKey: "openai_api_key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy"
|
||||
},
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs_api_key",
|
||||
baseUrl: "https://api.elevenlabs.io",
|
||||
voiceId: "voice_id",
|
||||
modelId: "eleven_multilingual_v2",
|
||||
seed: 42,
|
||||
applyTextNormalization: "auto",
|
||||
languageCode: "en",
|
||||
voiceSettings: {
|
||||
stability: 0.5,
|
||||
similarityBoost: 0.75,
|
||||
style: 0.0,
|
||||
useSpeakerBoost: true,
|
||||
speed: 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom limits + prefs path
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true,
|
||||
maxTextLength: 4000,
|
||||
timeoutMs: 30000,
|
||||
prefsPath: "~/.clawdbot/settings/tts.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Disable auto-summary for long replies
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```
|
||||
/tts summary off
|
||||
```
|
||||
|
||||
### Notes on fields
|
||||
|
||||
- `enabled`: master toggle (default `false`; local prefs can override).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: `"elevenlabs"` or `"openai"` (fallback is automatic).
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
- Accepts `provider/model` or a configured model alias.
|
||||
- `modelOverrides`: allow the model to emit TTS directives (on by default).
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path.
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `elevenlabs.voiceSettings`:
|
||||
- `stability`, `similarityBoost`, `style`: `0..1`
|
||||
- `useSpeakerBoost`: `true|false`
|
||||
- `speed`: `0.5..2.0` (1.0 = normal)
|
||||
- `elevenlabs.applyTextNormalization`: `auto|on|off`
|
||||
- `elevenlabs.languageCode`: 2-letter ISO 639-1 (e.g. `en`, `de`)
|
||||
- `elevenlabs.seed`: integer `0..4294967295` (best-effort determinism)
|
||||
|
||||
## Model-driven overrides (default on)
|
||||
|
||||
By default, the model **can** emit TTS directives for a single reply.
|
||||
|
||||
When enabled, the model can emit `[[tts:...]]` directives to override the voice
|
||||
for a single reply, plus an optional `[[tts:text]]...[[/tts:text]]` block to
|
||||
provide expressive tags (laughter, singing cues, etc) that should only appear in
|
||||
the audio.
|
||||
|
||||
Example reply payload:
|
||||
|
||||
```
|
||||
Here you go.
|
||||
|
||||
[[tts:provider=elevenlabs voiceId=pMsXgVXv3BLzUgSXRplE model=eleven_v3 speed=1.1]]
|
||||
[[tts:text]](laughs) Read the song once more.[[/tts:text]]
|
||||
```
|
||||
|
||||
Available directive keys (when enabled):
|
||||
- `provider` (`openai` | `elevenlabs`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs)
|
||||
- `model` (OpenAI TTS model or ElevenLabs model id)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
- `applyTextNormalization` (`auto|on|off`)
|
||||
- `languageCode` (ISO 639-1)
|
||||
- `seed`
|
||||
|
||||
Disable all model overrides:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Optional allowlist (disable specific overrides while keeping tags enabled):
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
modelOverrides: {
|
||||
enabled: true,
|
||||
allowProvider: false,
|
||||
allowSeed: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Per-user preferences
|
||||
|
||||
Slash commands write local overrides to `prefsPath` (default:
|
||||
`~/.clawdbot/settings/tts.json`, override with `CLAWDBOT_TTS_PREFS` or
|
||||
`messages.tts.prefsPath`).
|
||||
|
||||
Stored fields:
|
||||
- `enabled`
|
||||
- `provider`
|
||||
- `maxLength` (summary threshold; default 1500 chars)
|
||||
- `summarize` (default `true`)
|
||||
|
||||
These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
|
||||
This is not configurable; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
When enabled, Clawdbot:
|
||||
- skips TTS if the reply already contains media or a `MEDIA:` directive.
|
||||
- skips very short replies (< 10 chars).
|
||||
- summarizes long replies when enabled using `agents.defaults.model.primary` (or `summaryModel`).
|
||||
- attaches the generated audio to the reply.
|
||||
|
||||
If the reply exceeds `maxLength` and summary is off (or no API key for the
|
||||
summary model), audio
|
||||
is skipped and the normal text reply is sent.
|
||||
|
||||
## Flow diagram
|
||||
|
||||
```
|
||||
Reply -> TTS enabled?
|
||||
no -> send text
|
||||
yes -> has media / MEDIA: / short?
|
||||
yes -> send text
|
||||
no -> length > limit?
|
||||
no -> TTS -> attach audio
|
||||
yes -> summary enabled?
|
||||
no -> send text
|
||||
yes -> summarize (summaryModel or agents.defaults.model.primary)
|
||||
-> TTS -> attach audio
|
||||
```
|
||||
|
||||
## Slash command usage
|
||||
|
||||
There is a single command: `/tts`.
|
||||
See [Slash commands](/tools/slash-commands) for enablement details.
|
||||
|
||||
Discord note: `/tts` is a built-in Discord command, so Clawdbot registers
|
||||
`/voice` as the native command there. Text `/tts ...` still works.
|
||||
|
||||
```
|
||||
/tts on
|
||||
/tts off
|
||||
/tts status
|
||||
/tts provider openai
|
||||
/tts limit 2000
|
||||
/tts summary off
|
||||
/tts audio Hello from Clawdbot
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Commands require an authorized sender (allowlist/owner rules still apply).
|
||||
- `commands.text` or native command registration must be enabled.
|
||||
- `limit` and `summary` are stored in local prefs, not the main config.
|
||||
- `/tts audio` generates a one-off audio reply (does not toggle TTS on).
|
||||
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
|
||||
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
|
||||
Telegram sends a voice bubble.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
Gateway methods:
|
||||
- `tts.status`
|
||||
- `tts.enable`
|
||||
- `tts.disable`
|
||||
- `tts.convert`
|
||||
- `tts.setProvider`
|
||||
- `tts.providers`
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"id": "contacts-search",
|
||||
"name": "Contacts + Search",
|
||||
"description": "Unified contact graph and cross-platform message search",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
configureContactStore,
|
||||
closeContactStore,
|
||||
} from "./src/contacts/index.js";
|
||||
import { registerContactsCli } from "./src/cli/contacts-cli.js";
|
||||
import { registerSearchCli } from "./src/cli/search-cli.js";
|
||||
import { runSearchCommand } from "./src/commands/search-command.js";
|
||||
import { indexInboundMessage } from "./src/hooks/message-indexer.js";
|
||||
|
||||
const contactsSearchPlugin = {
|
||||
id: "contacts-search",
|
||||
name: "Contacts + Search",
|
||||
description: "Unified contact graph with cross-platform message search",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
const stateDir = api.runtime.state.resolveStateDir();
|
||||
configureContactStore({ stateDir });
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerContactsCli(program);
|
||||
registerSearchCli(program);
|
||||
},
|
||||
{ commands: ["contacts", "search"] },
|
||||
);
|
||||
|
||||
api.registerCommand({
|
||||
name: "search",
|
||||
description: "Search messages across platforms.",
|
||||
acceptsArgs: true,
|
||||
handler: async (ctx) => ({ text: runSearchCommand(ctx.commandBody) }),
|
||||
});
|
||||
|
||||
api.on(
|
||||
"message_received",
|
||||
(event, ctx) => {
|
||||
indexInboundMessage({ event, ctx, logger: api.logger });
|
||||
},
|
||||
);
|
||||
|
||||
api.registerService({
|
||||
id: "contacts-search",
|
||||
start: () => {},
|
||||
stop: () => {
|
||||
closeContactStore();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default contactsSearchPlugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/contacts-search",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot unified contacts and cross-platform search plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
findLinkSuggestions,
|
||||
getContactStore,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { cli, formatDanger, formatSuccess, renderTable, theme } from "./formatting.js";
|
||||
|
||||
function formatPlatformList(platforms: string[]): string {
|
||||
return platforms.join(", ");
|
||||
}
|
||||
|
||||
function formatContactRow(contact: {
|
||||
canonicalId: string;
|
||||
displayName: string;
|
||||
aliases: string[];
|
||||
identities: Array<{ platform: string; platformId: string; username?: string | null }>;
|
||||
}) {
|
||||
const platforms = [...new Set(contact.identities.map((i) => i.platform))];
|
||||
return {
|
||||
ID: contact.canonicalId,
|
||||
Name: contact.displayName,
|
||||
Platforms: formatPlatformList(platforms),
|
||||
Identities: String(contact.identities.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerContactsCli(program: Command) {
|
||||
const contacts = program
|
||||
.command("contacts")
|
||||
.description("Unified contact graph - cross-platform identity management")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cli/contacts",
|
||||
"docs.clawd.bot/cli/contacts",
|
||||
)}\n`,
|
||||
)
|
||||
.action(() => {
|
||||
contacts.help({ error: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("list")
|
||||
.description("List all contacts in the unified graph")
|
||||
.option("--query <text>", "Search by name or alias")
|
||||
.option("--platform <name>", "Filter by platform (channel id)")
|
||||
.option("--limit <n>", "Limit results", "50")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 50;
|
||||
|
||||
const platform = opts.platform
|
||||
? ((opts.platform as string).toLowerCase() as Platform)
|
||||
: undefined;
|
||||
const contactsList = store.listContacts({
|
||||
query: opts.query as string | undefined,
|
||||
platform,
|
||||
limit,
|
||||
});
|
||||
|
||||
const contactsWithIdentities = contactsList
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(contactsWithIdentities, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (contactsWithIdentities.length === 0) {
|
||||
cli.log(theme.muted("No contacts found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
|
||||
);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: contactsWithIdentities.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts show
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("show")
|
||||
.description("Show details for a specific contact")
|
||||
.argument("<id>", "Contact canonical ID or search query")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
// Try exact match first
|
||||
let contact = store.getContactWithIdentities(id);
|
||||
|
||||
// If not found, search
|
||||
if (!contact) {
|
||||
const matches = store.searchContacts(id, 1);
|
||||
contact = matches[0] ?? null;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
cli.error(formatDanger(`Contact not found: ${id}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(contact, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(`${theme.heading("Contact")}`);
|
||||
cli.log(` ID: ${contact.canonicalId}`);
|
||||
cli.log(` Name: ${contact.displayName}`);
|
||||
if (contact.aliases.length > 0) {
|
||||
cli.log(` Aliases: ${contact.aliases.join(", ")}`);
|
||||
}
|
||||
|
||||
cli.log("");
|
||||
cli.log(
|
||||
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Platform", header: "Platform", minWidth: 10 },
|
||||
{ key: "ID", header: "Platform ID", minWidth: 20, flex: true },
|
||||
{ key: "Username", header: "Username", minWidth: 12, flex: true },
|
||||
{ key: "Phone", header: "Phone", minWidth: 14 },
|
||||
],
|
||||
rows: contact.identities.map((i) => ({
|
||||
Platform: i.platform,
|
||||
ID: i.platformId,
|
||||
Username: i.username ?? "",
|
||||
Phone: i.phone ?? "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts search
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("search")
|
||||
.description("Search contacts by name, alias, or username")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--limit <n>", "Limit results", "10")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 10;
|
||||
const results = store.searchContacts(query, limit);
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
cli.log(theme.muted(`No contacts found matching "${query}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: results.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("link")
|
||||
.description("Link two contacts (merge into one)")
|
||||
.argument("<primary>", "Primary contact ID (will keep this one)")
|
||||
.argument("<secondary>", "Secondary contact ID (will be merged and deleted)")
|
||||
.action(async (primary: string, secondary: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = linkContacts(store, primary, secondary);
|
||||
|
||||
if (!result.success) {
|
||||
cli.error(formatDanger(result.error ?? "Failed to link contacts"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(formatSuccess(`Linked: ${secondary} merged into ${primary}`));
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts unlink
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("unlink")
|
||||
.description("Unlink a platform identity from its contact (creates a new contact)")
|
||||
.argument("<platform>", "Platform (channel id)")
|
||||
.argument("<platformId>", "Platform-specific user ID")
|
||||
.action(async (platform: string, platformId: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = unlinkIdentity(store, platform, platformId);
|
||||
|
||||
if (!result.success) {
|
||||
cli.error(formatDanger(result.error ?? "Failed to unlink identity"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts suggestions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("suggestions")
|
||||
.description("Show link suggestions (contacts that may be the same person)")
|
||||
.option("--min-score <n>", "Minimum name similarity score (0-1)", "0.85")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const minScore = parseFloat(opts.minScore as string) || 0.85;
|
||||
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(suggestions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
cli.log(theme.muted("No link suggestions found."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Source", header: "Source", minWidth: 20, flex: true },
|
||||
{ key: "Target", header: "Target", minWidth: 20, flex: true },
|
||||
{ key: "Reason", header: "Reason", minWidth: 14 },
|
||||
{ key: "Confidence", header: "Confidence", minWidth: 10 },
|
||||
{ key: "Score", header: "Score", minWidth: 6 },
|
||||
],
|
||||
rows: suggestions.map((s) => ({
|
||||
Source: `${s.sourceIdentity.platform}:${s.sourceIdentity.displayName || s.sourceIdentity.platformId}`,
|
||||
Target: `${s.targetIdentity.platform}:${s.targetIdentity.displayName || s.targetIdentity.platformId}`,
|
||||
Reason: s.reason,
|
||||
Confidence: s.confidence,
|
||||
Score: s.score.toFixed(2),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
cli.log("");
|
||||
cli.log(
|
||||
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts auto-link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("auto-link")
|
||||
.description("Automatically link high-confidence matches (e.g., same phone number)")
|
||||
.option("--dry-run", "Show what would be linked without making changes", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
if (opts.dryRun) {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
if (highConfidence.length === 0) {
|
||||
cli.log(theme.muted("No high-confidence matches found."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
|
||||
);
|
||||
for (const s of highConfidence) {
|
||||
cli.log(
|
||||
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
if (result.linked === 0) {
|
||||
cli.log(theme.muted("No high-confidence matches found to auto-link."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(formatSuccess(`Auto-linked ${result.linked} contact(s)`));
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("stats")
|
||||
.description("Show contact store statistics")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const stats = store.getStats();
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(stats, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(`${theme.heading("Contact Store Statistics")}`);
|
||||
cli.log(` Contacts: ${stats.contacts}`);
|
||||
cli.log(` Identities: ${stats.identities}`);
|
||||
cli.log(` Indexed Messages: ${stats.messages}`);
|
||||
cli.log("");
|
||||
cli.log(`${theme.heading("Identities by Platform")}`);
|
||||
for (const [platform, count] of Object.entries(stats.platforms)) {
|
||||
cli.log(` ${platform}: ${count}`);
|
||||
}
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts alias
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("alias")
|
||||
.description("Add or remove an alias for a contact")
|
||||
.argument("<contactId>", "Contact ID")
|
||||
.argument("<alias>", "Alias to add")
|
||||
.option("--remove", "Remove the alias instead of adding", false)
|
||||
.action(async (contactId: string, alias: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const contact = store.getContact(contactId);
|
||||
|
||||
if (!contact) {
|
||||
cli.error(formatDanger(`Contact not found: ${contactId}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAliases = contact.aliases;
|
||||
let newAliases: string[];
|
||||
|
||||
if (opts.remove) {
|
||||
newAliases = currentAliases.filter((a) => a !== alias);
|
||||
if (newAliases.length === currentAliases.length) {
|
||||
cli.log(theme.muted(`Alias "${alias}" not found on this contact.`));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (currentAliases.includes(alias)) {
|
||||
cli.log(theme.muted(`Alias "${alias}" already exists on this contact.`));
|
||||
return;
|
||||
}
|
||||
newAliases = [...currentAliases, alias];
|
||||
}
|
||||
|
||||
store.updateContact(contactId, { aliases: newAliases });
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
type TableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
minWidth?: number;
|
||||
flex?: boolean;
|
||||
};
|
||||
|
||||
type TableRow = Record<string, string | number | null | undefined>;
|
||||
|
||||
type TableOptions = {
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const pad = (value: string, width: number) => value.padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) return pad(value, width);
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return value.slice(0, width - 3) + "...";
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
heading: (value: string) => value,
|
||||
muted: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
accentBright: (value: string) => value,
|
||||
};
|
||||
|
||||
export const cli = {
|
||||
log: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
},
|
||||
error: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
},
|
||||
exit: (code: number) => {
|
||||
process.exit(code);
|
||||
},
|
||||
};
|
||||
|
||||
export const formatSuccess = (message: string) => message;
|
||||
export const formatDanger = (message: string) => message;
|
||||
|
||||
export function renderTable({ columns, rows, width }: TableOptions): string {
|
||||
const widths = columns.map((column) => {
|
||||
const headerWidth = column.header.length;
|
||||
const minWidth = column.minWidth ?? 0;
|
||||
const maxRowWidth = rows.reduce((max, row) => {
|
||||
const value = String(row[column.key] ?? "");
|
||||
return Math.max(max, value.length);
|
||||
}, 0);
|
||||
return Math.max(minWidth, headerWidth, maxRowWidth);
|
||||
});
|
||||
|
||||
if (width) {
|
||||
const baseWidth = widths.reduce((sum, colWidth) => sum + colWidth, 0);
|
||||
const totalWidth = baseWidth + (columns.length - 1) * 2;
|
||||
if (totalWidth > width) {
|
||||
const flexColumns = columns
|
||||
.map((column, index) => (column.flex ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
if (flexColumns.length > 0) {
|
||||
const excess = totalWidth - width;
|
||||
const shrinkEach = Math.ceil(excess / flexColumns.length);
|
||||
for (const index of flexColumns) {
|
||||
const minWidth = columns[index]!.minWidth ?? 4;
|
||||
widths[index] = Math.max(minWidth, widths[index]! - shrinkEach);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const header = columns
|
||||
.map((column, index) => truncate(column.header, widths[index]!))
|
||||
.join(" ");
|
||||
const separator = columns
|
||||
.map((_, index) => "-".repeat(widths[index]!))
|
||||
.join(" ");
|
||||
|
||||
const lines = [header, separator];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
columns
|
||||
.map((column, index) =>
|
||||
truncate(String(row[column.key] ?? ""), widths[index]!),
|
||||
)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { cli, formatDanger, theme } from "./formatting.js";
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Handle relative times like "1h", "2d", "1w"
|
||||
const relativeMatch = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (relativeMatch) {
|
||||
const amount = parseInt(relativeMatch[1]!, 10);
|
||||
const unit = relativeMatch[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ISO date strings
|
||||
const parsed = Date.parse(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerSearchCli(program: Command) {
|
||||
program
|
||||
.command("search")
|
||||
.description("Search messages across all messaging platforms")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
|
||||
.option(
|
||||
"--platform <name>",
|
||||
"Filter by platform (channel id)",
|
||||
)
|
||||
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
|
||||
.option("--until <time>", "Filter messages before this time")
|
||||
.option("--limit <n>", "Limit results", "20")
|
||||
.option("--json", "Output JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Examples:")}\n` +
|
||||
` clawdbot search "meeting tomorrow"\n` +
|
||||
` clawdbot search "deadline" --from alice\n` +
|
||||
` clawdbot search "project" --platform slack --since 1w\n` +
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/search", "docs.clawd.bot/cli/search")}\n`,
|
||||
)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 20;
|
||||
|
||||
// Parse platforms
|
||||
let platforms: Platform[] | undefined;
|
||||
if (opts.platform) {
|
||||
const platform = (opts.platform as string).toLowerCase() as Platform;
|
||||
platforms = [platform];
|
||||
}
|
||||
|
||||
// Parse timestamps
|
||||
const since = opts.since ? parseTimestamp(opts.since as string) : undefined;
|
||||
const until = opts.until ? parseTimestamp(opts.until as string) : undefined;
|
||||
|
||||
if (opts.since && since === null) {
|
||||
cli.error(formatDanger(`Invalid --since value: ${opts.since}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.until && until === null) {
|
||||
cli.error(formatDanger(`Invalid --until value: ${opts.until}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = store.searchMessages({
|
||||
query,
|
||||
from: opts.from as string | undefined,
|
||||
platforms,
|
||||
since: since ?? undefined,
|
||||
until: until ?? undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
cli.log(theme.muted(`No messages found matching "${query}".`));
|
||||
|
||||
// Helpful hints
|
||||
if (opts.from) {
|
||||
const contactMatches = store.searchContacts(opts.from as string, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
cli.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
cli.log("");
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
|
||||
cli.log(
|
||||
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
|
||||
);
|
||||
cli.log(` ${snippet}`);
|
||||
cli.log("");
|
||||
}
|
||||
|
||||
if (results.length === limit) {
|
||||
cli.log(
|
||||
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
/**
|
||||
* Parse relative time strings like "1h", "2d", "1w"
|
||||
*/
|
||||
function parseRelativeTime(value: string): number | null {
|
||||
const match = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = parseInt(match[1]!, 10);
|
||||
const unit = match[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search command arguments.
|
||||
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
|
||||
*/
|
||||
function tokenizeArgs(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g;
|
||||
for (const match of input.matchAll(pattern)) {
|
||||
const raw = match[1] ?? match[2] ?? match[0];
|
||||
tokens.push(raw.replace(/\\(["'\\])/g, "$1"));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseSearchArgs(commandBody: string): {
|
||||
query: string;
|
||||
from?: string;
|
||||
platform?: Platform;
|
||||
since?: number;
|
||||
error?: string;
|
||||
} {
|
||||
const argsStr = commandBody.replace(/^\/search\s*/i, "").trim();
|
||||
if (!argsStr) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
let query = "";
|
||||
let from: string | undefined;
|
||||
let platform: Platform | undefined;
|
||||
let since: number | undefined;
|
||||
|
||||
const parts = tokenizeArgs(argsStr);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
if (part === "--from" && i + 1 < parts.length) {
|
||||
const fromParts: string[] = [];
|
||||
while (i + 1 < parts.length && !parts[i + 1]!.startsWith("--")) {
|
||||
fromParts.push(parts[++i]!);
|
||||
}
|
||||
if (fromParts.length === 0) {
|
||||
return { query: "", error: "Missing value for --from" };
|
||||
}
|
||||
from = fromParts.join(" ");
|
||||
} else if (part === "--platform" && i + 1 < parts.length) {
|
||||
platform = parts[++i]!.toLowerCase() as Platform;
|
||||
} else if (part === "--since" && i + 1 < parts.length) {
|
||||
const timeStr = parts[++i]!;
|
||||
const parsed = parseRelativeTime(timeStr);
|
||||
if (parsed === null) {
|
||||
return {
|
||||
query: "",
|
||||
error: `Invalid --since value: ${timeStr}. Use format like 1h, 2d, 1w, 1m`,
|
||||
};
|
||||
}
|
||||
since = parsed;
|
||||
} else if (part.startsWith("--")) {
|
||||
return { query: "", error: `Unknown option: ${part}` };
|
||||
} else {
|
||||
queryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
query = queryParts.join(" ");
|
||||
if (!query) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
return { query, from, platform, since };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
describe("parseSearchArgs", () => {
|
||||
it("handles multi-word --from without quotes", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
|
||||
it("handles quoted multi-word --from", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from "Sarah Smith" --since 1w');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
expect(parsed.since).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("keeps multi-word query alongside --from", () => {
|
||||
const parsed = parseSearchArgs('/search quarterly report --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("quarterly report");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
*/
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function runSearchCommand(commandBody: string): string {
|
||||
const parsed = parseSearchArgs(commandBody);
|
||||
if (parsed.error) {
|
||||
return `❌ ${parsed.error}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: parsed.query,
|
||||
from: parsed.from,
|
||||
platforms: parsed.platform ? [parsed.platform] : undefined,
|
||||
since: parsed.since,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
let msg = `🔍 No messages found matching "${parsed.query}"`;
|
||||
if (parsed.from) {
|
||||
const contactMatches = store.searchContacts(parsed.from, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
msg += `\n\n⚠️ Note: No contacts found matching "${parsed.from}"`;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const lines = [`🔍 Search Results (${results.length})\n`];
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
const platformLabel = message.platform.toUpperCase();
|
||||
|
||||
lines.push(`[${platformLabel}] ${senderName} - ${time}`);
|
||||
lines.push(` ${snippet}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (results.length === 10) {
|
||||
lines.push('Use the CLI for more results: clawdbot search "' + parsed.query + '" --limit 50');
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
} catch (err) {
|
||||
return `❌ Search error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
extractDiscordContact,
|
||||
extractIMessageContact,
|
||||
extractSignalContact,
|
||||
extractSlackContact,
|
||||
extractTelegramContact,
|
||||
extractWhatsAppContact,
|
||||
importContactFromMessage,
|
||||
importDiscordGuildMembers,
|
||||
importSlackUsers,
|
||||
importWhatsAppGroupParticipants,
|
||||
isJidGroup,
|
||||
jidToE164,
|
||||
} from "./importer.js";
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("extracts phone from standard JID", () => {
|
||||
expect(jidToE164("14155551234@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("extracts phone from JID with device suffix", () => {
|
||||
expect(jidToE164("14155551234:0@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for invalid JID", () => {
|
||||
expect(jidToE164("")).toBeNull();
|
||||
expect(jidToE164("invalid")).toBeNull();
|
||||
expect(jidToE164("abc@s.whatsapp.net")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
// Group JIDs don't have phone numbers
|
||||
expect(jidToE164("123456789-1234567890@g.us")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isJidGroup", () => {
|
||||
it("returns true for group JIDs", () => {
|
||||
expect(isJidGroup("123456789-1234567890@g.us")).toBe(true);
|
||||
expect(isJidGroup("status@broadcast")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user JIDs", () => {
|
||||
expect(isJidGroup("14155551234@s.whatsapp.net")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWhatsAppContact", () => {
|
||||
it("extracts contact data from sender JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
pushName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "123-456@g.us",
|
||||
pushName: "Group Name",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing push name", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
});
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTelegramContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
username: "johndoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing fields", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
});
|
||||
expect(data?.username).toBeNull();
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
|
||||
it("handles first name only", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
firstName: "John",
|
||||
});
|
||||
expect(data?.displayName).toBe("John");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDiscordContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
nick: "Johnny",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "discord",
|
||||
platformId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "Johnny", // Nick takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to globalName when no nick", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("falls back to username when no globalName", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
});
|
||||
expect(data?.displayName).toBe("johndoe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSlackContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
displayName: "John Doe",
|
||||
realName: "John Michael Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "slack",
|
||||
platformId: "U12345678",
|
||||
username: "john.doe",
|
||||
phone: null,
|
||||
displayName: "John Doe", // displayName takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to realName when no displayName", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
realName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignalContact", () => {
|
||||
it("extracts contact data from signal envelope", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceUuid: "uuid-123-456",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "signal",
|
||||
platformId: "uuid-123-456", // UUID preferred
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses phone as platformId when no UUID", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data?.platformId).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null when no identifier", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIMessageContact", () => {
|
||||
it("extracts contact from phone number", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "+14155551234",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "+14155551234",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts contact from email", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "john@example.com",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "john@example.com",
|
||||
username: null,
|
||||
phone: null, // Email is not a phone
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("importContactFromMessage", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("creates new contact for unknown sender", () => {
|
||||
const { contactId, isNew } = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
expect(isNew).toBe(true);
|
||||
expect(contactId).toMatch(/^john-doe-[a-f0-9]{8}$/);
|
||||
|
||||
const contact = store.getContactWithIdentities(contactId);
|
||||
expect(contact?.displayName).toBe("John Doe");
|
||||
expect(contact?.identities.length).toBe(1);
|
||||
expect(contact?.identities[0]?.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("returns existing contact for known sender", () => {
|
||||
// First import
|
||||
const first = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(first.isNew).toBe(true);
|
||||
|
||||
// Second import of same sender
|
||||
const second = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(second.isNew).toBe(false);
|
||||
expect(second.contactId).toBe(first.contactId);
|
||||
});
|
||||
|
||||
it("updates identity metadata for known sender", () => {
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johnny",
|
||||
displayName: "John D",
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("telegram", "123456789");
|
||||
expect(identity?.username).toBe("johnny");
|
||||
expect(identity?.displayName).toBe("John D");
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("uses platformId as displayName fallback", () => {
|
||||
const { contactId } = importContactFromMessage(store, {
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
displayName: null,
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const contact = store.getContact(contactId);
|
||||
expect(contact?.displayName).toBe("14155551234@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk importers", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("importSlackUsers", () => {
|
||||
it("imports users from Slack API response", async () => {
|
||||
const mockUsers = [
|
||||
{ id: "U1", name: "alice", displayName: "Alice", isBot: false },
|
||||
{ id: "U2", name: "bob", realName: "Bob Smith", isBot: false },
|
||||
{ id: "U3", name: "bot", displayName: "Bot", isBot: true }, // Should be skipped
|
||||
{ id: "U4", name: "deleted", displayName: "Deleted", deleted: true }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importSlackUsers(store, async () => mockUsers);
|
||||
|
||||
expect(result.platform).toBe("slack");
|
||||
expect(result.imported).toBe(2); // Only Alice and Bob
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
const result = await importSlackUsers(store, async () => {
|
||||
throw new Error("API error");
|
||||
});
|
||||
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.errors.length).toBe(1);
|
||||
expect(result.errors[0]).toContain("Failed to list Slack users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importDiscordGuildMembers", () => {
|
||||
it("imports members from Discord API response", async () => {
|
||||
const mockMembers = [
|
||||
{ user: { id: "1", username: "alice", global_name: "Alice" }, nick: null },
|
||||
{ user: { id: "2", username: "bob", global_name: "Bob" }, nick: "Bobby" },
|
||||
{ user: { id: "3", username: "botuser", bot: true } }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importDiscordGuildMembers(store, async () => mockMembers);
|
||||
|
||||
expect(result.platform).toBe("discord");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importWhatsAppGroupParticipants", () => {
|
||||
it("imports participants from group metadata", async () => {
|
||||
const mockGetMetadata = async (_jid: string) => ({
|
||||
subject: "Test Group",
|
||||
participants: [
|
||||
{ id: "14155551111@s.whatsapp.net" },
|
||||
{ id: "14155552222@s.whatsapp.net" },
|
||||
{ id: "123-456@g.us" }, // Group JID should be skipped
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importWhatsAppGroupParticipants(store, mockGetMetadata, "123-456@g.us");
|
||||
|
||||
expect(result.platform).toBe("whatsapp");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,393 +0,0 @@
|
||||
/**
|
||||
* Contact importers for each messaging platform.
|
||||
*
|
||||
* Since many platforms don't have bulk contact APIs, importers use a combination of:
|
||||
* 1. Direct API calls where available (Slack users.list, Discord members)
|
||||
* 2. Message-based discovery (observing incoming messages)
|
||||
* 3. Group metadata extraction
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { ImportResult, Platform, PlatformIdentityInput } from "./types.js";
|
||||
|
||||
/**
|
||||
* Base interface for platform-specific contact importers.
|
||||
*/
|
||||
export type ContactImporter = {
|
||||
/** Platform this importer handles */
|
||||
platform: Platform;
|
||||
|
||||
/** Import contacts from this platform */
|
||||
import(store: ContactStore): Promise<ImportResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract E.164 phone number from WhatsApp JID.
|
||||
* JID format: "1234567890@s.whatsapp.net" or "1234567890:0@s.whatsapp.net"
|
||||
*/
|
||||
export function jidToE164(jid: string): string | null {
|
||||
if (!jid) return null;
|
||||
// Remove suffix
|
||||
const numberPart = jid.split("@")[0];
|
||||
if (!numberPart) return null;
|
||||
// Handle device suffix (e.g., "1234567890:0")
|
||||
const phone = numberPart.split(":")[0];
|
||||
if (!phone || !/^\d{7,15}$/.test(phone)) return null;
|
||||
return `+${phone}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a JID is a group.
|
||||
*/
|
||||
export function isJidGroup(jid: string): boolean {
|
||||
return jid.includes("@g.us") || jid.includes("@broadcast");
|
||||
}
|
||||
|
||||
/**
|
||||
* Data extracted from an incoming message for contact discovery.
|
||||
*/
|
||||
export type MessageContactData = {
|
||||
platform: Platform;
|
||||
platformId: string;
|
||||
username?: string | null;
|
||||
phone?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import a contact from message data.
|
||||
* Creates a new contact if the platform identity doesn't exist,
|
||||
* or updates the existing contact's metadata.
|
||||
*/
|
||||
export function importContactFromMessage(
|
||||
store: ContactStore,
|
||||
data: MessageContactData,
|
||||
): { contactId: string; isNew: boolean } {
|
||||
// Check if identity already exists
|
||||
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
|
||||
if (existing) {
|
||||
const updated = {
|
||||
contactId: existing.contactId,
|
||||
platform: existing.platform,
|
||||
platformId: existing.platformId,
|
||||
username: data.username ?? existing.username,
|
||||
phone: data.phone ?? existing.phone,
|
||||
displayName: data.displayName ?? existing.displayName,
|
||||
lastSeenAt: Date.now(),
|
||||
} satisfies PlatformIdentityInput;
|
||||
store.addIdentity(updated);
|
||||
return { contactId: existing.contactId, isNew: false };
|
||||
}
|
||||
|
||||
// Create new contact and identity
|
||||
const displayName = data.displayName || data.username || data.platformId;
|
||||
const contact = store.createContact(displayName);
|
||||
|
||||
const input: PlatformIdentityInput = {
|
||||
contactId: contact.canonicalId,
|
||||
platform: data.platform,
|
||||
platformId: data.platformId,
|
||||
username: data.username ?? null,
|
||||
phone: data.phone ?? null,
|
||||
displayName: data.displayName ?? null,
|
||||
lastSeenAt: Date.now(),
|
||||
};
|
||||
store.addIdentity(input);
|
||||
|
||||
return { contactId: contact.canonicalId, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp contact data extraction from message.
|
||||
*/
|
||||
export function extractWhatsAppContact(params: {
|
||||
senderJid: string;
|
||||
pushName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderJid, pushName } = params;
|
||||
if (!senderJid || isJidGroup(senderJid)) return null;
|
||||
|
||||
const phone = jidToE164(senderJid);
|
||||
|
||||
return {
|
||||
platform: "whatsapp",
|
||||
platformId: senderJid,
|
||||
username: null,
|
||||
phone,
|
||||
displayName: pushName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram contact data extraction from message.
|
||||
*/
|
||||
export function extractTelegramContact(params: {
|
||||
userId: number | string;
|
||||
username?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, firstName, lastName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(" ") || null;
|
||||
|
||||
return {
|
||||
platform: "telegram",
|
||||
platformId: String(userId),
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord contact data extraction from message.
|
||||
*/
|
||||
export function extractDiscordContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
globalName?: string | null;
|
||||
nick?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, globalName, nick } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
// Prefer display names: nick > globalName > username
|
||||
const displayName = nick || globalName || username || null;
|
||||
|
||||
return {
|
||||
platform: "discord",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack contact data extraction from message.
|
||||
*/
|
||||
export function extractSlackContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
realName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, displayName, realName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
return {
|
||||
platform: "slack",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName: displayName || realName || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal contact data extraction from message.
|
||||
*/
|
||||
export function extractSignalContact(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { sourceNumber, sourceUuid, sourceName } = params;
|
||||
|
||||
// Prefer UUID as platformId, fall back to phone
|
||||
const platformId = sourceUuid || sourceNumber;
|
||||
if (!platformId) return null;
|
||||
|
||||
return {
|
||||
platform: "signal",
|
||||
platformId,
|
||||
username: null,
|
||||
phone: sourceNumber ?? null,
|
||||
displayName: sourceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* iMessage contact data extraction from message.
|
||||
*/
|
||||
export function extractIMessageContact(params: {
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderId, senderName } = params;
|
||||
if (!senderId) return null;
|
||||
|
||||
// iMessage senderId can be phone or email
|
||||
const isPhone = /^\+?\d{10,}$/.test(senderId.replace(/\D/g, ""));
|
||||
|
||||
return {
|
||||
platform: "imessage",
|
||||
platformId: senderId,
|
||||
username: null,
|
||||
phone: isPhone ? senderId : null,
|
||||
displayName: senderName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BULK IMPORTERS (for platforms with bulk APIs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slack bulk importer using users.list API.
|
||||
*/
|
||||
export async function importSlackUsers(
|
||||
store: ContactStore,
|
||||
listUsers: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
realName?: string;
|
||||
email?: string;
|
||||
isBot?: boolean;
|
||||
deleted?: boolean;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "slack",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const users = await listUsers();
|
||||
|
||||
for (const user of users) {
|
||||
// Skip bots and deleted users
|
||||
if (user.isBot || user.deleted) continue;
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractSlackContact({
|
||||
userId: user.id,
|
||||
username: user.name,
|
||||
displayName: user.displayName,
|
||||
realName: user.realName,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import user ${user.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Slack users: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord bulk importer using guild member search.
|
||||
*/
|
||||
export async function importDiscordGuildMembers(
|
||||
store: ContactStore,
|
||||
listMembers: () => Promise<
|
||||
Array<{
|
||||
user: {
|
||||
id: string;
|
||||
username?: string;
|
||||
global_name?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
nick?: string | null;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "discord",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const members = await listMembers();
|
||||
|
||||
for (const member of members) {
|
||||
// Skip bots
|
||||
if (member.user.bot) continue;
|
||||
if (!member.user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractDiscordContact({
|
||||
userId: member.user.id,
|
||||
username: member.user.username,
|
||||
globalName: member.user.global_name,
|
||||
nick: member.nick,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import member ${member.user.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Discord members: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp group participants importer.
|
||||
*/
|
||||
export async function importWhatsAppGroupParticipants(
|
||||
store: ContactStore,
|
||||
getGroupMetadata: (groupJid: string) => Promise<{
|
||||
subject?: string;
|
||||
participants?: Array<{ id: string }>;
|
||||
}>,
|
||||
groupJid: string,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "whatsapp",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const meta = await getGroupMetadata(groupJid);
|
||||
const participants = meta.participants ?? [];
|
||||
|
||||
for (const participant of participants) {
|
||||
if (!participant.id) continue;
|
||||
if (isJidGroup(participant.id)) continue;
|
||||
|
||||
try {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: participant.id,
|
||||
pushName: null, // Group metadata doesn't include push names
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import participant ${participant.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to get group metadata for ${groupJid}: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Contacts module - Unified contact graph for cross-platform identity resolution.
|
||||
*
|
||||
* This module provides:
|
||||
* - Canonical contact management (create, link, search)
|
||||
* - Platform identity linking (WhatsApp, Telegram, Discord, Slack, Signal, etc.)
|
||||
* - Message indexing for cross-platform search
|
||||
* - Auto-linking heuristics based on phone/email/name matching
|
||||
*/
|
||||
|
||||
export { ContactStore, configureContactStore, getContactStore, closeContactStore } from "./store.js";
|
||||
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
|
||||
export {
|
||||
importContactFromMessage,
|
||||
extractWhatsAppContact,
|
||||
extractTelegramContact,
|
||||
extractDiscordContact,
|
||||
extractSlackContact,
|
||||
extractSignalContact,
|
||||
extractIMessageContact,
|
||||
importSlackUsers,
|
||||
importDiscordGuildMembers,
|
||||
importWhatsAppGroupParticipants,
|
||||
jidToE164,
|
||||
isJidGroup,
|
||||
} from "./importer.js";
|
||||
export type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
ImportResult,
|
||||
IndexedMessage,
|
||||
LinkConfidence,
|
||||
LinkSuggestion,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
export type { ContactImporter, MessageContactData } from "./importer.js";
|
||||
export {
|
||||
findPhoneMatches,
|
||||
findNameMatches,
|
||||
findLinkSuggestions,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
autoLinkHighConfidence,
|
||||
} from "./linker.js";
|
||||
@@ -1,600 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
findLinkSuggestions,
|
||||
findNameMatches,
|
||||
findPhoneMatches,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "./linker.js";
|
||||
|
||||
describe("linker", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("findPhoneMatches", () => {
|
||||
it("finds contacts with same phone number", () => {
|
||||
// Create two contacts with same phone on different platforms
|
||||
const contact1 = store.createContact("John on WhatsApp");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Signal");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "uuid-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(1);
|
||||
expect(suggestions[0]?.reason).toBe("phone_match");
|
||||
expect(suggestions[0]?.confidence).toBe("high");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("does not suggest already-linked contacts", () => {
|
||||
const contact = store.createContact("John");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty for no phone matches", () => {
|
||||
const contact1 = store.createContact("Alice");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-alice",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "Alice",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-bob",
|
||||
username: null,
|
||||
phone: "+14155552222",
|
||||
displayName: "Bob",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNameMatches", () => {
|
||||
it("finds contacts with similar names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Doe"); // Same name
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findNameMatches(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0]?.reason).toBe("name_similarity");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("finds contacts with slightly different names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John D"); // Shorter version
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// With default threshold of 0.85, these may or may not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.6 });
|
||||
// At least should find something with low threshold
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects minimum score threshold", () => {
|
||||
const contact1 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob Jones");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-bob",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Bob Jones",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Completely different names should not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.85 });
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLinkSuggestions", () => {
|
||||
it("combines phone and name matches", () => {
|
||||
// Phone match
|
||||
const contact1 = store.createContact("Phone User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Phone User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Name match (different person)
|
||||
const contact3 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact3.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact4 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact4.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Phone matches should come first (high confidence)
|
||||
const phoneMatch = suggestions.find((s) => s.reason === "phone_match");
|
||||
expect(phoneMatch).toBeDefined();
|
||||
expect(phoneMatch?.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("sorts by confidence then score", () => {
|
||||
// Create multiple potential matches with different confidence levels
|
||||
const contact1 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify sorted by confidence
|
||||
for (let i = 1; i < suggestions.length; i++) {
|
||||
const prev = suggestions[i - 1]!;
|
||||
const curr = suggestions[i]!;
|
||||
const confidenceOrder = { high: 3, medium: 2, low: 1 };
|
||||
const prevConf = confidenceOrder[prev.confidence];
|
||||
const currConf = confidenceOrder[curr.confidence];
|
||||
expect(prevConf).toBeGreaterThanOrEqual(currConf);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkContacts", () => {
|
||||
it("merges two contacts", () => {
|
||||
const contact1 = store.createContact("John on Telegram");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe_tg",
|
||||
phone: null,
|
||||
displayName: "John TG",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Discord");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe_dc",
|
||||
phone: null,
|
||||
displayName: "John DC",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact1.canonicalId, contact2.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Primary contact should have both identities
|
||||
const merged = store.getContactWithIdentities(contact1.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("discord");
|
||||
|
||||
// Secondary contact should be deleted
|
||||
expect(store.getContact(contact2.canonicalId)).toBeNull();
|
||||
|
||||
// Aliases should include secondary contact's name
|
||||
expect(merged?.aliases).toContain("John on Discord");
|
||||
});
|
||||
|
||||
it("reassigns message history when merging contacts", () => {
|
||||
const primary = store.createContact("Merge Primary");
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
const secondary = store.createContact("Merge Secondary");
|
||||
store.addIdentity({
|
||||
contactId: secondary.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-merge-1",
|
||||
content: "merge history one",
|
||||
platform: "telegram",
|
||||
senderId: "tg-merge",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-merge-2",
|
||||
content: "merge history two",
|
||||
platform: "discord",
|
||||
senderId: "dc-merge",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = linkContacts(store, primary.canonicalId, secondary.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const results = store.searchMessages({ query: "merge history", from: primary.canonicalId });
|
||||
expect(results.length).toBe(2);
|
||||
const ids = results.map((entry) => entry.message.id);
|
||||
expect(ids).toContain("msg-merge-1");
|
||||
expect(ids).toContain("msg-merge-2");
|
||||
expect(results.every((entry) => entry.message.contactId === primary.canonicalId)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns error for non-existent primary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, "fake-id", contact.canonicalId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Primary contact not found");
|
||||
});
|
||||
|
||||
it("returns error for non-existent secondary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact.canonicalId, "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Secondary contact not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinkIdentity", () => {
|
||||
it("creates new contact for unlinked identity", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-user",
|
||||
username: "user_tg",
|
||||
phone: null,
|
||||
displayName: "TG User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-user",
|
||||
username: "user_dc",
|
||||
phone: null,
|
||||
displayName: "DC User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-user");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContactId).toBeDefined();
|
||||
|
||||
// Original contact should only have telegram identity
|
||||
const original = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(original?.identities.length).toBe(1);
|
||||
expect(original?.identities[0]?.platform).toBe("telegram");
|
||||
|
||||
// New contact should have discord identity
|
||||
const newContact = store.getContactWithIdentities(result.newContactId!);
|
||||
expect(newContact?.identities.length).toBe(1);
|
||||
expect(newContact?.identities[0]?.platform).toBe("discord");
|
||||
});
|
||||
|
||||
it("moves message history to new contact when unlinking", () => {
|
||||
const contact = store.createContact("Unlink User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-unlink",
|
||||
content: "unlink history message",
|
||||
platform: "discord",
|
||||
senderId: "dc-unlink",
|
||||
channelId: "c3",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-unlink");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const newContactId = result.newContactId!;
|
||||
const newResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: newContactId,
|
||||
});
|
||||
expect(newResults.length).toBe(1);
|
||||
expect(newResults[0]?.message.contactId).toBe(newContactId);
|
||||
|
||||
const oldResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(oldResults.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns error for non-existent identity", () => {
|
||||
const result = unlinkIdentity(store, "telegram", "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Identity not found");
|
||||
});
|
||||
|
||||
it("returns error when trying to unlink only identity", () => {
|
||||
const contact = store.createContact("Single Identity User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-single",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "telegram", "tg-single");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Cannot unlink the only identity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoLinkHighConfidence", () => {
|
||||
it("automatically links high confidence matches", () => {
|
||||
// Create contacts with same phone (high confidence)
|
||||
const contact1 = store.createContact("Auto Link User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Auto Link User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const initialCount = store.listContacts().length;
|
||||
expect(initialCount).toBe(2);
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
expect(result.linked).toBe(1);
|
||||
|
||||
// Should now have only one contact
|
||||
const finalCount = store.listContacts().length;
|
||||
expect(finalCount).toBe(1);
|
||||
|
||||
// The remaining contact should have both identities
|
||||
const contacts = store.listContacts();
|
||||
const merged = store.getContactWithIdentities(contacts[0]!.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not link medium/low confidence matches", () => {
|
||||
// Create contacts with similar but not exact names (medium confidence)
|
||||
const contact1 = store.createContact("John Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Smyth"); // Similar but not same
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smyth",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
// Name similarity below threshold should not auto-link
|
||||
// They may or may not be linked depending on exact similarity
|
||||
// But we verify auto-link only processes high confidence
|
||||
expect(result.suggestions.every((s) => s.confidence === "high")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
/**
|
||||
* Auto-linking heuristics for the unified contact graph.
|
||||
*
|
||||
* This module provides algorithms to automatically detect and suggest
|
||||
* links between platform identities that likely belong to the same person.
|
||||
*
|
||||
* Linking heuristics (in priority order):
|
||||
* 1. Phone match: Same E.164 phone across platforms (high confidence)
|
||||
* 2. Name similarity: Fuzzy name matching with high threshold (medium confidence)
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { LinkConfidence, LinkSuggestion, PlatformIdentity } from "./types.js";
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings.
|
||||
*/
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize matrix
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0]![j] = j;
|
||||
}
|
||||
|
||||
// Fill in the rest of the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j - 1]! + cost, // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length]![a.length]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity score between two strings (0-1).
|
||||
* 1 = identical, 0 = completely different.
|
||||
*/
|
||||
function calculateSimilarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
// Normalize: lowercase, trim, remove extra whitespace
|
||||
const normalizedA = a.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const normalizedB = b.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
|
||||
if (normalizedA === normalizedB) return 1;
|
||||
if (normalizedA.length === 0 || normalizedB.length === 0) return 0;
|
||||
|
||||
const maxLength = Math.max(normalizedA.length, normalizedB.length);
|
||||
const distance = levenshteinDistance(normalizedA, normalizedB);
|
||||
|
||||
return 1 - distance / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number for comparison.
|
||||
* Strips non-digit characters except leading +.
|
||||
*/
|
||||
function normalizePhone(phone: string | null): string | null {
|
||||
if (!phone) return null;
|
||||
// Keep only digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if 10 digits without country code
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized.length >= 10 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on phone number matching.
|
||||
* This is the highest confidence match since phone numbers are unique identifiers.
|
||||
*/
|
||||
export function findPhoneMatches(store: ContactStore): LinkSuggestion[] {
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
|
||||
// Build phone → identities map
|
||||
const phoneToIdentities = new Map<string, PlatformIdentity[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const withIdentities = store.getContactWithIdentities(contact.canonicalId);
|
||||
if (!withIdentities) continue;
|
||||
|
||||
for (const identity of withIdentities.identities) {
|
||||
const phone = normalizePhone(identity.phone);
|
||||
if (!phone) continue;
|
||||
|
||||
const existing = phoneToIdentities.get(phone) ?? [];
|
||||
existing.push(identity);
|
||||
phoneToIdentities.set(phone, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Find identities with same phone but different contacts
|
||||
for (const [_phone, identities] of phoneToIdentities) {
|
||||
if (identities.length < 2) continue;
|
||||
|
||||
// Group by contact ID
|
||||
const byContact = new Map<string, PlatformIdentity[]>();
|
||||
for (const identity of identities) {
|
||||
const existing = byContact.get(identity.contactId) ?? [];
|
||||
existing.push(identity);
|
||||
byContact.set(identity.contactId, existing);
|
||||
}
|
||||
|
||||
// If all belong to same contact, already linked
|
||||
if (byContact.size < 2) continue;
|
||||
|
||||
// Create suggestions for each pair of contacts
|
||||
const contactIds = Array.from(byContact.keys());
|
||||
for (let i = 0; i < contactIds.length; i++) {
|
||||
for (let j = i + 1; j < contactIds.length; j++) {
|
||||
const sourceIdentity = byContact.get(contactIds[i]!)![0]!;
|
||||
const targetIdentity = byContact.get(contactIds[j]!)![0]!;
|
||||
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "phone_match",
|
||||
confidence: "high",
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on name similarity.
|
||||
* Uses fuzzy matching with a configurable threshold.
|
||||
*/
|
||||
export function findNameMatches(
|
||||
store: ContactStore,
|
||||
options: { minScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const { minScore = 0.85 } = options;
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
const contactsWithIdentities = contacts
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
// Compare each pair of contacts
|
||||
for (let i = 0; i < contactsWithIdentities.length; i++) {
|
||||
for (let j = i + 1; j < contactsWithIdentities.length; j++) {
|
||||
const contactA = contactsWithIdentities[i]!;
|
||||
const contactB = contactsWithIdentities[j]!;
|
||||
|
||||
// Skip if already same contact
|
||||
if (contactA.canonicalId === contactB.canonicalId) continue;
|
||||
|
||||
// Compare display names
|
||||
const similarity = calculateSimilarity(contactA.displayName, contactB.displayName);
|
||||
|
||||
if (similarity >= minScore) {
|
||||
// Get representative identities for the suggestion
|
||||
const sourceIdentity = contactA.identities[0];
|
||||
const targetIdentity = contactB.identities[0];
|
||||
|
||||
if (sourceIdentity && targetIdentity) {
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "name_similarity",
|
||||
confidence: similarity >= 0.95 ? "high" : "medium",
|
||||
score: similarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also compare identity display names
|
||||
for (const identityA of contactA.identities) {
|
||||
for (const identityB of contactB.identities) {
|
||||
if (!identityA.displayName || !identityB.displayName) continue;
|
||||
|
||||
const identitySimilarity = calculateSimilarity(
|
||||
identityA.displayName,
|
||||
identityB.displayName,
|
||||
);
|
||||
|
||||
if (identitySimilarity >= minScore) {
|
||||
// Avoid duplicate suggestions
|
||||
const alreadySuggested = suggestions.some(
|
||||
(s) =>
|
||||
(s.sourceIdentity.id === identityA.id && s.targetIdentity.id === identityB.id) ||
|
||||
(s.sourceIdentity.id === identityB.id && s.targetIdentity.id === identityA.id),
|
||||
);
|
||||
|
||||
if (!alreadySuggested) {
|
||||
suggestions.push({
|
||||
sourceIdentity: identityA,
|
||||
targetIdentity: identityB,
|
||||
reason: "name_similarity",
|
||||
confidence: identitySimilarity >= 0.95 ? "high" : "medium",
|
||||
score: identitySimilarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all link suggestions across all heuristics.
|
||||
* Returns suggestions sorted by confidence and score.
|
||||
*/
|
||||
export function findLinkSuggestions(
|
||||
store: ContactStore,
|
||||
options: { minNameScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const phoneSuggestions = findPhoneMatches(store);
|
||||
const nameSuggestions = findNameMatches(store, { minScore: options.minNameScore });
|
||||
|
||||
// Combine and sort by confidence (high first) then score
|
||||
const all = [...phoneSuggestions, ...nameSuggestions];
|
||||
|
||||
const confidenceOrder: Record<LinkConfidence, number> = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
return all.sort((a, b) => {
|
||||
const confDiff = confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
|
||||
if (confDiff !== 0) return confDiff;
|
||||
return b.score - a.score;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two contacts by merging all identities into the primary contact.
|
||||
* The secondary contact is deleted.
|
||||
*/
|
||||
export function linkContacts(
|
||||
store: ContactStore,
|
||||
primaryContactId: string,
|
||||
secondaryContactId: string,
|
||||
): { success: boolean; error?: string } {
|
||||
const primary = store.getContactWithIdentities(primaryContactId);
|
||||
const secondary = store.getContactWithIdentities(secondaryContactId);
|
||||
|
||||
if (!primary) {
|
||||
return { success: false, error: `Primary contact not found: ${primaryContactId}` };
|
||||
}
|
||||
if (!secondary) {
|
||||
return { success: false, error: `Secondary contact not found: ${secondaryContactId}` };
|
||||
}
|
||||
|
||||
// Move all identities from secondary to primary
|
||||
for (const identity of secondary.identities) {
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge aliases
|
||||
const newAliases = [...primary.aliases];
|
||||
if (!newAliases.includes(secondary.displayName)) {
|
||||
newAliases.push(secondary.displayName);
|
||||
}
|
||||
for (const alias of secondary.aliases) {
|
||||
if (!newAliases.includes(alias)) {
|
||||
newAliases.push(alias);
|
||||
}
|
||||
}
|
||||
store.updateContact(primary.canonicalId, { aliases: newAliases });
|
||||
|
||||
// Delete secondary contact
|
||||
store.deleteContact(secondaryContactId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a platform identity from its current contact.
|
||||
* Creates a new contact for the identity.
|
||||
*/
|
||||
export function unlinkIdentity(
|
||||
store: ContactStore,
|
||||
platform: string,
|
||||
platformId: string,
|
||||
): { success: boolean; newContactId?: string; error?: string } {
|
||||
const identity = store.getIdentityByPlatformId(platform, platformId);
|
||||
if (!identity) {
|
||||
return { success: false, error: `Identity not found: ${platform}:${platformId}` };
|
||||
}
|
||||
|
||||
const currentContact = store.getContactWithIdentities(identity.contactId);
|
||||
if (!currentContact) {
|
||||
return { success: false, error: `Contact not found: ${identity.contactId}` };
|
||||
}
|
||||
|
||||
// If this is the only identity, nothing to unlink
|
||||
if (currentContact.identities.length === 1) {
|
||||
return { success: false, error: "Cannot unlink the only identity from a contact" };
|
||||
}
|
||||
|
||||
// Create new contact for this identity
|
||||
const displayName = identity.displayName || identity.username || identity.platformId;
|
||||
const newContact = store.createContact(displayName);
|
||||
|
||||
// Move the identity to the new contact
|
||||
store.addIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
|
||||
return { success: true, newContactId: newContact.canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-apply high-confidence link suggestions.
|
||||
* Returns the number of links applied.
|
||||
*/
|
||||
export function autoLinkHighConfidence(store: ContactStore): {
|
||||
linked: number;
|
||||
suggestions: LinkSuggestion[];
|
||||
} {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
let linked = 0;
|
||||
const processedContacts = new Set<string>();
|
||||
|
||||
for (const suggestion of highConfidence) {
|
||||
const sourceContactId = suggestion.sourceIdentity.contactId;
|
||||
const targetContactId = suggestion.targetIdentity.contactId;
|
||||
|
||||
// Skip if either contact was already processed (merged into another)
|
||||
if (processedContacts.has(sourceContactId) || processedContacts.has(targetContactId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if same contact (already linked)
|
||||
if (sourceContactId === targetContactId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = linkContacts(store, sourceContactId, targetContactId);
|
||||
if (result.success) {
|
||||
linked++;
|
||||
processedContacts.add(targetContactId);
|
||||
}
|
||||
}
|
||||
|
||||
return { linked, suggestions: highConfidence };
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Ensures the contact store schema is created in the SQLite database.
|
||||
* Creates tables for contacts, platform identities, and indexed messages.
|
||||
*/
|
||||
export function ensureContactStoreSchema(db: DatabaseSync): {
|
||||
ftsAvailable: boolean;
|
||||
ftsError?: string;
|
||||
} {
|
||||
// Unified contacts table - canonical contact records
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
canonical_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
aliases TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Platform identities table - links platform-specific IDs to canonical contacts
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS platform_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id TEXT NOT NULL REFERENCES contacts(canonical_id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
phone TEXT,
|
||||
display_name TEXT,
|
||||
last_seen_at INTEGER,
|
||||
UNIQUE(platform, platform_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexed messages table - for cross-platform message search
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS indexed_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
contact_id TEXT REFERENCES contacts(canonical_id) ON DELETE SET NULL,
|
||||
platform TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
embedding TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexes for efficient queries
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_contact_id ON platform_identities(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_platform ON platform_identities(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_phone ON platform_identities(phone);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_username ON platform_identities(username);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_contact_id ON indexed_messages(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_platform ON indexed_messages(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_sender_id ON indexed_messages(sender_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_channel_id ON indexed_messages(channel_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_timestamp ON indexed_messages(timestamp);`,
|
||||
);
|
||||
|
||||
// Full-text search virtual table for message content
|
||||
let ftsAvailable = false;
|
||||
let ftsError: string | undefined;
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
id UNINDEXED,
|
||||
contact_id UNINDEXED,
|
||||
platform UNINDEXED,
|
||||
sender_id UNINDEXED,
|
||||
channel_id UNINDEXED,
|
||||
timestamp UNINDEXED
|
||||
);
|
||||
`);
|
||||
ftsAvailable = true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ftsAvailable = false;
|
||||
ftsError = message;
|
||||
}
|
||||
|
||||
// Migration helper - add columns if they don't exist
|
||||
ensureColumn(db, "contacts", "aliases", "TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a column exists on a table, adding it if missing.
|
||||
*/
|
||||
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (rows.some((row) => row.name === column)) return;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all contact store tables (for testing/reset).
|
||||
*/
|
||||
export function dropContactStoreTables(db: DatabaseSync): void {
|
||||
db.exec(`DROP TABLE IF EXISTS messages_fts;`);
|
||||
db.exec(`DROP TABLE IF EXISTS indexed_messages;`);
|
||||
db.exec(`DROP TABLE IF EXISTS platform_identities;`);
|
||||
db.exec(`DROP TABLE IF EXISTS contacts;`);
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import type { Platform } from "./types.js";
|
||||
|
||||
describe("ContactStore", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("contacts", () => {
|
||||
it("creates a contact with generated canonical ID", () => {
|
||||
const contact = store.createContact("Sarah Jones");
|
||||
expect(contact.canonicalId).toMatch(/^sarah-jones-[a-f0-9]{8}$/);
|
||||
expect(contact.displayName).toBe("Sarah Jones");
|
||||
expect(contact.aliases).toEqual([]);
|
||||
expect(contact.createdAt).toBeGreaterThan(0);
|
||||
expect(contact.updatedAt).toBe(contact.createdAt);
|
||||
});
|
||||
|
||||
it("creates a contact with aliases", () => {
|
||||
const contact = store.createContact("Bob Smith", ["Bobby", "Bob S"]);
|
||||
expect(contact.aliases).toEqual(["Bobby", "Bob S"]);
|
||||
});
|
||||
|
||||
it("retrieves a contact by canonical ID", () => {
|
||||
const created = store.createContact("Alice Doe");
|
||||
const retrieved = store.getContact(created.canonicalId);
|
||||
expect(retrieved).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const retrieved = store.getContact("non-existent-id");
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("updates contact display name", () => {
|
||||
const contact = store.createContact("Old Name");
|
||||
const success = store.updateContact(contact.canonicalId, { displayName: "New Name" });
|
||||
expect(success).toBe(true);
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.displayName).toBe("New Name");
|
||||
// updatedAt should be >= createdAt (may be same millisecond in fast tests)
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(contact.updatedAt);
|
||||
});
|
||||
|
||||
it("updates contact aliases", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
store.updateContact(contact.canonicalId, { aliases: ["Tester", "TU"] });
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.aliases).toEqual(["Tester", "TU"]);
|
||||
});
|
||||
|
||||
it("returns false when updating non-existent contact", () => {
|
||||
const success = store.updateContact("fake-id", { displayName: "Test" });
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a contact", () => {
|
||||
const contact = store.createContact("To Delete");
|
||||
expect(store.getContact(contact.canonicalId)).not.toBeNull();
|
||||
|
||||
const deleted = store.deleteContact(contact.canonicalId);
|
||||
expect(deleted).toBe(true);
|
||||
expect(store.getContact(contact.canonicalId)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false when deleting non-existent contact", () => {
|
||||
const deleted = store.deleteContact("fake-id");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("lists all contacts", () => {
|
||||
store.createContact("Alpha User");
|
||||
store.createContact("Beta User");
|
||||
store.createContact("Gamma User");
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(3);
|
||||
});
|
||||
|
||||
it("lists contacts with query filter", () => {
|
||||
store.createContact("John Doe");
|
||||
store.createContact("Jane Doe", ["Janey"]);
|
||||
store.createContact("Bob Smith");
|
||||
|
||||
const contacts = store.listContacts({ query: "doe" });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("lists contacts with limit", () => {
|
||||
store.createContact("User 1");
|
||||
store.createContact("User 2");
|
||||
store.createContact("User 3");
|
||||
|
||||
const contacts = store.listContacts({ limit: 2 });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform identities", () => {
|
||||
it("adds a platform identity to a contact", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
const identity = store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "testuser",
|
||||
phone: null,
|
||||
displayName: "Test User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
expect(identity.id).toBeGreaterThan(0);
|
||||
expect(identity.contactId).toBe(contact.canonicalId);
|
||||
expect(identity.platform).toBe("telegram");
|
||||
expect(identity.platformId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("retrieves identities by contact ID", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-123",
|
||||
username: "teleuser",
|
||||
phone: null,
|
||||
displayName: "Tele User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-456",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.getIdentitiesByContact(contact.canonicalId);
|
||||
expect(identities.length).toBe(2);
|
||||
expect(identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(identities.map((i) => i.platform)).toContain("discord");
|
||||
});
|
||||
|
||||
it("retrieves identity by platform and platform ID", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "+14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("whatsapp", "+14155551234@s.whatsapp.net");
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity?.contactId).toBe(contact.canonicalId);
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for non-existent identity", () => {
|
||||
const identity = store.getIdentityByPlatformId("telegram", "fake-id");
|
||||
expect(identity).toBeNull();
|
||||
});
|
||||
|
||||
it("finds identities by phone number", () => {
|
||||
const contact = store.createContact("Phone User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-jid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-uuid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Signal User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.findIdentitiesByPhone("+14155551234");
|
||||
expect(identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("updates last seen timestamp", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-id",
|
||||
username: "user",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.updateIdentityLastSeen("telegram", "tg-id");
|
||||
const identity = store.getIdentityByPlatformId("telegram", "tg-id");
|
||||
expect(identity?.lastSeenAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves platform sender to contact ID", () => {
|
||||
const contact = store.createContact("Resolvable User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "discord-user-id",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord Display",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const resolved = store.resolveContact("discord", "discord-user-id");
|
||||
expect(resolved).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("returns null when resolving unknown sender", () => {
|
||||
const resolved = store.resolveContact("telegram", "unknown-id");
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("contact search", () => {
|
||||
it("searches contacts by display name", () => {
|
||||
store.createContact("Alice Wonderland");
|
||||
store.createContact("Bob Builder");
|
||||
store.createContact("Alice Cooper");
|
||||
|
||||
const results = store.searchContacts("alice");
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Wonderland");
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Cooper");
|
||||
});
|
||||
|
||||
it("searches contacts by username", () => {
|
||||
const contact = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("johndoe");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("returns contact with all identities", () => {
|
||||
const contact = store.createContact("Multi User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-multi",
|
||||
username: "multi_tg",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "slack",
|
||||
platformId: "slack-multi",
|
||||
username: "multi_slack",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("multi");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.identities.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message indexing", () => {
|
||||
it("indexes a message", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-1",
|
||||
content: "Hello, this is a test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-123",
|
||||
channelId: "channel-456",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.messages).toBe(1);
|
||||
});
|
||||
|
||||
it("links message to contact when sender is resolved", () => {
|
||||
const contact = store.createContact("Known Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-known",
|
||||
username: "known",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-linked",
|
||||
content: "Message from known sender",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-known",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Search should find the message
|
||||
const results = store.searchMessages({ query: "known sender" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("filters messages by canonical contact id", () => {
|
||||
const contact = store.createContact("Filter Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-filter",
|
||||
username: "filter",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-filter",
|
||||
content: "Message for canonical filter",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-filter",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "canonical filter",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("searches messages by content", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-search-1",
|
||||
content: "The quick brown fox jumps over the lazy dog",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-search-2",
|
||||
content: "A slow red turtle crawls under the fence",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "quick fox" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.id).toBe("msg-search-1");
|
||||
});
|
||||
|
||||
it("filters messages by platform", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-tg",
|
||||
content: "Telegram message about deadlines",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-dc",
|
||||
content: "Discord message about deadlines",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "deadlines",
|
||||
platforms: ["telegram"],
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("filters messages by timestamp range", () => {
|
||||
const now = Date.now();
|
||||
store.indexMessage({
|
||||
id: "msg-old",
|
||||
content: "Old message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now - 7 * 24 * 60 * 60 * 1000, // 7 days ago
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-new",
|
||||
content: "New message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "projects",
|
||||
since: now - 24 * 60 * 60 * 1000, // Last 24 hours
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.id).toBe("msg-new");
|
||||
});
|
||||
|
||||
it("creates snippet with context", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-snippet",
|
||||
content:
|
||||
"This is a very long message that contains the word deadline somewhere in the middle and continues with more text after that point to test the snippet creation functionality.",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "deadline" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.snippet).toContain("deadline");
|
||||
expect(results[0]?.snippet.length).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactWithIdentities", () => {
|
||||
it("returns contact with all platform identities", () => {
|
||||
const contact = store.createContact("Full Contact");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-full",
|
||||
username: "full_tg",
|
||||
phone: "+14155551111",
|
||||
displayName: "TG Full",
|
||||
lastSeenAt: Date.now(),
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-full",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "WA Full",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.displayName).toBe("Full Contact");
|
||||
expect(result?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const result = store.getContactWithIdentities("fake-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statistics", () => {
|
||||
it("returns accurate stats", () => {
|
||||
const contact1 = store.createContact("Stats User 1");
|
||||
const contact2 = store.createContact("Stats User 2");
|
||||
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-1",
|
||||
username: "stats1",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-stats-1",
|
||||
username: "stats1dc",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-2",
|
||||
username: "stats2",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "stats-msg-1",
|
||||
content: "Stats test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "tg-stats-1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.contacts).toBe(2);
|
||||
expect(stats.identities).toBe(3);
|
||||
expect(stats.messages).toBe(1);
|
||||
expect(stats.platforms.telegram).toBe(2);
|
||||
expect(stats.platforms.discord).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,838 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
|
||||
import { requireNodeSqlite } from "../sqlite.js";
|
||||
import { ensureContactStoreSchema } from "./schema.js";
|
||||
import type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
IndexedMessage,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
const CONTACTS_DB_FILENAME = "contacts.sqlite";
|
||||
|
||||
/**
|
||||
* ContactStore manages the unified contact graph and message index.
|
||||
*
|
||||
* Key capabilities:
|
||||
* - Store and retrieve canonical contacts
|
||||
* - Link platform-specific identities to canonical contacts
|
||||
* - Index messages for cross-platform search
|
||||
* - Resolve sender identities to canonical contacts
|
||||
*/
|
||||
export class ContactStore {
|
||||
private db: DatabaseSync;
|
||||
private ftsAvailable: boolean;
|
||||
|
||||
// Prepared statements for performance
|
||||
private stmtInsertContact: StatementSync;
|
||||
private stmtUpdateContact: StatementSync;
|
||||
private stmtGetContact: StatementSync;
|
||||
private stmtDeleteContact: StatementSync;
|
||||
private stmtInsertIdentity: StatementSync;
|
||||
private stmtGetIdentitiesByContact: StatementSync;
|
||||
private stmtGetIdentityByPlatformId: StatementSync;
|
||||
private stmtUpdateIdentityLastSeen: StatementSync;
|
||||
private stmtInsertMessage: StatementSync;
|
||||
private stmtInsertMessageFts: StatementSync | null;
|
||||
private stmtUpdateMessageContactBySender: StatementSync;
|
||||
private stmtUpdateMessageFtsContactBySender: StatementSync | null;
|
||||
|
||||
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
|
||||
this.db = db;
|
||||
this.ftsAvailable = ftsAvailable;
|
||||
|
||||
// Prepare statements
|
||||
this.stmtInsertContact = db.prepare(`
|
||||
INSERT INTO contacts (canonical_id, display_name, aliases, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtUpdateContact = db.prepare(`
|
||||
UPDATE contacts SET display_name = ?, aliases = ?, updated_at = ? WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtGetContact = db.prepare(`
|
||||
SELECT canonical_id, display_name, aliases, created_at, updated_at
|
||||
FROM contacts WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtDeleteContact = db.prepare(`DELETE FROM contacts WHERE canonical_id = ?`);
|
||||
|
||||
this.stmtInsertIdentity = db.prepare(`
|
||||
INSERT OR REPLACE INTO platform_identities
|
||||
(contact_id, platform, platform_id, username, phone, display_name, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtGetIdentitiesByContact = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE contact_id = ?
|
||||
`);
|
||||
this.stmtGetIdentityByPlatformId = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
this.stmtUpdateIdentityLastSeen = db.prepare(`
|
||||
UPDATE platform_identities SET last_seen_at = ? WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
|
||||
this.stmtInsertMessage = db.prepare(`
|
||||
INSERT OR REPLACE INTO indexed_messages
|
||||
(id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.stmtInsertMessageFts = ftsAvailable
|
||||
? db.prepare(`
|
||||
INSERT OR REPLACE INTO messages_fts
|
||||
(content, id, contact_id, platform, sender_id, channel_id, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
: null;
|
||||
|
||||
this.stmtUpdateMessageContactBySender = db.prepare(`
|
||||
UPDATE indexed_messages SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`);
|
||||
this.stmtUpdateMessageFtsContactBySender = ftsAvailable
|
||||
? db.prepare(`
|
||||
UPDATE messages_fts SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or create a contact store database.
|
||||
*/
|
||||
static open(params: { dbPath?: string; stateDir?: string } = {}): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const resolvedPath =
|
||||
params.dbPath ??
|
||||
(params.stateDir ? path.join(params.stateDir, "contacts", CONTACTS_DB_FILENAME) : undefined);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("ContactStore.open requires dbPath or stateDir");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(resolvedPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new nodeSqlite.DatabaseSync(resolvedPath);
|
||||
|
||||
// Enable foreign keys
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Set up schema
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new in-memory store (for testing).
|
||||
*/
|
||||
static openInMemory(): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const db = new nodeSqlite.DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONTACT OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a canonical ID from a display name.
|
||||
*/
|
||||
private generateCanonicalId(displayName: string): string {
|
||||
const slug = displayName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 30);
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
return `${slug || "contact"}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new canonical contact.
|
||||
*/
|
||||
createContact(displayName: string, aliases: string[] = []): Contact {
|
||||
const now = Date.now();
|
||||
const canonicalId = this.generateCanonicalId(displayName);
|
||||
this.stmtInsertContact.run(canonicalId, displayName, JSON.stringify(aliases), now, now);
|
||||
return {
|
||||
canonicalId,
|
||||
displayName,
|
||||
aliases,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact by canonical ID.
|
||||
*/
|
||||
getContact(canonicalId: string): Contact | null {
|
||||
const row = this.stmtGetContact.get(canonicalId) as
|
||||
| {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact's display name and/or aliases.
|
||||
*/
|
||||
updateContact(
|
||||
canonicalId: string,
|
||||
updates: { displayName?: string; aliases?: string[] },
|
||||
): boolean {
|
||||
const existing = this.getContact(canonicalId);
|
||||
if (!existing) return false;
|
||||
|
||||
const displayName = updates.displayName ?? existing.displayName;
|
||||
const aliases = updates.aliases ?? existing.aliases;
|
||||
const now = Date.now();
|
||||
|
||||
this.stmtUpdateContact.run(displayName, JSON.stringify(aliases), now, canonicalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact and all its platform identities.
|
||||
*/
|
||||
deleteContact(canonicalId: string): boolean {
|
||||
const result = this.stmtDeleteContact.run(canonicalId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all contacts with optional filtering.
|
||||
*/
|
||||
listContacts(options: ContactSearchOptions = {}): Contact[] {
|
||||
let sql = `SELECT canonical_id, display_name, aliases, created_at, updated_at FROM contacts`;
|
||||
const params: (string | number)[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (options.query) {
|
||||
conditions.push(`(display_name LIKE ? OR aliases LIKE ?)`);
|
||||
const pattern = `%${options.query}%`;
|
||||
params.push(pattern, pattern);
|
||||
}
|
||||
|
||||
if (options.platform) {
|
||||
conditions.push(
|
||||
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
|
||||
);
|
||||
params.push(this.normalizePlatform(options.platform));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ` WHERE ${conditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY updated_at DESC`;
|
||||
|
||||
if (options.limit) {
|
||||
sql += ` LIMIT ?`;
|
||||
params.push(options.limit);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact with all its platform identities.
|
||||
*/
|
||||
getContactWithIdentities(canonicalId: string): ContactWithIdentities | null {
|
||||
const contact = this.getContact(canonicalId);
|
||||
if (!contact) return null;
|
||||
|
||||
const identities = this.getIdentitiesByContact(canonicalId);
|
||||
return { ...contact, identities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name, alias, or username.
|
||||
*/
|
||||
searchContacts(query: string, limit = 10): ContactWithIdentities[] {
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
// Search in contacts table
|
||||
const contactRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT c.canonical_id
|
||||
FROM contacts c
|
||||
LEFT JOIN platform_identities pi ON c.canonical_id = pi.contact_id
|
||||
WHERE c.display_name LIKE ?
|
||||
OR c.aliases LIKE ?
|
||||
OR pi.username LIKE ?
|
||||
OR pi.display_name LIKE ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, pattern, pattern, limit) as Array<{ canonical_id: string }>;
|
||||
|
||||
return contactRows
|
||||
.map((row) => this.getContactWithIdentities(row.canonical_id))
|
||||
.filter((c): c is ContactWithIdentities => c !== null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PLATFORM IDENTITY OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a platform identity to a contact.
|
||||
*/
|
||||
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
|
||||
const platform = this.normalizePlatform(input.platform);
|
||||
const phone = input.phone ? this.normalizePhone(input.phone) : null;
|
||||
this.stmtInsertIdentity.run(
|
||||
input.contactId,
|
||||
platform,
|
||||
input.platformId,
|
||||
input.username,
|
||||
phone,
|
||||
input.displayName,
|
||||
input.lastSeenAt,
|
||||
);
|
||||
|
||||
// Get the inserted row to return
|
||||
const identity = this.getIdentityByPlatformId(platform, input.platformId);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
`Failed to retrieve inserted identity: ${platform}:${input.platformId}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform identities for a contact.
|
||||
*/
|
||||
getIdentitiesByContact(contactId: string): PlatformIdentity[] {
|
||||
const rows = this.stmtGetIdentitiesByContact.all(contactId) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a platform identity by platform and platform-specific ID.
|
||||
*/
|
||||
getIdentityByPlatformId(platform: string, platformId: string): PlatformIdentity | null {
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
const row = this.stmtGetIdentityByPlatformId.get(normalizedPlatform, platformId) as
|
||||
| {
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find identities by phone number across all platforms.
|
||||
*/
|
||||
findIdentitiesByPhone(phone: string): PlatformIdentity[] {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE phone = ?
|
||||
`,
|
||||
)
|
||||
.all(normalized) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last seen timestamp for a platform identity.
|
||||
*/
|
||||
updateIdentityLastSeen(platform: string, platformId: string): void {
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
this.stmtUpdateIdentityLastSeen.run(Date.now(), normalizedPlatform, platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a platform sender to a canonical contact ID.
|
||||
* Returns null if the sender is not in the contact graph.
|
||||
*/
|
||||
resolveContact(platform: string, platformId: string): string | null {
|
||||
const identity = this.getIdentityByPlatformId(platform, platformId);
|
||||
return identity?.contactId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign indexed messages for a platform identity to a contact.
|
||||
*/
|
||||
updateMessageContactForIdentity(params: {
|
||||
contactId: string | null;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
}): void {
|
||||
const platform = this.normalizePlatform(params.platform);
|
||||
this.stmtUpdateMessageContactBySender.run(params.contactId, platform, params.senderId);
|
||||
this.stmtUpdateMessageFtsContactBySender?.run(params.contactId, platform, params.senderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number to E.164 format.
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
// Strip everything except digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if no country code and 10 digits
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePlatform(platform: string): string {
|
||||
return platform.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MESSAGE INDEXING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Index a message for cross-platform search.
|
||||
*/
|
||||
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
|
||||
const platform = this.normalizePlatform(message.platform);
|
||||
// Try to resolve the sender to a canonical contact
|
||||
const contactId = this.resolveContact(platform, message.senderId);
|
||||
|
||||
this.stmtInsertMessage.run(
|
||||
message.id,
|
||||
message.content,
|
||||
contactId,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
message.embedding ?? null,
|
||||
);
|
||||
|
||||
// Also insert into FTS table
|
||||
if (this.stmtInsertMessageFts && message.content) {
|
||||
this.stmtInsertMessageFts.run(
|
||||
message.content,
|
||||
message.id,
|
||||
contactId,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
// Update last seen timestamp for the sender
|
||||
if (contactId) {
|
||||
this.updateIdentityLastSeen(platform, message.senderId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexed messages.
|
||||
*/
|
||||
searchMessages(options: MessageSearchOptions): MessageSearchResult[] {
|
||||
const results: MessageSearchResult[] = [];
|
||||
|
||||
if (!options.query) return results;
|
||||
|
||||
const normalizedPlatforms = options.platforms?.map((platform) =>
|
||||
this.normalizePlatform(platform),
|
||||
);
|
||||
|
||||
// Resolve "from" filter to contact IDs
|
||||
let contactIds: string[] | null = null;
|
||||
if (options.from) {
|
||||
const normalized = options.from.trim();
|
||||
const exact = normalized ? this.getContact(normalized) : null;
|
||||
const matches = this.searchContacts(options.from, 10);
|
||||
const ids = new Set<string>(matches.map((m) => m.canonicalId));
|
||||
if (exact) ids.add(exact.canonicalId);
|
||||
if (ids.size === 0) {
|
||||
return results;
|
||||
}
|
||||
contactIds = [...ids];
|
||||
}
|
||||
|
||||
// Build query based on FTS availability
|
||||
const normalizedOptions = {
|
||||
...options,
|
||||
platforms: normalizedPlatforms,
|
||||
};
|
||||
|
||||
if (this.ftsAvailable) {
|
||||
return this.searchMessagesFts(normalizedOptions, contactIds);
|
||||
}
|
||||
return this.searchMessagesLike(normalizedOptions, contactIds);
|
||||
}
|
||||
|
||||
private searchMessagesFts(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT m.id, m.content, m.contact_id, m.platform, m.sender_id, m.channel_id, m.timestamp, m.embedding,
|
||||
bm25(messages_fts) as score
|
||||
FROM messages_fts fts
|
||||
JOIN indexed_messages m ON fts.id = m.id
|
||||
WHERE messages_fts MATCH ?
|
||||
`;
|
||||
const params: (string | number)[] = [options.query];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND m.contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND m.platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND m.channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND m.timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND m.timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY score LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: Math.abs(row.score), // BM25 returns negative scores
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private searchMessagesLike(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding
|
||||
FROM indexed_messages
|
||||
WHERE content LIKE ?
|
||||
`;
|
||||
const params: (string | number)[] = [`%${options.query}%`];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: 1.0, // Simple LIKE doesn't provide scoring
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snippet with the query highlighted.
|
||||
*/
|
||||
private createSnippet(content: string, query: string, maxLength = 200): string {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) {
|
||||
return content.slice(0, maxLength) + (content.length > maxLength ? "..." : "");
|
||||
}
|
||||
|
||||
// Center the snippet around the match
|
||||
const contextBefore = 50;
|
||||
const contextAfter = 100;
|
||||
let start = Math.max(0, index - contextBefore);
|
||||
let end = Math.min(content.length, index + query.length + contextAfter);
|
||||
|
||||
// Adjust to word boundaries if possible
|
||||
if (start > 0) {
|
||||
const spaceIndex = content.lastIndexOf(" ", start + 10);
|
||||
if (spaceIndex > start - 20) start = spaceIndex + 1;
|
||||
}
|
||||
if (end < content.length) {
|
||||
const spaceIndex = content.indexOf(" ", end - 10);
|
||||
if (spaceIndex !== -1 && spaceIndex < end + 20) end = spaceIndex;
|
||||
}
|
||||
|
||||
let snippet = content.slice(start, end);
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < content.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STATISTICS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get statistics about the contact store.
|
||||
*/
|
||||
getStats(): {
|
||||
contacts: number;
|
||||
identities: number;
|
||||
messages: number;
|
||||
platforms: Record<string, number>;
|
||||
} {
|
||||
const contactCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM contacts`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const identityCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM platform_identities`).get() as {
|
||||
count: number;
|
||||
}
|
||||
).count;
|
||||
|
||||
const messageCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM indexed_messages`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const platformRows = this.db
|
||||
.prepare(`SELECT platform, COUNT(*) as count FROM platform_identities GROUP BY platform`)
|
||||
.all() as Array<{ platform: string; count: number }>;
|
||||
|
||||
const platforms: Record<string, number> = {};
|
||||
for (const row of platformRows) {
|
||||
platforms[row.platform] = row.count;
|
||||
}
|
||||
|
||||
return {
|
||||
contacts: contactCount,
|
||||
identities: identityCount,
|
||||
messages: messageCount,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _store: ContactStore | null = null;
|
||||
let _storeConfig: { dbPath?: string; stateDir?: string } = {};
|
||||
|
||||
export function configureContactStore(params: { dbPath?: string; stateDir?: string }): void {
|
||||
_storeConfig = params;
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global contact store instance.
|
||||
*/
|
||||
export function getContactStore(): ContactStore {
|
||||
if (!_store) {
|
||||
_store = ContactStore.open(_storeConfig);
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the global contact store instance.
|
||||
*/
|
||||
export function closeContactStore(): void {
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import type { ChannelId } from "clawdbot/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Types for the unified contact graph.
|
||||
*
|
||||
* The contact graph allows cross-platform identity resolution:
|
||||
* - Multiple platform identities can be linked to a single canonical contact
|
||||
* - Enables unified message search across all messaging channels
|
||||
*/
|
||||
|
||||
/**
|
||||
* A canonical contact in the unified contact graph.
|
||||
* Represents a single person who may have multiple platform identities.
|
||||
*/
|
||||
export type Contact = {
|
||||
/** Unique canonical identifier (e.g., "sarah-jones-abc123") */
|
||||
canonicalId: string;
|
||||
/** Primary display name for this contact */
|
||||
displayName: string;
|
||||
/** Alternative names/aliases for this contact */
|
||||
aliases: string[];
|
||||
/** When this contact was first created */
|
||||
createdAt: number;
|
||||
/** When this contact was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supported messaging platforms.
|
||||
*/
|
||||
export type Platform = ChannelId;
|
||||
|
||||
/**
|
||||
* A platform-specific identity linked to a canonical contact.
|
||||
* Each person may have one or more of these across different platforms.
|
||||
*/
|
||||
export type PlatformIdentity = {
|
||||
/** Database row ID */
|
||||
id: number;
|
||||
/** Reference to the canonical contact */
|
||||
contactId: string;
|
||||
/** Which platform this identity belongs to */
|
||||
platform: Platform;
|
||||
/** Platform-specific user identifier (JID, user ID, etc.) */
|
||||
platformId: string;
|
||||
/** Platform-specific username (@handle) if available */
|
||||
username: string | null;
|
||||
/** E.164 phone number if available */
|
||||
phone: string | null;
|
||||
/** Platform-specific display name */
|
||||
displayName: string | null;
|
||||
/** When this identity was last seen in a message */
|
||||
lastSeenAt: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input for creating a new platform identity.
|
||||
*/
|
||||
export type PlatformIdentityInput = Omit<PlatformIdentity, "id">;
|
||||
|
||||
/**
|
||||
* Result of a contact search/lookup.
|
||||
*/
|
||||
export type ContactWithIdentities = Contact & {
|
||||
/** All platform identities associated with this contact */
|
||||
identities: PlatformIdentity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-link match confidence levels.
|
||||
*/
|
||||
export type LinkConfidence = "high" | "medium" | "low";
|
||||
|
||||
/**
|
||||
* A suggested link between platform identities.
|
||||
*/
|
||||
export type LinkSuggestion = {
|
||||
/** The source identity that was analyzed */
|
||||
sourceIdentity: PlatformIdentity;
|
||||
/** The target identity to potentially link with */
|
||||
targetIdentity: PlatformIdentity;
|
||||
/** Why this link is suggested */
|
||||
reason: "phone_match" | "email_match" | "name_similarity";
|
||||
/** How confident we are in this match */
|
||||
confidence: LinkConfidence;
|
||||
/** Score for ranking (0-1) */
|
||||
score: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Contact import result from a platform.
|
||||
*/
|
||||
export type ImportResult = {
|
||||
platform: Platform;
|
||||
imported: number;
|
||||
linked: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for contact search.
|
||||
*/
|
||||
export type ContactSearchOptions = {
|
||||
/** Search query (matches name, aliases, username) */
|
||||
query?: string;
|
||||
/** Filter by platform */
|
||||
platform?: Platform;
|
||||
/** Maximum results to return */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message indexed for cross-platform search.
|
||||
*/
|
||||
export type IndexedMessage = {
|
||||
/** Unique message ID */
|
||||
id: string;
|
||||
/** Message content (may be empty for media) */
|
||||
content: string;
|
||||
/** Reference to canonical contact ID of sender */
|
||||
contactId: string | null;
|
||||
/** Platform this message came from */
|
||||
platform: Platform;
|
||||
/** Platform-specific sender ID */
|
||||
senderId: string;
|
||||
/** Platform-specific channel/chat ID */
|
||||
channelId: string;
|
||||
/** When the message was sent */
|
||||
timestamp: number;
|
||||
/** Optional: pre-computed embedding for semantic search */
|
||||
embedding: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for message search.
|
||||
*/
|
||||
export type MessageSearchOptions = {
|
||||
/** Text query to search for */
|
||||
query: string;
|
||||
/** Filter by sender (canonical contact ID or platform identity) */
|
||||
from?: string;
|
||||
/** Filter by platform */
|
||||
platforms?: Platform[];
|
||||
/** Filter by channel/chat ID */
|
||||
channelId?: string;
|
||||
/** Filter messages after this timestamp */
|
||||
since?: number;
|
||||
/** Filter messages before this timestamp */
|
||||
until?: number;
|
||||
/** Maximum results */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message search result.
|
||||
*/
|
||||
export type MessageSearchResult = {
|
||||
message: IndexedMessage;
|
||||
/** The contact who sent this message (if resolved) */
|
||||
contact: Contact | null;
|
||||
/** Search relevance score */
|
||||
score: number;
|
||||
/** Snippet with highlighted match */
|
||||
snippet: string;
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { importContactFromMessage, getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
function normalizePlatform(value: string): Platform {
|
||||
return value.trim().toLowerCase() as Platform;
|
||||
}
|
||||
|
||||
function resolveMessageId(params: {
|
||||
messageId?: string;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
timestamp?: number;
|
||||
content: string;
|
||||
}): string {
|
||||
if (params.messageId) {
|
||||
return `${params.platform}:${params.messageId}`;
|
||||
}
|
||||
if (!params.timestamp) return randomUUID();
|
||||
const hash = createHash("sha1");
|
||||
hash.update(params.platform);
|
||||
hash.update("|");
|
||||
hash.update(params.senderId);
|
||||
hash.update("|");
|
||||
hash.update(String(params.timestamp));
|
||||
hash.update("|");
|
||||
hash.update(params.content);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export function indexInboundMessage(params: {
|
||||
event: {
|
||||
from: string;
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
ctx: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
logger?: { warn?: (message: string) => void };
|
||||
}): void {
|
||||
const { event, ctx, logger } = params;
|
||||
const channelId = (ctx.channelId ?? "").trim();
|
||||
if (!channelId) return;
|
||||
|
||||
const metadata = event.metadata ?? {};
|
||||
const meta = metadata as {
|
||||
senderId?: string;
|
||||
messageId?: string;
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
senderName?: string;
|
||||
};
|
||||
const senderId = String(meta.senderId ?? event.from ?? "").trim();
|
||||
if (!senderId) return;
|
||||
|
||||
const content = typeof event.content === "string" ? event.content.trim() : "";
|
||||
const platform = normalizePlatform(channelId);
|
||||
const timestamp =
|
||||
typeof event.timestamp === "number" && Number.isFinite(event.timestamp)
|
||||
? event.timestamp
|
||||
: Date.now();
|
||||
const metadataMessageId = meta.messageId;
|
||||
const messageId = resolveMessageId({
|
||||
messageId: typeof metadataMessageId === "string" ? metadataMessageId : undefined,
|
||||
platform,
|
||||
senderId,
|
||||
timestamp,
|
||||
content,
|
||||
});
|
||||
const conversationId = (ctx.conversationId ?? "").trim() || senderId;
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
importContactFromMessage(store, {
|
||||
platform,
|
||||
platformId: senderId,
|
||||
username: typeof meta.senderUsername === "string" ? meta.senderUsername : null,
|
||||
phone: typeof meta.senderE164 === "string" ? meta.senderE164 : null,
|
||||
displayName: typeof meta.senderName === "string" ? meta.senderName : null,
|
||||
});
|
||||
|
||||
if (!content) return;
|
||||
|
||||
store.indexMessage({
|
||||
id: messageId,
|
||||
content,
|
||||
contactId: null,
|
||||
platform,
|
||||
senderId,
|
||||
channelId: conversationId,
|
||||
timestamp,
|
||||
embedding: null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`[contacts-search] failed indexing message: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function requireNodeSqlite(): typeof import("node:sqlite") {
|
||||
const onWarning = (warning: Error & { name?: string; message?: string }) => {
|
||||
if (
|
||||
warning.name === "ExperimentalWarning" &&
|
||||
warning.message?.includes("SQLite is an experimental feature")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
|
||||
};
|
||||
|
||||
process.on("warning", onWarning);
|
||||
try {
|
||||
return require("node:sqlite") as typeof import("node:sqlite");
|
||||
} finally {
|
||||
process.off("warning", onWarning);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,48 @@ Enable it in an agent allowlist:
|
||||
}
|
||||
```
|
||||
|
||||
## Using `clawd.invoke` (Lobster → Clawdbot tools)
|
||||
|
||||
Some Lobster pipelines may include a `clawd.invoke` step to call back into Clawdbot tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.).
|
||||
|
||||
For this to work, the Clawdbot Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy:
|
||||
|
||||
- Clawdbot provides an HTTP endpoint: `POST /tools/invoke`.
|
||||
- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled).
|
||||
- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, Clawdbot returns `404 Tool not available`.
|
||||
|
||||
### Allowlisting recommended
|
||||
|
||||
To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `clawd.invoke`.
|
||||
|
||||
Example (allow only a small set of tools):
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": {
|
||||
"allow": [
|
||||
"lobster",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"gog",
|
||||
"gh"
|
||||
],
|
||||
"deny": ["gateway"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`.
|
||||
- Tool names depend on which plugins you have installed/enabled.
|
||||
|
||||
## Security
|
||||
|
||||
- Runs the `lobster` executable as a local subprocess.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-bot-sdk": "0.8.0",
|
||||
"music-metadata": "^11.10.6",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.23-1"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*",
|
||||
"nostr-tools": "^2.19.4",
|
||||
"zod": "^4.3.5"
|
||||
"nostr-tools": "^2.20.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@urbit/aura": "^2.0.0",
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("Tlon config schema", () => {
|
||||
it("accepts channelRules with string keys", () => {
|
||||
const parsed = TlonAuthorizationSchema.parse({
|
||||
channelRules: {
|
||||
"chat/~zod/test": {
|
||||
mode: "open",
|
||||
allowedShips: ["~zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
|
||||
});
|
||||
|
||||
it("accepts accounts with string keys", () => {
|
||||
const parsed = TlonConfigSchema.parse({
|
||||
accounts: {
|
||||
primary: {
|
||||
ship: "~zod",
|
||||
url: "https://example.com",
|
||||
code: "code-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.accounts?.primary?.ship).toBe("~zod");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
|
||||
});
|
||||
|
||||
export const TlonAuthorizationSchema = z.object({
|
||||
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
||||
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAccountSchema = z.object({
|
||||
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
|
||||
showModelSignature: z.boolean().optional(),
|
||||
authorization: TlonAuthorizationSchema.optional(),
|
||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||
accounts: z.record(TlonAccountSchema).optional(),
|
||||
accounts: z.record(z.string(), TlonAccountSchema).optional(),
|
||||
});
|
||||
|
||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@urbit/aura", () => ({
|
||||
scot: vi.fn(() => "mocked-ud"),
|
||||
da: {
|
||||
fromUnix: vi.fn(() => 123n),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("sendDm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses aura v3 helpers for the DM id", async () => {
|
||||
const { sendDm } = await import("./send.js");
|
||||
const aura = await import("@urbit/aura");
|
||||
const scot = vi.mocked(aura.scot);
|
||||
const fromUnix = vi.mocked(aura.da.fromUnix);
|
||||
|
||||
const sentAt = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(sentAt);
|
||||
|
||||
const poke = vi.fn(async () => ({}));
|
||||
|
||||
const result = await sendDm({
|
||||
api: { poke },
|
||||
fromShip: "~zod",
|
||||
toShip: "~nec",
|
||||
text: "hi",
|
||||
});
|
||||
|
||||
expect(fromUnix).toHaveBeenCalledWith(sentAt);
|
||||
expect(scot).toHaveBeenCalledWith("ud", 123n);
|
||||
expect(poke).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("~zod/mocked-ud");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unixToDa, formatUd } from "@urbit/aura";
|
||||
import { scot, da } from "@urbit/aura";
|
||||
|
||||
export type TlonPokeApi = {
|
||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
||||
@@ -14,7 +14,7 @@ type SendTextParams = {
|
||||
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
const idUd = formatUd(unixToDa(sentAt));
|
||||
const idUd = scot('ud', da.fromUnix(sentAt));
|
||||
const id = `${fromShip}/${idUd}`;
|
||||
|
||||
const delta = {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Flawd - Clawdbot Fly.io demo deployment
|
||||
# Clawdbot Fly.io deployment configuration
|
||||
# See https://fly.io/docs/reference/configuration/
|
||||
|
||||
app = "flawd-bot"
|
||||
primary_region = "lhr"
|
||||
app = "clawdbot"
|
||||
primary_region = "iad" # change to your closest region
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
NODE_ENV = "production"
|
||||
# Fly uses x86, but keep this for consistency
|
||||
CLAWDBOT_PREFER_PNPM = "1"
|
||||
CLAWDBOT_STATE_DIR = "/data"
|
||||
NODE_OPTIONS = "--max-old-space-size=1536"
|
||||
@@ -19,7 +20,7 @@ primary_region = "lhr"
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = false
|
||||
auto_stop_machines = false # Keep running for persistent connections
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
+10
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.23",
|
||||
"version": "2026.1.24-0",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -43,6 +43,7 @@
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/tui/**",
|
||||
"dist/tts/**",
|
||||
"dist/web/**",
|
||||
"dist/wizard/**",
|
||||
"dist/*.js",
|
||||
@@ -146,7 +147,7 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.23.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.13.0",
|
||||
"@agentclientprotocol/sdk": "0.13.1",
|
||||
"@aws-sdk/client-bedrock": "^3.975.0",
|
||||
"@buape/carbon": "0.14.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
@@ -186,7 +187,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"osc-progress": "^0.3.0",
|
||||
"pdfjs-dist": "^5.4.530",
|
||||
"playwright-core": "1.57.0",
|
||||
"playwright-core": "1.58.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"sharp": "^0.34.5",
|
||||
@@ -196,7 +197,7 @@
|
||||
"undici": "^7.19.0",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.5"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.88",
|
||||
@@ -214,21 +215,21 @@
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260120.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260124.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"docx-preview": "^0.3.7",
|
||||
"lit": "^3.3.2",
|
||||
"lucide": "^0.562.0",
|
||||
"lucide": "^0.563.0",
|
||||
"ollama": "^0.6.3",
|
||||
"oxfmt": "0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"oxlint-tsgolint": "^0.11.1",
|
||||
"quicktype-core": "^23.2.6",
|
||||
"rolldown": "1.0.0-beta.60",
|
||||
"rolldown": "1.0.0-rc.1",
|
||||
"signal-utils": "^0.21.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.17",
|
||||
"vitest": "^4.0.18",
|
||||
"wireit": "^0.14.12"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
Generated
+486
-856
File diff suppressed because it is too large.
Load diff
@@ -6,14 +6,18 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY patches ./patches
|
||||
COPY ui ./ui
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:build
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -29,12 +29,23 @@ const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
|
||||
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
|
||||
|
||||
const WARNING_SUPPRESSION_FLAGS = [
|
||||
"--disable-warning=ExperimentalWarning",
|
||||
"--disable-warning=DEP0040",
|
||||
"--disable-warning=DEP0060",
|
||||
];
|
||||
|
||||
const run = (entry) =>
|
||||
new Promise((resolve) => {
|
||||
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
|
||||
const nodeOptions = process.env.NODE_OPTIONS ?? "";
|
||||
const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce(
|
||||
(acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()),
|
||||
nodeOptions,
|
||||
);
|
||||
const child = spawn(pnpm, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, VITEST_GROUP: entry.name },
|
||||
env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: nextNodeOptions },
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
children.add(child);
|
||||
|
||||
@@ -70,7 +70,8 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
|
||||
} {
|
||||
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
|
||||
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
|
||||
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
|
||||
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
|
||||
return { defaultAgentId, sessionAgentId };
|
||||
}
|
||||
|
||||
@@ -150,4 +150,35 @@ describe("exec approvals", () => {
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("requires approval for elevated ask when allowlist misses", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const calls: string[] = [];
|
||||
let resolveApproval: (() => void) | undefined;
|
||||
const approvalSeen = new Promise<void>((resolve) => {
|
||||
resolveApproval = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
resolveApproval?.();
|
||||
return { decision: "deny" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await approvalSeen;
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
});
|
||||
@@ -791,7 +791,7 @@ export function createExecTool(
|
||||
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||
if (elevatedRequested) {
|
||||
if (elevatedRequested && elevatedMode === "full") {
|
||||
security = "full";
|
||||
}
|
||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||
|
||||
@@ -17,7 +17,8 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
updateSessionStoreMock(storePath, store);
|
||||
return store;
|
||||
},
|
||||
resolveStorePath: () => "/tmp/sessions.json",
|
||||
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
|
||||
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,11 +118,118 @@ describe("session_status tool", () => {
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
|
||||
"Unknown sessionKey",
|
||||
"Unknown sessionId",
|
||||
);
|
||||
expect(updateSessionStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves sessionId inputs", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
const sessionId = "sess-main";
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:main:main": {
|
||||
sessionId,
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call3", { sessionKey: sessionId });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("uses non-standard session keys without sessionId resolution", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"temp:slug-generator": {
|
||||
sessionId: "sess-temp",
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("temp:slug-generator");
|
||||
});
|
||||
|
||||
it("blocks cross-agent session_status without agent-to-agent access", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
loadSessionStoreMock.mockReturnValue({
|
||||
"agent:other:main": {
|
||||
sessionId: "s2",
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "agent:main:main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow(
|
||||
"Agent-to-agent status is disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes bare session keys to the requester agent", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
const stores = new Map<string, Record<string, unknown>>([
|
||||
[
|
||||
"/tmp/main/sessions.json",
|
||||
{
|
||||
"agent:main:main": { sessionId: "s-main", updatedAt: 10 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"/tmp/support/sessions.json",
|
||||
{
|
||||
main: { sessionId: "s-support", updatedAt: 20 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
loadSessionStoreMock.mockImplementation((storePath: string) => {
|
||||
return stores.get(storePath) ?? {};
|
||||
});
|
||||
updateSessionStoreMock.mockImplementation(
|
||||
(_storePath: string, store: Record<string, unknown>) => {
|
||||
// Keep map in sync for resolveSessionEntry fallbacks if needed.
|
||||
if (_storePath) {
|
||||
stores.set(_storePath, store);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const tool = createClawdbotTools({ agentSessionKey: "agent:support:main" }).find(
|
||||
(candidate) => candidate.name === "session_status",
|
||||
);
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing session_status tool");
|
||||
|
||||
const result = await tool.execute("call6", { sessionKey: "main" });
|
||||
const details = result.details as { ok?: boolean; sessionKey?: string };
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe("main");
|
||||
});
|
||||
|
||||
it("resets per-session model override via model=default", async () => {
|
||||
loadSessionStoreMock.mockReset();
|
||||
updateSessionStoreMock.mockReset();
|
||||
|
||||
@@ -172,6 +172,62 @@ describe("sessions tools", () => {
|
||||
expect(withToolsDetails.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sessions_history resolves sessionId inputs", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "sess-group";
|
||||
const targetKey = "agent:main:discord:channel:1457165743010611293";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.resolve") {
|
||||
return {
|
||||
key: targetKey,
|
||||
};
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
return {
|
||||
messages: [{ role: "assistant", content: [{ type: "text", text: "ok" }] }],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_history tool");
|
||||
|
||||
const result = await tool.execute("call5", { sessionKey: sessionId });
|
||||
const details = result.details as { messages?: unknown[] };
|
||||
expect(details.messages).toHaveLength(1);
|
||||
const historyCall = callGatewayMock.mock.calls.find(
|
||||
(call) => (call[0] as { method?: string }).method === "chat.history",
|
||||
);
|
||||
expect(historyCall?.[0]).toMatchObject({
|
||||
method: "chat.history",
|
||||
params: { sessionKey: targetKey },
|
||||
});
|
||||
});
|
||||
|
||||
it("sessions_history errors on missing sessionId", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.resolve") {
|
||||
throw new Error("No session found");
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools().find((candidate) => candidate.name === "sessions_history");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_history tool");
|
||||
|
||||
const result = await tool.execute("call6", { sessionKey: sessionId });
|
||||
const details = result.details as { status?: string; error?: string };
|
||||
expect(details.status).toBe("error");
|
||||
expect(details.error).toMatch(/Session not found|No session found/);
|
||||
});
|
||||
|
||||
it("sessions_send supports fire-and-forget and wait", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
@@ -313,6 +369,50 @@ describe("sessions tools", () => {
|
||||
expect(sendCallCount).toBe(0);
|
||||
});
|
||||
|
||||
it("sessions_send resolves sessionId inputs", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const sessionId = "sess-send";
|
||||
const targetKey = "agent:main:discord:channel:123";
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.resolve") {
|
||||
return { key: targetKey };
|
||||
}
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-1", acceptedAt: 123 };
|
||||
}
|
||||
if (request.method === "agent.wait") {
|
||||
return { status: "ok" };
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
return { messages: [] };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const tool = createClawdbotTools({
|
||||
agentSessionKey: "main",
|
||||
agentChannel: "discord",
|
||||
}).find((candidate) => candidate.name === "sessions_send");
|
||||
expect(tool).toBeDefined();
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
|
||||
const result = await tool.execute("call7", {
|
||||
sessionKey: sessionId,
|
||||
message: "ping",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
const details = result.details as { status?: string };
|
||||
expect(details.status).toBe("accepted");
|
||||
const agentCall = callGatewayMock.mock.calls.find(
|
||||
(call) => (call[0] as { method?: string }).method === "agent",
|
||||
);
|
||||
expect(agentCall?.[0]).toMatchObject({
|
||||
method: "agent",
|
||||
params: { sessionKey: targetKey },
|
||||
});
|
||||
});
|
||||
|
||||
it("sessions_send runs ping-pong then announces", async () => {
|
||||
callGatewayMock.mockReset();
|
||||
const calls: Array<{ method?: string; params?: unknown }> = [];
|
||||
|
||||
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user