Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3b924af44c chore(fly): update Flawd demo config 2026-01-24 08:53:49 +00:00
Peter Steinberger e083e49756 refactor: align contacts-search command handling 2026-01-24 08:39:46 +00:00
Peter Steinberger 0dc131e9f3 feat: add contacts search plugin 2026-01-24 08:39:46 +00:00
Evyatar BluzerandClaude Opus 4.5 cab8dcf456 fix: lint errors and add /search docs
oops, forgot to document the /search command and had some lint issues:
- removed unused imports
- fixed template literal type issues
- documented /search in slash-commands.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:39:46 +00:00
Evyatar Bluzer 0845b24e9d feat: add unified contact graph and cross-platform message search
been working on this for a while now. basically it lets you search messages
across all your connected platforms (whatsapp, telegram, discord etc) from
one place.

the main idea is that same person on different platforms gets linked together
so when you search for "what did sarah say about the deadline" you get results
from everywhere she messaged you.

whats included:
- new contacts module with sqlite storage for the unified graph
- auto-linking by phone number (high confidence) and name similarity
- /search command in chat + clawdbot search CLI
- clawdbot contacts CLI for managing the graph manually

all tests passing (77 tests)
2026-01-24 08:39:46 +00:00
210 changed files with 9161 additions and 9575 deletions

No files matched your search

-143
View File
@@ -1,143 +0,0 @@
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 }}
-1
View File
@@ -106,7 +106,6 @@
## 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.
+69 -81
View File
@@ -2,81 +2,69 @@
Docs: https://docs.clawd.bot
## 2026.1.24
## 2026.1.23 (Unreleased)
### Changes
- 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.
### Fixes
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: unify reasoning tag stripping so `<final>` wrappers stay hidden. (#1613) Thanks @kyleok.
## 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: 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.
- 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
- 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.
### 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)
- 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 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.
- 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)
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- 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)
- 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)
- 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. (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)
- 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: 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.
- 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`).
- 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.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- 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)
## 2026.1.22
@@ -124,22 +112,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
- 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.
- 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
- 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)
+190 -87
View File
@@ -2,93 +2,6 @@
<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>
@@ -211,5 +124,195 @@
]]></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>
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601240
versionName = "2026.1.24"
versionCode = 202601230
versionName = "2026.1.23"
}
buildTypes {
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.24</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>20260124</string>
<string>20260123</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.24</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>20260124</string>
<string>20260123</string>
</dict>
</plist>
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.24</string>
<string>2026.1.23</string>
<key>CFBundleVersion</key>
<string>202601240</string>
<string>202601230</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -964,7 +964,6 @@ 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?
@@ -973,7 +972,6 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -981,7 +979,6 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -990,7 +987,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
@@ -964,7 +964,6 @@ 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?
@@ -973,7 +972,6 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -981,7 +979,6 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -990,7 +987,6 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
+1
View File
@@ -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);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
View File
@@ -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);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
-1
View File
@@ -1 +0,0 @@
{"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
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"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
+2 -2
View File
@@ -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-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
+45
View File
@@ -0,0 +1,45 @@
---
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).
+4
View File
@@ -32,6 +32,8 @@ 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)
@@ -122,6 +124,8 @@ clawdbot [--dev] [--profile <name>] <command>
status
index
search
contacts
search
message
agent
agents
+36
View File
@@ -0,0 +1,36 @@
---
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).
+8
View File
@@ -0,0 +1,8 @@
---
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).
+2 -3
View File
@@ -56,20 +56,19 @@ Row shape (JSON):
Fetch transcript for one session.
Parameters:
- `sessionKey` (required; accepts session key or `sessionId` from `sessions_list`)
- `sessionKey` (required)
- `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; accepts session key or `sessionId` from `sessions_list`)
- `sessionKey` (required)
- `message` (required)
- `timeoutSeconds?: number` (default >0; 0 = fire-and-forget)
+100
View File
@@ -0,0 +1,100 @@
---
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.
+19 -4
View File
@@ -387,7 +387,7 @@
},
{
"source": "/faq",
"destination": "/help/faq"
"destination": "/start/faq"
},
{
"source": "/gateway-lock",
@@ -449,6 +449,10 @@
"source": "/location-command",
"destination": "/nodes/location-command"
},
{
"source": "/logging",
"destination": "/gateway/logging"
},
{
"source": "/lore",
"destination": "/start/lore"
@@ -757,13 +761,21 @@
"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/faq"
"destination": "/help"
},
{
"source": "/start/faq/",
"destination": "/help/faq"
"destination": "/help"
},
{
"source": "/oauth",
@@ -836,6 +848,8 @@
"cli/skills",
"cli/plugins",
"cli/memory",
"cli/contacts",
"cli/search",
"cli/models",
"cli/logs",
"cli/system",
@@ -871,6 +885,7 @@
"concepts/session",
"concepts/session-pruning",
"concepts/sessions",
"contact",
"concepts/session-tool",
"concepts/presence",
"concepts/channel-routing",
@@ -904,7 +919,6 @@
"gateway/configuration-examples",
"gateway/authentication",
"gateway/openai-http-api",
"gateway/tools-invoke-http-api",
"gateway/cli-backends",
"gateway/local-models",
"gateway/background-process",
@@ -992,6 +1006,7 @@
"tools/lobster",
"tools/llm-task",
"plugin",
"plugins/contacts-search",
"plugins/voice-call",
"plugins/zalouser",
"tools/exec",
+1 -1
View File
@@ -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](/help/faq#env-vars-and-env-loading)
- [FAQ: env vars and .env loading](/start/faq#env-vars-and-env-loading)
- [Models overview](/concepts/models)
+3 -24
View File
@@ -1459,28 +1459,13 @@ 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",
seed: 42,
applyTextNormalization: "auto",
languageCode: "en",
voiceSettings: {
stability: 0.5,
similarityBoost: 0.75,
style: 0.0,
useSpeakerBoost: true,
speed: 1.0
}
modelId: "eleven_multilingual_v2"
},
openai: {
apiKey: "openai_api_key",
@@ -1493,17 +1478,11 @@ 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.
- `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.
- `/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`
+1 -71
View File
@@ -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 | none | <channel id> (core or plugin, e.g. "bluebubbles")
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
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,14 +92,6 @@ 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**
@@ -164,68 +156,6 @@ 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
-1
View File
@@ -30,7 +30,6 @@ 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.
-79
View File
@@ -1,79 +0,0 @@
---
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": {}
}'
```
+1 -29
View File
@@ -7,7 +7,7 @@ read_when:
When Clawdbot misbehaves, here's how to fix it.
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.
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.
Provider-specific shortcuts: [/channels/troubleshooting](/channels/troubleshooting)
@@ -31,34 +31,6 @@ 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
+12 -1847
View File
File diff suppressed because it is too large. Load diff
-16
View File
@@ -33,22 +33,6 @@ 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)
+14 -87
View File
@@ -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 iad
fly volumes create clawdbot_data --size 1 --region lhr
```
**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 = "iad"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
@@ -134,14 +134,17 @@ fly ssh console
Create the config directory and file:
```bash
mkdir -p /data
cat > /data/clawdbot.json << 'EOF'
mkdir -p /data/.clawdbot
cat > /data/.clawdbot/clawdbot.json << 'EOF'
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5",
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
"primary": "anthropic/claude-opus-4-5"
},
"models": {
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {}
},
"maxConcurrent": 4
},
@@ -152,49 +155,15 @@ cat > /data/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,
"groupPolicy": "allowlist",
"guilds": {
"YOUR_GUILD_ID": {
"channels": { "general": { "allow": true } },
"requireMention": false
}
}
"enabled": true
}
},
"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
@@ -237,7 +206,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. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
Container keeps restarting or getting killed.
**Fix:** Increase memory in `fly.toml`:
```toml
@@ -245,13 +214,6 @@ Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::R
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.
@@ -260,12 +222,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 --command "rm -f /data/gateway.*.lock"
fly ssh console
rm /data/.clawdbot/run/gateway.*.lock
exit
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.
@@ -275,24 +237,6 @@ 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
@@ -307,23 +251,6 @@ 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)
+7 -7
View File
@@ -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.24 \
APP_VERSION=2026.1.23 \
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.24.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.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.24.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.24 \
APP_VERSION=2026.1.23 \
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.24.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.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.24.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.23.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.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- 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.
+1
View File
@@ -38,6 +38,7 @@ 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`
+70
View File
@@ -0,0 +1,70 @@
---
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)
-1
View File
@@ -100,7 +100,6 @@ 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.
@@ -1,75 +0,0 @@
---
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`
-27
View File
@@ -78,30 +78,3 @@ 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`).
-15
View File
@@ -92,21 +92,6 @@ 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
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -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` (or `sessionId`), `limit?`, `includeTools?`
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_history`: `sessionKey`, `limit?`, `includeTools?`
- `sessions_send`: `sessionKey`, `message`, `timeoutSeconds?` (0 = fire-and-forget)
- `sessions_spawn`: `task`, `label?`, `agentId?`, `model?`, `runTimeoutSeconds?`, `cleanup?`
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
- `session_status`: `sessionKey?` (default current), `model?` (`default` clears override)
Notes:
- `main` is the canonical direct-chat key; global/unknown are hidden.
+9 -3
View File
@@ -61,14 +61,20 @@ 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)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/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)
- `/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|off|status|provider|limit|summary|audio` (control TTS; see [/tts](/tts))
- Discord: native command is `/voice` (Discord reserves `/tts`); text `/tts` still works.
- `/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)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
+1 -1
View File
@@ -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](/help/faq#how-does-clawdbot-load-environment-variables).
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity (direct or via OpenRouter)
-296
View File
@@ -1,296 +0,0 @@
---
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`
@@ -0,0 +1,10 @@
{
"id": "contacts-search",
"name": "Contacts + Search",
"description": "Unified contact graph and cross-platform message search",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+54
View File
@@ -0,0 +1,54 @@
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;
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/contacts-search",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot unified contacts and cross-platform search plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
@@ -0,0 +1,474 @@
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);
}
});
}
@@ -0,0 +1,96 @@
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");
}
@@ -0,0 +1,161 @@
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);
}
});
}
@@ -0,0 +1,105 @@
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 };
}
@@ -0,0 +1,27 @@
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");
});
});
@@ -0,0 +1,74 @@
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)}`;
}
}
@@ -0,0 +1,407 @@
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);
});
});
});
@@ -0,0 +1,393 @@
/**
* 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;
}
@@ -0,0 +1,49 @@
/**
* 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";
@@ -0,0 +1,600 @@
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);
});
});
});
@@ -0,0 +1,396 @@
/**
* 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 };
}
@@ -0,0 +1,125 @@
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;`);
}
@@ -0,0 +1,547 @@
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);
});
});
});
@@ -0,0 +1,838 @@
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;
}
}
@@ -0,0 +1,165 @@
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;
};
@@ -0,0 +1,104 @@
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)}`,
);
}
}
+22
View File
@@ -0,0 +1,22 @@
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);
}
}
-42
View File
@@ -30,48 +30,6 @@ 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.
+1 -1
View File
@@ -28,7 +28,7 @@
"markdown-it": "14.1.0",
"matrix-bot-sdk": "0.8.0",
"music-metadata": "^11.10.6",
"zod": "^4.3.6"
"zod": "^4.3.5"
},
"devDependencies": {
"clawdbot": "workspace:*"
+2 -2
View File
@@ -8,7 +8,7 @@
"./index.ts"
]
},
"peerDependencies": {
"clawdbot": ">=2026.1.23-1"
"dependencies": {
"clawdbot": "workspace:*"
}
}
+2 -2
View File
@@ -25,7 +25,7 @@
},
"dependencies": {
"clawdbot": "workspace:*",
"nostr-tools": "^2.20.0",
"zod": "^4.3.6"
"nostr-tools": "^2.19.4",
"zod": "^4.3.5"
}
}
+1 -1
View File
@@ -24,7 +24,7 @@
}
},
"dependencies": {
"@urbit/aura": "^3.0.0",
"@urbit/aura": "^2.0.0",
"@urbit/http-api": "^3.0.0"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"dependencies": {
"@sinclair/typebox": "0.34.47",
"ws": "^8.19.0",
"zod": "^4.3.6"
"zod": "^4.3.5"
},
"clawdbot": {
"extensions": [
+4 -5
View File
@@ -1,15 +1,14 @@
# Clawdbot Fly.io deployment configuration
# Flawd - Clawdbot Fly.io demo deployment
# See https://fly.io/docs/reference/configuration/
app = "clawdbot"
primary_region = "iad" # change to your closest region
app = "flawd-bot"
primary_region = "lhr"
[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"
@@ -20,7 +19,7 @@ primary_region = "iad" # change to your closest region
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false # Keep running for persistent connections
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
+9 -10
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.24-0",
"version": "2026.1.23",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -43,7 +43,6 @@
"dist/slack/**",
"dist/telegram/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
"dist/wizard/**",
"dist/*.js",
@@ -147,7 +146,7 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.1",
"@agentclientprotocol/sdk": "0.13.0",
"@aws-sdk/client-bedrock": "^3.975.0",
"@buape/carbon": "0.14.0",
"@clack/prompts": "^0.11.0",
@@ -187,7 +186,7 @@
"markdown-it": "^14.1.0",
"osc-progress": "^0.3.0",
"pdfjs-dist": "^5.4.530",
"playwright-core": "1.58.0",
"playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2",
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
@@ -197,7 +196,7 @@
"undici": "^7.19.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
"zod": "^4.3.5"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.88",
@@ -215,21 +214,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.20260124.1",
"@vitest/coverage-v8": "^4.0.18",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"@vitest/coverage-v8": "^4.0.17",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
"lucide": "^0.563.0",
"lucide": "^0.562.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-rc.1",
"rolldown": "1.0.0-beta.60",
"signal-utils": "^0.21.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18",
"vitest": "^4.0.17",
"wireit": "^0.14.12"
},
"pnpm": {
+856 -486
View File
File diff suppressed because it is too large. Load diff
+1 -5
View File
@@ -6,18 +6,14 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.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"]
+1 -12
View File
@@ -29,23 +29,12 @@ 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, NODE_OPTIONS: nextNodeOptions },
env: { ...process.env, VITEST_GROUP: entry.name },
shell: process.platform === "win32",
});
children.add(child);
+1 -2
View File
@@ -70,8 +70,7 @@ export function resolveSessionAgentIds(params: { sessionKey?: string; config?: C
} {
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
const sessionKey = params.sessionKey?.trim();
const normalizedSessionKey = sessionKey ? sessionKey.toLowerCase() : undefined;
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
const parsed = sessionKey ? parseAgentSessionKey(sessionKey) : null;
const sessionAgentId = parsed?.agentId ? normalizeAgentId(parsed.agentId) : defaultAgentId;
return { defaultAgentId, sessionAgentId };
}
@@ -17,8 +17,7 @@ vi.mock("../config/sessions.js", async (importOriginal) => {
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
resolveStorePath: () => "/tmp/sessions.json",
};
});
@@ -118,118 +117,11 @@ describe("session_status tool", () => {
if (!tool) throw new Error("missing session_status tool");
await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow(
"Unknown sessionId",
"Unknown sessionKey",
);
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();
-100
View File
@@ -172,62 +172,6 @@ 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 }> = [];
@@ -369,50 +313,6 @@ 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 }> = [];
-3
View File
@@ -13,7 +13,6 @@ import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildSystemPromptParams } from "../system-prompt-params.js";
import { resolveDefaultModelForAgent } from "../model-selection.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
const CLI_RUN_QUEUE = new Map<string, Promise<unknown>>();
@@ -195,7 +194,6 @@ export function buildSystemPrompt(params: {
defaultModel: defaultModelLabel,
},
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
@@ -211,7 +209,6 @@ export function buildSystemPrompt(params: {
userTime,
userTimeFormat,
contextFiles: params.contextFiles,
ttsHint,
});
}
-3
View File
@@ -1,7 +1,6 @@
import { classifyFailoverReason, type FailoverReason } from "./pi-embedded-helpers.js";
const TIMEOUT_HINT_RE = /timeout|timed out|deadline exceeded|context deadline exceeded/i;
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
export class FailoverError extends Error {
readonly reason: FailoverReason;
@@ -105,8 +104,6 @@ export function isTimeoutError(err: unknown): boolean {
if (hasTimeoutHint(err)) return true;
if (!err || typeof err !== "object") return false;
if (getErrorName(err) !== "AbortError") return false;
const message = getErrorMessage(err);
if (message && ABORT_TIMEOUT_RE.test(message)) return true;
const cause = "cause" in err ? (err as { cause?: unknown }).cause : undefined;
const reason = "reason" in err ? (err as { reason?: unknown }).reason : undefined;
return hasTimeoutHint(cause) || hasTimeoutHint(reason);
-3
View File
@@ -69,9 +69,6 @@ export function isModernModelRef(ref: ModelRef): boolean {
if (provider === "opencode" && id.endsWith("-free")) {
return false;
}
if (provider === "opencode" && id === "alpha-glm-4.7") {
return false;
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
-22
View File
@@ -346,28 +346,6 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("falls back on provider abort errors with request-aborted messages", async () => {
const cfg = makeCfg();
const run = vi
.fn()
.mockRejectedValueOnce(
Object.assign(new Error("Request was aborted"), { name: "AbortError" }),
)
.mockResolvedValueOnce("ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]?.[0]).toBe("anthropic");
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("does not fall back on user aborts", async () => {
const cfg = makeCfg();
const run = vi
+1 -1
View File
@@ -54,7 +54,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
it("returns an array of models", () => {
const models = getOpencodeZenStaticFallbackModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBe(9);
expect(models.length).toBe(10);
});
it("includes Claude, GPT, Gemini, and GLM models", () => {
+7 -1
View File
@@ -67,9 +67,10 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
"gemini-2.5-pro": "gemini-3-pro",
"gemini-2.5-flash": "gemini-3-flash",
// GLM (free)
// GLM (free + alpha)
glm: "glm-4.7",
"glm-free": "glm-4.7",
"alpha-glm": "alpha-glm-4.7",
};
/**
@@ -121,6 +122,7 @@ const MODEL_COSTS: Record<
},
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
"gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
"alpha-glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0.6, cacheWrite: 0 },
"gpt-5.1-codex-mini": {
input: 0.25,
output: 2,
@@ -145,6 +147,7 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"gpt-5.1-codex": 400000,
"claude-opus-4-5": 200000,
"gemini-3-pro": 1048576,
"alpha-glm-4.7": 204800,
"gpt-5.1-codex-mini": 400000,
"gpt-5.1": 400000,
"glm-4.7": 204800,
@@ -161,6 +164,7 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
"gpt-5.1-codex": 128000,
"claude-opus-4-5": 64000,
"gemini-3-pro": 65536,
"alpha-glm-4.7": 131072,
"gpt-5.1-codex-mini": 128000,
"gpt-5.1": 128000,
"glm-4.7": 131072,
@@ -197,6 +201,7 @@ const MODEL_NAMES: Record<string, string> = {
"gpt-5.1-codex": "GPT-5.1 Codex",
"claude-opus-4-5": "Claude Opus 4.5",
"gemini-3-pro": "Gemini 3 Pro",
"alpha-glm-4.7": "Alpha GLM-4.7",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
"glm-4.7": "GLM-4.7",
@@ -224,6 +229,7 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
"gpt-5.1-codex",
"claude-opus-4-5",
"gemini-3-pro",
"alpha-glm-4.7",
"gpt-5.1-codex-mini",
"gpt-5.1",
"glm-4.7",
@@ -128,7 +128,7 @@ describe("getDmHistoryLimitFromSessionKey", () => {
slack: { dmHistoryLimit: 10 },
},
} as ClawdbotConfig;
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:C1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined();
});
it("returns undefined for unknown provider", () => {
@@ -126,7 +126,7 @@ describe("resolveSessionAgentIds", () => {
});
it("keeps the agent id for provider-qualified agent sessions", () => {
const { sessionAgentId } = resolveSessionAgentIds({
sessionKey: "agent:beta:slack:channel:c1",
sessionKey: "agent:beta:slack:channel:C1",
config: cfg,
});
expect(sessionAgentId).toBe("beta");
-3
View File
@@ -66,7 +66,6 @@ import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
export async function compactEmbeddedPiSession(params: {
sessionId: string;
@@ -299,7 +298,6 @@ export async function compactEmbeddedPiSession(params: {
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
@@ -312,7 +310,6 @@ export async function compactEmbeddedPiSession(params: {
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
@@ -78,7 +78,6 @@ import { toClientToolDefinitions } from "../../pi-tool-definition-adapter.js";
import { buildSystemPromptParams } from "../../system-prompt-params.js";
import { describeUnknownError, mapThinkingLevel } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import { buildTtsSystemPromptHint } from "../../../tts/tts.js";
import { isTimeoutError } from "../../failover-error.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
@@ -316,7 +315,6 @@ export async function runEmbeddedAttempt(
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
@@ -330,7 +328,6 @@ export async function runEmbeddedAttempt(
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
workspaceNotes,
reactionGuidance,
promptMode,
@@ -16,7 +16,6 @@ export function buildEmbeddedSystemPrompt(params: {
heartbeatPrompt?: string;
skillsPrompt?: string;
docsPath?: string;
ttsHint?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
@@ -56,7 +55,6 @@ export function buildEmbeddedSystemPrompt(params: {
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
ttsHint: params.ttsHint,
workspaceNotes: params.workspaceNotes,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
-16
View File
@@ -460,22 +460,6 @@ File contents here`,
expect(result).toBe("The actual answer.");
});
it("strips final tags while keeping content", () => {
const msg: AssistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<final>\nAnswer\n</final>",
},
],
timestamp: Date.now(),
};
const result = extractAssistantText(msg);
expect(result).toBe("Answer");
});
it("strips thought tags", () => {
const msg: AssistantMessage = {
role: "assistant",
+30 -2
View File
@@ -1,5 +1,4 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
@@ -167,7 +166,36 @@ export function stripDowngradedToolCallText(text: string): string {
* that slip through other filtering mechanisms.
*/
export function stripThinkingTagsFromText(text: string): string {
return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
if (!text) return text;
// Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
let result = "";
let lastIndex = 0;
let inThinking = false;
for (const match of text.matchAll(tagRe)) {
const idx = match.index ?? 0;
const isClose = match[1] === "/";
if (!inThinking && !isClose) {
// Opening tag - save text before it.
result += text.slice(lastIndex, idx);
inThinking = true;
} else if (inThinking && isClose) {
// Closing tag - skip content inside.
inThinking = false;
}
lastIndex = idx + match[0].length;
}
// Append remaining text if we're not inside thinking.
if (!inThinking) {
result += text.slice(lastIndex);
}
return result.trim();
}
export function extractAssistantText(msg: AssistantMessage): string {
+1 -1
View File
@@ -106,7 +106,7 @@ describe("sandbox explain helpers", () => {
const msg = formatSandboxToolPolicyBlockedMessage({
cfg,
sessionKey: "agent:main:whatsapp:group:g1",
sessionKey: "agent:main:whatsapp:group:G1",
toolName: "browser",
});
expect(msg).toBeTruthy();
-12
View File
@@ -34,7 +34,6 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details",
ttsHint: "Voice (TTS) is enabled.",
});
expect(prompt).not.toContain("## User Identity");
@@ -43,7 +42,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Voice (TTS)");
expect(prompt).not.toContain("## Silent Replies");
expect(prompt).not.toContain("## Heartbeats");
expect(prompt).toContain("## Subagent Context");
@@ -51,16 +49,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Subagent details");
});
it("includes voice hint when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
ttsHint: "Voice (TTS) is enabled.",
});
expect(prompt).toContain("## Voice (TTS)");
expect(prompt).toContain("Voice (TTS) is enabled.");
});
it("adds reasoning tag hint when enabled", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
-9
View File
@@ -103,13 +103,6 @@ function buildMessagingSection(params: {
];
}
function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) {
if (params.isMinimal) return [];
const hint = params.ttsHint?.trim();
if (!hint) return [];
return ["## Voice (TTS)", hint, ""];
}
function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) return [];
@@ -144,7 +137,6 @@ export function buildAgentSystemPrompt(params: {
heartbeatPrompt?: string;
docsPath?: string;
workspaceNotes?: string[];
ttsHint?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
@@ -472,7 +464,6 @@ export function buildAgentSystemPrompt(params: {
runtimeChannel,
messageToolHints: params.messageToolHints,
}),
...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
];
if (extraSystemPrompt) {
+44 -7
View File
@@ -8,6 +8,7 @@ import { createMessageTool } from "./message-tool.js";
const mocks = vi.hoisted(() => ({
runMessageAction: vi.fn(),
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
@@ -20,9 +21,47 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mocks.runMessageAction.mockClear();
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",
);
return {
...actual,
appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript,
};
});
describe("message tool mirroring", () => {
it("mirrors media filename for plugin-handled sends", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
handledBy: "plugin",
payload: {},
dryRun: false,
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:main:main",
config: {} as never,
});
await tool.execute("1", {
action: "send",
target: "telegram:123",
message: "",
media: "https://example.com/files/report.pdf?sig=1",
});
expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith(
expect.objectContaining({ text: "report.pdf" }),
);
});
it("does not mirror on dry-run", async () => {
mocks.appendAssistantMessageToSessionTranscript.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
@@ -33,7 +72,7 @@ describe("message tool agent routing", () => {
} satisfies MessageActionRunResult);
const tool = createMessageTool({
agentSessionKey: "agent:alpha:main",
agentSessionKey: "agent:main:main",
config: {} as never,
});
@@ -43,9 +82,7 @@ describe("message tool agent routing", () => {
message: "hi",
});
const call = mocks.runMessageAction.mock.calls[0]?.[0];
expect(call?.agentId).toBe("alpha");
expect(call?.sessionKey).toBeUndefined();
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
});
});
+29
View File
@@ -11,6 +11,10 @@ import {
import { BLUEBUBBLES_GROUP_ACTIONS } from "../../channels/plugins/bluebubbles-actions.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
appendAssistantMessageToSessionTranscript,
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
@@ -373,11 +377,36 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
defaultAccountId: accountId ?? undefined,
gateway,
toolContext,
sessionKey: options?.agentSessionKey,
agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined,
});
if (
action === "send" &&
options?.agentSessionKey &&
!result.dryRun &&
result.handledBy === "plugin"
) {
const mediaUrl = typeof params.media === "string" ? params.media : undefined;
const mirrorText = resolveMirroredTranscriptText({
text: typeof params.message === "string" ? params.message : undefined,
mediaUrls: mediaUrl ? [mediaUrl] : undefined,
});
if (mirrorText) {
const agentId = resolveSessionAgentId({
sessionKey: options.agentSessionKey,
config: cfg,
});
await appendAssistantMessageToSessionTranscript({
agentId,
sessionKey: options.agentSessionKey,
text: mirrorText,
});
}
}
const toolResult = getToolResult(result);
if (toolResult) return toolResult;
return jsonResult(result.payload);
+7 -79
View File
@@ -40,13 +40,7 @@ import {
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import {
shouldResolveSessionIdInput,
resolveInternalSessionKey,
resolveMainSessionAlias,
createAgentToAgentPolicy,
} from "./sessions-helpers.js";
import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
const SessionStatusToolSchema = Type.Object({
sessionKey: Type.Optional(Type.String()),
@@ -155,22 +149,6 @@ function resolveSessionEntry(params: {
return null;
}
function resolveSessionKeyFromSessionId(params: {
cfg: ClawdbotConfig;
sessionId: string;
agentId?: string;
}): string | null {
const trimmed = params.sessionId.trim();
if (!trimmed) return null;
const { store } = loadCombinedSessionStoreForGateway(params.cfg);
const match = Object.entries(store).find(([key, entry]) => {
if (entry?.sessionId !== trimmed) return false;
if (!params.agentId) return true;
return resolveAgentIdFromSessionKey(key) === params.agentId;
});
return match?.[0] ?? null;
}
async function resolveModelOverride(params: {
cfg: ClawdbotConfig;
raw: string;
@@ -244,74 +222,24 @@ export function createSessionStatusTool(opts?: {
const params = args as Record<string, unknown>;
const cfg = opts?.config ?? loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const a2aPolicy = createAgentToAgentPolicy(cfg);
const requestedKeyParam = readStringParam(params, "sessionKey");
let requestedKeyRaw = requestedKeyParam ?? opts?.agentSessionKey;
const requestedKeyRaw = readStringParam(params, "sessionKey") ?? opts?.agentSessionKey;
if (!requestedKeyRaw?.trim()) {
throw new Error("sessionKey required");
}
const requesterAgentId = resolveAgentIdFromSessionKey(
opts?.agentSessionKey ?? requestedKeyRaw,
);
const ensureAgentAccess = (targetAgentId: string) => {
if (targetAgentId === requesterAgentId) return;
// Gate cross-agent access behind tools.agentToAgent settings.
if (!a2aPolicy.enabled) {
throw new Error(
"Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
);
}
if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
throw new Error("Agent-to-agent session status denied by tools.agentToAgent.allow.");
}
};
const agentId = resolveAgentIdFromSessionKey(opts?.agentSessionKey ?? requestedKeyRaw);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
if (requestedKeyRaw.startsWith("agent:")) {
ensureAgentAccess(resolveAgentIdFromSessionKey(requestedKeyRaw));
}
const isExplicitAgentKey = requestedKeyRaw.startsWith("agent:");
let agentId = isExplicitAgentKey
? resolveAgentIdFromSessionKey(requestedKeyRaw)
: requesterAgentId;
let storePath = resolveStorePath(cfg.session?.store, { agentId });
let store = loadSessionStore(storePath);
// Resolve against the requester-scoped store first to avoid leaking default agent data.
let resolved = resolveSessionEntry({
const resolved = resolveSessionEntry({
store,
keyRaw: requestedKeyRaw,
alias,
mainKey,
});
if (!resolved && shouldResolveSessionIdInput(requestedKeyRaw)) {
const resolvedKey = resolveSessionKeyFromSessionId({
cfg,
sessionId: requestedKeyRaw,
agentId: a2aPolicy.enabled ? undefined : requesterAgentId,
});
if (resolvedKey) {
// If resolution points at another agent, enforce A2A policy before switching stores.
ensureAgentAccess(resolveAgentIdFromSessionKey(resolvedKey));
requestedKeyRaw = resolvedKey;
agentId = resolveAgentIdFromSessionKey(resolvedKey);
storePath = resolveStorePath(cfg.session?.store, { agentId });
store = loadSessionStore(storePath);
resolved = resolveSessionEntry({
store,
keyRaw: requestedKeyRaw,
alias,
mainKey,
});
}
}
if (!resolved) {
const kind = shouldResolveSessionIdInput(requestedKeyRaw) ? "sessionId" : "sessionKey";
throw new Error(`Unknown ${kind}: ${requestedKeyRaw}`);
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
}
const configured = resolveDefaultModelForAgent({ cfg, agentId });
+1 -191
View File
@@ -1,12 +1,11 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { sanitizeUserFacingText } from "../pi-embedded-helpers.js";
import {
stripDowngradedToolCallText,
stripMinimaxToolCallXml,
stripThinkingTagsFromText,
} from "../pi-embedded-utils.js";
import { isAcpSessionKey, normalizeMainKey } from "../../routing/session-key.js";
import { normalizeMainKey } from "../../routing/session-key.js";
export type SessionKind = "main" | "group" | "cron" | "hook" | "node" | "other";
@@ -63,195 +62,6 @@ export function resolveInternalSessionKey(params: { key: string; alias: string;
return params.key;
}
export type AgentToAgentPolicy = {
enabled: boolean;
matchesAllow: (agentId: string) => boolean;
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
};
export function createAgentToAgentPolicy(cfg: ClawdbotConfig): AgentToAgentPolicy {
const routingA2A = cfg.tools?.agentToAgent;
const enabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const isAllowed = (requesterAgentId: string, targetAgentId: string) => {
if (requesterAgentId === targetAgentId) return true;
if (!enabled) return false;
return matchesAllow(requesterAgentId) && matchesAllow(targetAgentId);
};
return { enabled, matchesAllow, isAllowed };
}
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function looksLikeSessionId(value: string): boolean {
return SESSION_ID_RE.test(value.trim());
}
export function looksLikeSessionKey(value: string): boolean {
const raw = value.trim();
if (!raw) return false;
// These are canonical key shapes that should never be treated as sessionIds.
if (raw === "main" || raw === "global" || raw === "unknown") return true;
if (isAcpSessionKey(raw)) return true;
if (raw.startsWith("agent:")) return true;
if (raw.startsWith("cron:") || raw.startsWith("hook:")) return true;
if (raw.startsWith("node-") || raw.startsWith("node:")) return true;
if (raw.includes(":group:") || raw.includes(":channel:")) return true;
return false;
}
export function shouldResolveSessionIdInput(value: string): boolean {
// Treat anything that doesn't look like a well-formed key as a sessionId candidate.
return looksLikeSessionId(value) || !looksLikeSessionKey(value);
}
export type SessionReferenceResolution =
| {
ok: true;
key: string;
displayKey: string;
resolvedViaSessionId: boolean;
}
| { ok: false; status: "error" | "forbidden"; error: string };
async function resolveSessionKeyFromSessionId(params: {
sessionId: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution> {
try {
// Resolve via gateway so we respect store routing and visibility rules.
const result = (await callGateway({
method: "sessions.resolve",
params: {
sessionId: params.sessionId,
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
includeGlobal: !params.restrictToSpawned,
includeUnknown: !params.restrictToSpawned,
},
})) as { key?: unknown };
const key = typeof result?.key === "string" ? result.key.trim() : "";
if (!key) {
throw new Error(
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
);
}
return {
ok: true,
key,
displayKey: resolveDisplaySessionKey({
key,
alias: params.alias,
mainKey: params.mainKey,
}),
resolvedViaSessionId: true,
};
} catch (err) {
if (params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${params.sessionId}`,
};
}
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
status: "error",
error:
message ||
`Session not found: ${params.sessionId} (use the full sessionKey from sessions_list)`,
};
}
}
async function resolveSessionKeyFromKey(params: {
key: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution | null> {
try {
// Try key-based resolution first so non-standard keys keep working.
const result = (await callGateway({
method: "sessions.resolve",
params: {
key: params.key,
spawnedBy: params.restrictToSpawned ? params.requesterInternalKey : undefined,
},
})) as { key?: unknown };
const key = typeof result?.key === "string" ? result.key.trim() : "";
if (!key) return null;
return {
ok: true,
key,
displayKey: resolveDisplaySessionKey({
key,
alias: params.alias,
mainKey: params.mainKey,
}),
resolvedViaSessionId: false,
};
} catch {
return null;
}
}
export async function resolveSessionReference(params: {
sessionKey: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
restrictToSpawned: boolean;
}): Promise<SessionReferenceResolution> {
const raw = params.sessionKey.trim();
if (shouldResolveSessionIdInput(raw)) {
// Prefer key resolution to avoid misclassifying custom keys as sessionIds.
const resolvedByKey = await resolveSessionKeyFromKey({
key: raw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
restrictToSpawned: params.restrictToSpawned,
});
if (resolvedByKey) return resolvedByKey;
return await resolveSessionKeyFromSessionId({
sessionId: raw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
restrictToSpawned: params.restrictToSpawned,
});
}
const resolvedKey = resolveInternalSessionKey({
key: raw,
alias: params.alias,
mainKey: params.mainKey,
});
const displayKey = resolveDisplaySessionKey({
key: resolvedKey,
alias: params.alias,
mainKey: params.mainKey,
});
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
}
export function classifySessionKind(params: {
key: string;
gatewayKind?: string | null;
+42 -28
View File
@@ -2,14 +2,17 @@ import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { isSubagentSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
import {
createAgentToAgentPolicy,
resolveSessionReference,
resolveMainSessionAlias,
resolveDisplaySessionKey,
resolveInternalSessionKey,
resolveMainSessionAlias,
stripToolMessages,
} from "./sessions-helpers.js";
@@ -55,7 +58,7 @@ export function createSessionsHistoryTool(opts?: {
parameters: SessionsHistoryToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKeyParam = readStringParam(params, "sessionKey", {
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const cfg = loadConfig();
@@ -69,26 +72,17 @@ export function createSessionsHistoryTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
!!requesterInternalKey &&
requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
const resolvedSession = await resolveSessionReference({
sessionKey: sessionKeyParam,
alias,
mainKey,
requesterInternalKey,
restrictToSpawned,
});
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
// From here on, use the canonical key (sessionId inputs already resolved).
const resolvedKey = resolvedSession.key;
const displayKey = resolvedSession.displayKey;
const resolvedViaSessionId = resolvedSession.resolvedViaSessionId;
if (restrictToSpawned && !resolvedViaSessionId) {
if (restrictToSpawned) {
const ok = await isSpawnedSessionAllowed({
requesterSessionKey: requesterInternalKey,
targetSessionKey: resolvedKey,
@@ -96,24 +90,40 @@ export function createSessionsHistoryTool(opts?: {
if (!ok) {
return jsonResult({
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKeyParam}`,
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
});
}
}
const a2aPolicy = createAgentToAgentPolicy(cfg);
const requesterAgentId = resolveAgentIdFromSessionKey(requesterInternalKey);
const targetAgentId = resolveAgentIdFromSessionKey(resolvedKey);
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow) ? routingA2A.allow : [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
const targetAgentId = normalizeAgentId(parseAgentSessionKey(resolvedKey)?.agentId);
const isCrossAgent = requesterAgentId !== targetAgentId;
if (isCrossAgent) {
if (!a2aPolicy.enabled) {
if (!a2aEnabled) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
@@ -133,7 +143,11 @@ export function createSessionsHistoryTool(opts?: {
const rawMessages = Array.isArray(result?.messages) ? result.messages : [];
const messages = includeTools ? rawMessages : stripToolMessages(rawMessages);
return jsonResult({
sessionKey: displayKey,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
messages,
});
},
Loaded 100 of 210 files, more files were not shown because too many files have changed in this diff. Show more