Compare commits

..

8 Commits

Author SHA1 Message Date
Peter Steinberger
185ffca274 fix: filter telegram native command names (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
c2940adc80 fix: register plugin commands natively (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
29043209c9 fix: refresh reserved plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
bfc0fb742e fix: harden plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Glucksberg
051c92651f fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
b16576f298 fix: clear plugin commands on reload to prevent duplicates
Add clearPluginCommands() call in loadClawdbotPlugins() to ensure
previously registered commands are cleaned up before reloading plugins.
This prevents command conflicts during hot-reload scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
691adccf32 fix: address code review findings for plugin command API
Blockers fixed:
- Fix documentation: requireAuth defaults to true (not false)
- Add command name validation (must start with letter, alphanumeric only)
- Add reserved commands list to prevent shadowing built-in commands
- Emit diagnostic errors for invalid/duplicate command registration

Other improvements:
- Return user-friendly message for unauthorized commands (instead of silence)
- Sanitize error messages to avoid leaking internal details
- Document acceptsArgs behavior when arguments are provided
- Add notes about reserved commands and validation rules to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
420af3285f feat: add plugin command API for LLM-free auto-reply commands
This adds a new `api.registerCommand()` method to the plugin API, allowing
plugins to register slash commands that execute without invoking the AI agent.

Features:
- Plugin commands are processed before built-in commands and the agent
- Commands can optionally require authorization
- Commands can accept arguments
- Async handlers are supported

Use case: plugins can implement toggle commands (like /tts_on, /tts_off)
that respond immediately without consuming LLM API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
97 changed files with 483 additions and 8477 deletions

View File

@@ -5,11 +5,9 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- Contacts/Search: add the contacts-search plugin for unified contacts + cross-platform message search (CLI + /search). (#1438) Thanks @bluzername. https://docs.clawd.bot/plugins/contacts-search https://docs.clawd.bot/contact https://docs.clawd.bot/cli/contacts https://docs.clawd.bot/cli/search
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
- 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`.
@@ -19,15 +17,9 @@ Docs: https://docs.clawd.bot
- 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
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- 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.
@@ -62,9 +54,7 @@ Docs: https://docs.clawd.bot
- 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
@@ -112,22 +102,22 @@ Docs: https://docs.clawd.bot
## 2026.1.21
### Changes
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)

View File

@@ -479,29 +479,28 @@ Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
<a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a>
<a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a>
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a>
<a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a>
<a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a>
<a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a>
<a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
<a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a>
<a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
<a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a>
<a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a>
<a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a>
<a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
<a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a>
<a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
<a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a>
<a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a>
<a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a>
<a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p>

View File

@@ -1,45 +0,0 @@
---
summary: "CLI reference for `clawdbot contacts` (unified contact graph)"
read_when:
- You want to list or link contacts across channels
- You are using the contacts-search plugin
---
# `clawdbot contacts`
Unified contact graph and identity linking.
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
Concept overview: [Contact graph](/contact).
## Examples
```bash
clawdbot contacts list
clawdbot contacts list --query "sarah" --platform slack
clawdbot contacts show <contact-id>
clawdbot contacts search "alice"
clawdbot contacts link <primary-id> <secondary-id>
clawdbot contacts unlink slack U12345678
clawdbot contacts suggestions
clawdbot contacts auto-link --dry-run
clawdbot contacts stats
clawdbot contacts alias <contact-id> "Alias Name"
clawdbot contacts alias <contact-id> "Old Alias" --remove
```
## Commands
- `list`: list contacts (supports `--query`, `--platform`, `--limit`, `--json`).
- `show <id>`: show a contact + identities (accepts a canonical id or a search query).
- `search <query>`: search contacts by name/alias/username.
- `link <primary> <secondary>`: merge two contacts.
- `unlink <platform> <platformId>`: detach an identity into a new contact.
- `suggestions`: show link suggestions.
- `auto-link`: link high-confidence matches (use `--dry-run` to preview).
- `stats`: store statistics by platform.
- `alias <contactId> <alias>`: add or remove aliases (`--remove`).
## Notes
- `--platform` expects a channel id (e.g. `slack`, `discord`, `whatsapp`).
- `unlink` uses the platform id stored on the identity (not the contact id).

View File

@@ -32,8 +32,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`system`](/cli/system)
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`contacts`](/cli/contacts) (plugin; if enabled)
- [`search`](/cli/search) (plugin; if enabled)
- [`nodes`](/cli/nodes)
- [`devices`](/cli/devices)
- [`node`](/cli/node)
@@ -124,8 +122,6 @@ clawdbot [--dev] [--profile <name>] <command>
status
index
search
contacts
search
message
agent
agents

View File

@@ -1,36 +0,0 @@
---
summary: "CLI reference for `clawdbot search` (cross-platform message search)"
read_when:
- You want to search indexed messages across channels
- You are using the contacts-search plugin
---
# `clawdbot search`
Search indexed messages across channels.
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
## Examples
```bash
clawdbot search "meeting tomorrow"
clawdbot search "deadline" --from alice
clawdbot search "project" --platform slack --since 1w
clawdbot search "invoice" --since 2025-12-01 --until 2025-12-31
clawdbot search "handoff" --limit 50 --json
```
## Options
- `--from <contact>`: filter by sender name/alias/username or contact id.
- `--platform <name>`: filter by channel id (e.g. `slack`, `discord`, `whatsapp`).
- `--since <time>`: start time (`1h`, `2d`, `1w`, `1m`, or ISO date).
- `--until <time>`: end time (same formats as `--since`).
- `--limit <n>`: limit results (default `20`).
- `--json`: raw JSON output.
## Notes
- Results come from the local contacts store (`~/.clawdbot/contacts/contacts.sqlite`).
- Only inbound messages are indexed (no backfill).
- Concept overview: [Contact graph](/contact).

View File

@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling
The system prompt includes a dedicated **Current Date & Time** section when the
user timezone is known. To keep the prompt cache-stable, it now only includes
the **time zone** (no dynamic clock or time format).
The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
Use `session_status` when the agent needs the current time; the status card
includes a timestamp line.
- The users **local time** (already converted).
- The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Configure with:

View File

@@ -1,100 +0,0 @@
---
summary: "Unified contacts: contact graph, identity linking, and message indexing"
read_when:
- You want to understand how Clawdbot merges identities across channels
- You are using the Contacts + Search plugin
---
# Contact graph
Clawdbot can maintain a **unified contact graph** that links the same person across multiple channels (Slack, Discord, WhatsApp, etc.).
This powers cross-platform message search and manual identity linking.
The contact graph is provided by the **Contacts + Search** plugin and is **disabled by default**.
## Enable
Install/enable the plugin on the **Gateway host**, then restart the Gateway.
```bash
clawdbot plugins enable contacts-search
```
Config equivalent:
```json5
{
plugins: {
entries: {
"contacts-search": { enabled: true }
}
}
}
```
Related:
- [Contacts + Search plugin](/plugins/contacts-search)
- [Plugins overview](/plugin)
## Data model
The contact graph has three layers:
1) **Canonical contact**
- One logical person.
- Has a `canonicalId`, display name, and optional aliases.
2) **Platform identity**
- One account on one channel (e.g. `slack:U123...`).
- Links back to a canonical contact.
- Optional username, phone, display name, and last-seen time.
3) **Indexed message**
- Text of inbound messages tied to a platform identity.
- Used by cross-platform search.
## How contacts are created
Contacts are created automatically when **inbound messages** arrive:
- The plugin extracts sender identity details from the inbound message.
- If the platform identity is new, a new canonical contact is created.
- If it already exists, the identity metadata is refreshed.
There is **no backfill** step today; indexing starts when the plugin is enabled.
## Linking identities
You can link identities that belong to the same person:
- **Manual link**: merge two contacts into one canonical contact.
- **Suggestions**: name/phone similarity hints (preview-only).
- **Auto-link**: high-confidence matches (same phone number).
CLI reference: [Contacts CLI](/cli/contacts)
## Searching messages
Use the CLI or slash command:
- `clawdbot search "query"` (CLI)
- `/search <query>` (chat)
Search uses SQLite FTS when available; otherwise it falls back to SQL `LIKE`.
CLI reference: [Search CLI](/cli/search)
Slash commands: [Slash commands](/tools/slash-commands)
## Storage + privacy
- Stored locally on the Gateway host at `~/.clawdbot/contacts/contacts.sqlite`.
- No cloud sync by default.
- Treat this file as **sensitive** (names, handles, phone numbers).
To reset the graph, disable the plugin and move the SQLite file to Trash, then restart the Gateway.
## Troubleshooting
- **No results**: the plugin only indexes **new inbound messages**.
- **Missing contacts**: ensure the plugin is enabled and the Gateway restarted.
- **Search feels shallow**: FTS may be unavailable; check that SQLite FTS5 is supported on your runtime.

View File

@@ -7,8 +7,8 @@ read_when:
# Date & Time
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (local by default)
@@ -63,16 +63,16 @@ You can override this behavior:
## System prompt: Current Date & Time
If the user timezone is known, the system prompt includes a dedicated
**Current Date & Time** section with the **time zone only** (no clock/time format)
to keep prompt caching stable:
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
```
Time zone: America/Chicago
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
```
When the agent needs the current time, use the `session_status` tool; the status
card includes a timestamp line.
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (local by default)

View File

@@ -848,8 +848,6 @@
"cli/skills",
"cli/plugins",
"cli/memory",
"cli/contacts",
"cli/search",
"cli/models",
"cli/logs",
"cli/system",
@@ -885,7 +883,6 @@
"concepts/session",
"concepts/session-pruning",
"concepts/sessions",
"contact",
"concepts/session-tool",
"concepts/presence",
"concepts/channel-routing",
@@ -1006,7 +1003,6 @@
"tools/lobster",
"tools/llm-task",
"plugin",
"plugins/contacts-search",
"plugins/voice-call",
"plugins/zalouser",
"tools/exec",
@@ -1049,7 +1045,6 @@
"platforms/android",
"platforms/windows",
"platforms/linux",
"platforms/fly",
"platforms/hetzner",
"platforms/exe-dev"
]

View File

@@ -1446,44 +1446,6 @@ active agents `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
`removeAckAfterReply` removes the bots ack reaction after a reply is sent
(Slack/Discord/Telegram only). Default: `false`.
#### `messages.tts`
Enable text-to-speech for outbound replies. When on, Clawdbot generates audio
using ElevenLabs or OpenAI and attaches it to responses. Telegram uses Opus
voice notes; other channels send MP3 audio.
```json5
{
messages: {
tts: {
enabled: true,
mode: "final", // final | all (include tool/block replies)
provider: "elevenlabs",
maxTextLength: 4000,
timeoutMs: 30000,
prefsPath: "~/.clawdbot/settings/tts.json",
elevenlabs: {
apiKey: "elevenlabs_api_key",
voiceId: "voice_id",
modelId: "eleven_multilingual_v2"
},
openai: {
apiKey: "openai_api_key",
model: "gpt-4o-mini-tts",
voice: "alloy"
}
}
}
}
```
Notes:
- `messages.tts.enabled` can be overridden by local user prefs (see `/tts_on`, `/tts_off`).
- `prefsPath` stores local overrides (enabled/provider/limit/summarize).
- `maxTextLength` is a hard cap for TTS input; summaries are truncated to fit.
- `/tts_limit` and `/tts_summary` control per-user summarization settings.
- `apiKey` values fall back to `ELEVENLABS_API_KEY`/`XI_API_KEY` and `OPENAI_API_KEY`.
### `talk`
Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.

View File

@@ -1,267 +0,0 @@
---
title: Fly.io
description: Deploy Clawdbot on Fly.io
---
# Fly.io Deployment
**Goal:** Clawdbot Gateway running on a [Fly.io](https://fly.io) machine with persistent storage, automatic HTTPS, and Discord/channel access.
## What you need
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
- Fly.io account (free tier works)
- Model auth: Anthropic API key (or other provider keys)
- Channel credentials: Discord bot token, Telegram token, etc.
## Beginner quick path
1. Clone repo → customize `fly.toml`
2. Create app + volume → set secrets
3. Deploy with `fly deploy`
4. SSH in to create config or use Control UI
## 1) Create the Fly app
```bash
# Clone the repo
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
# Create a new Fly app (pick your own name)
fly apps create my-clawdbot
# Create a persistent volume (1GB is usually enough)
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).
## 2) Configure fly.toml
Edit `fly.toml` to match your app name and requirements:
```toml
app = "my-clawdbot" # Your app name
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
CLAWDBOT_PREFER_PNPM = "1"
CLAWDBOT_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "clawdbot_data"
destination = "/data"
```
**Key settings:**
| Setting | Why |
|---------|-----|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
| `--allow-unconfigured` | Starts without a config file (you'll create one after) |
| `memory = "2048mb"` | 512MB is too small; 2GB recommended |
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
## 3) Set secrets
```bash
# Required: Gateway token (for non-loopback binding)
fly secrets set CLAWDBOT_GATEWAY_TOKEN=$(openssl rand -hex 32)
# Model provider API keys
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
# Optional: Other providers
fly secrets set OPENAI_API_KEY=sk-...
fly secrets set GOOGLE_API_KEY=...
# Channel tokens
fly secrets set DISCORD_BOT_TOKEN=MTQ...
```
**Notes:**
- Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security.
- Treat these tokens like passwords.
## 4) Deploy
```bash
fly deploy
```
First deploy builds the Docker image (~2-3 minutes). Subsequent deploys are faster.
After deployment, verify:
```bash
fly status
fly logs
```
You should see:
```
[gateway] listening on ws://0.0.0.0:3000 (PID xxx)
[discord] logged in to discord as xxx
```
## 5) Create config file
SSH into the machine to create a proper config:
```bash
fly ssh console
```
Create the config directory and file:
```bash
mkdir -p /data/.clawdbot
cat > /data/.clawdbot/clawdbot.json << 'EOF'
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5"
},
"models": {
"anthropic/claude-opus-4-5": {},
"anthropic/claude-sonnet-4-5": {}
},
"maxConcurrent": 4
},
"list": [
{
"id": "main",
"default": true
}
]
},
"channels": {
"discord": {
"enabled": true
}
}
}
EOF
```
Restart to apply:
```bash
exit
fly machine restart <machine-id>
```
## 6) Access the Gateway
### Control UI
Open in browser:
```bash
fly open
```
Or visit `https://my-clawdbot.fly.dev/`
Paste your gateway token (the one from `CLAWDBOT_GATEWAY_TOKEN`) to authenticate.
### Logs
```bash
fly logs # Live logs
fly logs --no-tail # Recent logs
```
### SSH Console
```bash
fly ssh console
```
## Troubleshooting
### "App is not listening on expected address"
The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
**Fix:** Add `--bind lan` to your process command in `fly.toml`.
### OOM / Memory Issues
Container keeps restarting or getting killed.
**Fix:** Increase memory in `fly.toml`:
```toml
[[vm]]
memory = "2048mb"
```
### Gateway Lock Issues
Gateway refuses to start with "already running" errors.
This happens when the container restarts but the PID lock file persists on the volume.
**Fix:** Delete the lock file:
```bash
fly ssh console
rm /data/.clawdbot/run/gateway.*.lock
exit
fly machine restart <machine-id>
```
### 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.
Verify the config exists:
```bash
fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
```
## Updates
```bash
# Pull latest changes
git pull
# Redeploy
fly deploy
# Check health
fly status
fly logs
```
## Notes
- Fly.io uses **x86 architecture** (not ARM)
- The Dockerfile is compatible with both architectures
- For WhatsApp/Telegram onboarding, use `fly ssh console`
- Persistent data lives on the volume at `/data`
## Cost
With the recommended config (`shared-cpu-2x`, 2GB RAM):
- ~$10-15/month depending on usage
- Free tier includes some allowance
See [Fly.io pricing](https://fly.io/docs/about/pricing/) for details.

View File

@@ -23,7 +23,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
## VPS & hosting
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)

View File

@@ -38,7 +38,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Contacts + Search](/plugins/contacts-search) — bundled unified contacts + cross-platform search (disabled by default)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -552,6 +551,7 @@ Notes:
- Commands are registered globally and work across all channels
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegrams native command list.
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
- Duplicate command registration across plugins will fail with a diagnostic error

View File

@@ -1,70 +0,0 @@
---
summary: "Contacts + Search plugin: unified contacts and cross-platform message search"
read_when:
- You want unified contacts or cross-platform message search
- You are enabling the contacts-search plugin
---
# Contacts + Search (plugin)
Unified contact graph + cross-platform message search.
Indexes incoming messages, links platform identities, and exposes `/search` plus CLI tools.
## What it adds
- `clawdbot contacts ...` (link, list, search, stats)
- `clawdbot search ...` (message search)
- `/search ...` slash command (text surfaces)
## Where it runs
Runs inside the Gateway process. Enable it on the **Gateway host**, then restart the Gateway.
## Enable (bundled)
```bash
clawdbot plugins enable contacts-search
```
Or in config:
```json5
{
plugins: {
entries: {
"contacts-search": { enabled: true }
}
}
}
```
Restart the Gateway after enabling.
## Data location
The contact store lives under the Clawdbot state directory:
- `~/.clawdbot/contacts/contacts.sqlite`
If you run with `--profile <name>` or `--dev`, the state root changes accordingly.
## Indexing notes
- Messages are indexed as they arrive (no backfill).
- Search uses SQLite FTS when available; otherwise falls back to SQL `LIKE` queries.
## CLI quickstart
```bash
clawdbot contacts list
clawdbot contacts search "sarah"
clawdbot contacts show <contact-id>
clawdbot search "meeting notes" --from sarah --since 1w
```
Related:
- CLI: [contacts](/cli/contacts)
- CLI: [search](/cli/search)
- Concept: [Contact graph](/contact)
- Slash commands: [Slash commands](/tools/slash-commands)
- Plugins: [Plugins](/plugin)

View File

@@ -48,7 +48,6 @@ Implementation:
**OpenAI / OpenAI Codex**
- Image sanitization only.
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
- No tool call id sanitization.
- No tool result pairing repair.
- No turn validation or reordering.

View File

@@ -25,7 +25,6 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot.
Notes:
- Matching is case-insensitive.
- `*` wildcards are supported (`"*"` means all tools).
- If `tools.allow` only references unknown or unloaded plugin tool names, Clawdbot logs a warning and ignores the allowlist so core tools stay available.
## Tool profiles (base allowlist)

View File

@@ -61,20 +61,12 @@ Text + native (when enabled):
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/search <query> [--from <contact>] [--platform <name>] [--since <time>]` (search messages across platforms; requires [Contacts + Search](/plugins/contacts-search))
- `/context [list|detail|json]` (explain "context"; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
- `/tts_on` (enable TTS replies)
- `/tts_off` (disable TTS replies)
- `/tts_provider [openai|elevenlabs]` (set or show TTS provider)
- `/tts_limit <chars>` (max chars before TTS summarization)
- `/tts_summary on|off` (toggle TTS auto-summary)
- `/tts_status` (show TTS status)
- `/audio <text>` (convert text to a TTS audio reply)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)

View File

@@ -1,10 +0,0 @@
{
"id": "contacts-search",
"name": "Contacts + Search",
"description": "Unified contact graph and cross-platform message search",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -1,54 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import {
configureContactStore,
closeContactStore,
} from "./src/contacts/index.js";
import { registerContactsCli } from "./src/cli/contacts-cli.js";
import { registerSearchCli } from "./src/cli/search-cli.js";
import { runSearchCommand } from "./src/commands/search-command.js";
import { indexInboundMessage } from "./src/hooks/message-indexer.js";
const contactsSearchPlugin = {
id: "contacts-search",
name: "Contacts + Search",
description: "Unified contact graph with cross-platform message search",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
const stateDir = api.runtime.state.resolveStateDir();
configureContactStore({ stateDir });
api.registerCli(
({ program }) => {
registerContactsCli(program);
registerSearchCli(program);
},
{ commands: ["contacts", "search"] },
);
api.registerCommand({
name: "search",
description: "Search messages across platforms.",
acceptsArgs: true,
handler: async (ctx) => ({ text: runSearchCommand(ctx.commandBody) }),
});
api.on(
"message_received",
(event, ctx) => {
indexInboundMessage({ event, ctx, logger: api.logger });
},
);
api.registerService({
id: "contacts-search",
start: () => {},
stop: () => {
closeContactStore();
},
});
},
};
export default contactsSearchPlugin;

View File

@@ -1,9 +0,0 @@
{
"name": "@clawdbot/contacts-search",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot unified contacts and cross-platform search plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -1,474 +0,0 @@
import type { Command } from "commander";
import {
autoLinkHighConfidence,
findLinkSuggestions,
getContactStore,
linkContacts,
unlinkIdentity,
} from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
import { formatDocsLink } from "clawdbot/plugin-sdk";
import { cli, formatDanger, formatSuccess, renderTable, theme } from "./formatting.js";
function formatPlatformList(platforms: string[]): string {
return platforms.join(", ");
}
function formatContactRow(contact: {
canonicalId: string;
displayName: string;
aliases: string[];
identities: Array<{ platform: string; platformId: string; username?: string | null }>;
}) {
const platforms = [...new Set(contact.identities.map((i) => i.platform))];
return {
ID: contact.canonicalId,
Name: contact.displayName,
Platforms: formatPlatformList(platforms),
Identities: String(contact.identities.length),
};
}
export function registerContactsCli(program: Command) {
const contacts = program
.command("contacts")
.description("Unified contact graph - cross-platform identity management")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink(
"/cli/contacts",
"docs.clawd.bot/cli/contacts",
)}\n`,
)
.action(() => {
contacts.help({ error: true });
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts list
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("list")
.description("List all contacts in the unified graph")
.option("--query <text>", "Search by name or alias")
.option("--platform <name>", "Filter by platform (channel id)")
.option("--limit <n>", "Limit results", "50")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 50;
const platform = opts.platform
? ((opts.platform as string).toLowerCase() as Platform)
: undefined;
const contactsList = store.listContacts({
query: opts.query as string | undefined,
platform,
limit,
});
const contactsWithIdentities = contactsList
.map((c) => store.getContactWithIdentities(c.canonicalId))
.filter((c): c is NonNullable<typeof c> => c !== null);
if (opts.json) {
cli.log(JSON.stringify(contactsWithIdentities, null, 2));
return;
}
if (contactsWithIdentities.length === 0) {
cli.log(theme.muted("No contacts found."));
return;
}
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 20, flex: true },
{ key: "Name", header: "Name", minWidth: 16, flex: true },
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
{ key: "Identities", header: "#", minWidth: 4 },
],
rows: contactsWithIdentities.map(formatContactRow),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts show
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("show")
.description("Show details for a specific contact")
.argument("<id>", "Contact canonical ID or search query")
.option("--json", "Output JSON", false)
.action(async (id: string, opts) => {
try {
const store = getContactStore();
// Try exact match first
let contact = store.getContactWithIdentities(id);
// If not found, search
if (!contact) {
const matches = store.searchContacts(id, 1);
contact = matches[0] ?? null;
}
if (!contact) {
cli.error(formatDanger(`Contact not found: ${id}`));
cli.exit(1);
return;
}
if (opts.json) {
cli.log(JSON.stringify(contact, null, 2));
return;
}
cli.log(`${theme.heading("Contact")}`);
cli.log(` ID: ${contact.canonicalId}`);
cli.log(` Name: ${contact.displayName}`);
if (contact.aliases.length > 0) {
cli.log(` Aliases: ${contact.aliases.join(", ")}`);
}
cli.log("");
cli.log(
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
);
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Platform", header: "Platform", minWidth: 10 },
{ key: "ID", header: "Platform ID", minWidth: 20, flex: true },
{ key: "Username", header: "Username", minWidth: 12, flex: true },
{ key: "Phone", header: "Phone", minWidth: 14 },
],
rows: contact.identities.map((i) => ({
Platform: i.platform,
ID: i.platformId,
Username: i.username ?? "",
Phone: i.phone ?? "",
})),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts search
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("search")
.description("Search contacts by name, alias, or username")
.argument("<query>", "Search query")
.option("--limit <n>", "Limit results", "10")
.option("--json", "Output JSON", false)
.action(async (query: string, opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 10;
const results = store.searchContacts(query, limit);
if (opts.json) {
cli.log(JSON.stringify(results, null, 2));
return;
}
if (results.length === 0) {
cli.log(theme.muted(`No contacts found matching "${query}".`));
return;
}
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 20, flex: true },
{ key: "Name", header: "Name", minWidth: 16, flex: true },
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
{ key: "Identities", header: "#", minWidth: 4 },
],
rows: results.map(formatContactRow),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts link
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("link")
.description("Link two contacts (merge into one)")
.argument("<primary>", "Primary contact ID (will keep this one)")
.argument("<secondary>", "Secondary contact ID (will be merged and deleted)")
.action(async (primary: string, secondary: string) => {
try {
const store = getContactStore();
const result = linkContacts(store, primary, secondary);
if (!result.success) {
cli.error(formatDanger(result.error ?? "Failed to link contacts"));
cli.exit(1);
return;
}
cli.log(formatSuccess(`Linked: ${secondary} merged into ${primary}`));
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts unlink
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("unlink")
.description("Unlink a platform identity from its contact (creates a new contact)")
.argument("<platform>", "Platform (channel id)")
.argument("<platformId>", "Platform-specific user ID")
.action(async (platform: string, platformId: string) => {
try {
const store = getContactStore();
const result = unlinkIdentity(store, platform, platformId);
if (!result.success) {
cli.error(formatDanger(result.error ?? "Failed to unlink identity"));
cli.exit(1);
return;
}
cli.log(
formatSuccess(
`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`,
),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts suggestions
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("suggestions")
.description("Show link suggestions (contacts that may be the same person)")
.option("--min-score <n>", "Minimum name similarity score (0-1)", "0.85")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const minScore = parseFloat(opts.minScore as string) || 0.85;
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
if (opts.json) {
cli.log(JSON.stringify(suggestions, null, 2));
return;
}
if (suggestions.length === 0) {
cli.log(theme.muted("No link suggestions found."));
return;
}
cli.log(
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
);
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Source", header: "Source", minWidth: 20, flex: true },
{ key: "Target", header: "Target", minWidth: 20, flex: true },
{ key: "Reason", header: "Reason", minWidth: 14 },
{ key: "Confidence", header: "Confidence", minWidth: 10 },
{ key: "Score", header: "Score", minWidth: 6 },
],
rows: suggestions.map((s) => ({
Source: `${s.sourceIdentity.platform}:${s.sourceIdentity.displayName || s.sourceIdentity.platformId}`,
Target: `${s.targetIdentity.platform}:${s.targetIdentity.displayName || s.targetIdentity.platformId}`,
Reason: s.reason,
Confidence: s.confidence,
Score: s.score.toFixed(2),
})),
}).trimEnd(),
);
cli.log("");
cli.log(
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts auto-link
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("auto-link")
.description("Automatically link high-confidence matches (e.g., same phone number)")
.option("--dry-run", "Show what would be linked without making changes", false)
.action(async (opts) => {
try {
const store = getContactStore();
if (opts.dryRun) {
const suggestions = findLinkSuggestions(store);
const highConfidence = suggestions.filter((s) => s.confidence === "high");
if (highConfidence.length === 0) {
cli.log(theme.muted("No high-confidence matches found."));
return;
}
cli.log(
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
);
for (const s of highConfidence) {
cli.log(
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
);
}
return;
}
const result = autoLinkHighConfidence(store);
if (result.linked === 0) {
cli.log(theme.muted("No high-confidence matches found to auto-link."));
return;
}
cli.log(formatSuccess(`Auto-linked ${result.linked} contact(s)`));
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts stats
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("stats")
.description("Show contact store statistics")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const stats = store.getStats();
if (opts.json) {
cli.log(JSON.stringify(stats, null, 2));
return;
}
cli.log(`${theme.heading("Contact Store Statistics")}`);
cli.log(` Contacts: ${stats.contacts}`);
cli.log(` Identities: ${stats.identities}`);
cli.log(` Indexed Messages: ${stats.messages}`);
cli.log("");
cli.log(`${theme.heading("Identities by Platform")}`);
for (const [platform, count] of Object.entries(stats.platforms)) {
cli.log(` ${platform}: ${count}`);
}
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts alias
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("alias")
.description("Add or remove an alias for a contact")
.argument("<contactId>", "Contact ID")
.argument("<alias>", "Alias to add")
.option("--remove", "Remove the alias instead of adding", false)
.action(async (contactId: string, alias: string, opts) => {
try {
const store = getContactStore();
const contact = store.getContact(contactId);
if (!contact) {
cli.error(formatDanger(`Contact not found: ${contactId}`));
cli.exit(1);
return;
}
const currentAliases = contact.aliases;
let newAliases: string[];
if (opts.remove) {
newAliases = currentAliases.filter((a) => a !== alias);
if (newAliases.length === currentAliases.length) {
cli.log(theme.muted(`Alias "${alias}" not found on this contact.`));
return;
}
} else {
if (currentAliases.includes(alias)) {
cli.log(theme.muted(`Alias "${alias}" already exists on this contact.`));
return;
}
newAliases = [...currentAliases, alias];
}
store.updateContact(contactId, { aliases: newAliases });
cli.log(
formatSuccess(
opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`,
),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
}

View File

@@ -1,96 +0,0 @@
type TableColumn = {
key: string;
header: string;
minWidth?: number;
flex?: boolean;
};
type TableRow = Record<string, string | number | null | undefined>;
type TableOptions = {
columns: TableColumn[];
rows: TableRow[];
width?: number;
};
const pad = (value: string, width: number) => value.padEnd(width);
const truncate = (value: string, width: number) => {
if (value.length <= width) return pad(value, width);
if (width <= 3) return value.slice(0, width);
return value.slice(0, width - 3) + "...";
};
export const theme = {
heading: (value: string) => value,
muted: (value: string) => value,
accent: (value: string) => value,
accentBright: (value: string) => value,
};
export const cli = {
log: (message: string) => {
// eslint-disable-next-line no-console
console.log(message);
},
error: (message: string) => {
// eslint-disable-next-line no-console
console.error(message);
},
exit: (code: number) => {
process.exit(code);
},
};
export const formatSuccess = (message: string) => message;
export const formatDanger = (message: string) => message;
export function renderTable({ columns, rows, width }: TableOptions): string {
const widths = columns.map((column) => {
const headerWidth = column.header.length;
const minWidth = column.minWidth ?? 0;
const maxRowWidth = rows.reduce((max, row) => {
const value = String(row[column.key] ?? "");
return Math.max(max, value.length);
}, 0);
return Math.max(minWidth, headerWidth, maxRowWidth);
});
if (width) {
const baseWidth = widths.reduce((sum, colWidth) => sum + colWidth, 0);
const totalWidth = baseWidth + (columns.length - 1) * 2;
if (totalWidth > width) {
const flexColumns = columns
.map((column, index) => (column.flex ? index : -1))
.filter((index) => index >= 0);
if (flexColumns.length > 0) {
const excess = totalWidth - width;
const shrinkEach = Math.ceil(excess / flexColumns.length);
for (const index of flexColumns) {
const minWidth = columns[index]!.minWidth ?? 4;
widths[index] = Math.max(minWidth, widths[index]! - shrinkEach);
}
}
}
}
const header = columns
.map((column, index) => truncate(column.header, widths[index]!))
.join(" ");
const separator = columns
.map((_, index) => "-".repeat(widths[index]!))
.join(" ");
const lines = [header, separator];
for (const row of rows) {
lines.push(
columns
.map((column, index) =>
truncate(String(row[column.key] ?? ""), widths[index]!),
)
.join(" "),
);
}
return lines.join("\n");
}

View File

@@ -1,161 +0,0 @@
import type { Command } from "commander";
import { formatDocsLink } from "clawdbot/plugin-sdk";
import { getContactStore } from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
import { cli, formatDanger, theme } from "./formatting.js";
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: "short" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function parseTimestamp(value: string): number | null {
if (!value) return null;
// Handle relative times like "1h", "2d", "1w"
const relativeMatch = value.match(/^(\d+)([hdwm])$/i);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1]!, 10);
const unit = relativeMatch[2]!.toLowerCase();
const now = Date.now();
switch (unit) {
case "h":
return now - amount * 60 * 60 * 1000;
case "d":
return now - amount * 24 * 60 * 60 * 1000;
case "w":
return now - amount * 7 * 24 * 60 * 60 * 1000;
case "m":
return now - amount * 30 * 24 * 60 * 60 * 1000;
}
}
// Handle ISO date strings
const parsed = Date.parse(value);
if (!isNaN(parsed)) {
return parsed;
}
return null;
}
export function registerSearchCli(program: Command) {
program
.command("search")
.description("Search messages across all messaging platforms")
.argument("<query>", "Search query")
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
.option(
"--platform <name>",
"Filter by platform (channel id)",
)
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
.option("--until <time>", "Filter messages before this time")
.option("--limit <n>", "Limit results", "20")
.option("--json", "Output JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.muted("Examples:")}\n` +
` clawdbot search "meeting tomorrow"\n` +
` clawdbot search "deadline" --from alice\n` +
` clawdbot search "project" --platform slack --since 1w\n` +
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/search", "docs.clawd.bot/cli/search")}\n`,
)
.action(async (query: string, opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 20;
// Parse platforms
let platforms: Platform[] | undefined;
if (opts.platform) {
const platform = (opts.platform as string).toLowerCase() as Platform;
platforms = [platform];
}
// Parse timestamps
const since = opts.since ? parseTimestamp(opts.since as string) : undefined;
const until = opts.until ? parseTimestamp(opts.until as string) : undefined;
if (opts.since && since === null) {
cli.error(formatDanger(`Invalid --since value: ${opts.since}`));
cli.exit(1);
return;
}
if (opts.until && until === null) {
cli.error(formatDanger(`Invalid --until value: ${opts.until}`));
cli.exit(1);
return;
}
const results = store.searchMessages({
query,
from: opts.from as string | undefined,
platforms,
since: since ?? undefined,
until: until ?? undefined,
limit,
});
if (opts.json) {
cli.log(JSON.stringify(results, null, 2));
return;
}
if (results.length === 0) {
cli.log(theme.muted(`No messages found matching "${query}".`));
// Helpful hints
if (opts.from) {
const contactMatches = store.searchContacts(opts.from as string, 5);
if (contactMatches.length === 0) {
cli.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
}
}
return;
}
cli.log(
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
);
cli.log("");
for (const result of results) {
const { message, contact, snippet } = result;
const senderName = contact?.displayName ?? message.senderId;
const time = formatTimestamp(message.timestamp);
cli.log(
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
);
cli.log(` ${snippet}`);
cli.log("");
}
if (results.length === limit) {
cli.log(
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
);
}
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
}

View File

@@ -1,105 +0,0 @@
import type { Platform } from "../contacts/types.js";
/**
* Parse relative time strings like "1h", "2d", "1w"
*/
function parseRelativeTime(value: string): number | null {
const match = value.match(/^(\d+)([hdwm])$/i);
if (!match) return null;
const amount = parseInt(match[1]!, 10);
const unit = match[2]!.toLowerCase();
const now = Date.now();
switch (unit) {
case "h":
return now - amount * 60 * 60 * 1000;
case "d":
return now - amount * 24 * 60 * 60 * 1000;
case "w":
return now - amount * 7 * 24 * 60 * 60 * 1000;
case "m":
return now - amount * 30 * 24 * 60 * 60 * 1000;
default:
return null;
}
}
/**
* Parse search command arguments.
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
*/
function tokenizeArgs(input: string): string[] {
const tokens: string[] = [];
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g;
for (const match of input.matchAll(pattern)) {
const raw = match[1] ?? match[2] ?? match[0];
tokens.push(raw.replace(/\\(["'\\])/g, "$1"));
}
return tokens;
}
export function parseSearchArgs(commandBody: string): {
query: string;
from?: string;
platform?: Platform;
since?: number;
error?: string;
} {
const argsStr = commandBody.replace(/^\/search\s*/i, "").trim();
if (!argsStr) {
return {
query: "",
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
};
}
let query = "";
let from: string | undefined;
let platform: Platform | undefined;
let since: number | undefined;
const parts = tokenizeArgs(argsStr);
const queryParts: string[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (part === "--from" && i + 1 < parts.length) {
const fromParts: string[] = [];
while (i + 1 < parts.length && !parts[i + 1]!.startsWith("--")) {
fromParts.push(parts[++i]!);
}
if (fromParts.length === 0) {
return { query: "", error: "Missing value for --from" };
}
from = fromParts.join(" ");
} else if (part === "--platform" && i + 1 < parts.length) {
platform = parts[++i]!.toLowerCase() as Platform;
} else if (part === "--since" && i + 1 < parts.length) {
const timeStr = parts[++i]!;
const parsed = parseRelativeTime(timeStr);
if (parsed === null) {
return {
query: "",
error: `Invalid --since value: ${timeStr}. Use format like 1h, 2d, 1w, 1m`,
};
}
since = parsed;
} else if (part.startsWith("--")) {
return { query: "", error: `Unknown option: ${part}` };
} else {
queryParts.push(part);
}
}
query = queryParts.join(" ");
if (!query) {
return {
query: "",
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
};
}
return { query, from, platform, since };
}

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseSearchArgs } from "./search-args.js";
describe("parseSearchArgs", () => {
it("handles multi-word --from without quotes", () => {
const parsed = parseSearchArgs('/search budget --from Sarah Smith');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("budget");
expect(parsed.from).toBe("Sarah Smith");
});
it("handles quoted multi-word --from", () => {
const parsed = parseSearchArgs('/search budget --from "Sarah Smith" --since 1w');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("budget");
expect(parsed.from).toBe("Sarah Smith");
expect(parsed.since).toBeTypeOf("number");
});
it("keeps multi-word query alongside --from", () => {
const parsed = parseSearchArgs('/search quarterly report --from Sarah Smith');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("quarterly report");
expect(parsed.from).toBe("Sarah Smith");
});
});

View File

@@ -1,74 +0,0 @@
import { getContactStore } from "../contacts/index.js";
import { parseSearchArgs } from "./search-args.js";
/**
* Format a timestamp for display
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: "short" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
export function runSearchCommand(commandBody: string): string {
const parsed = parseSearchArgs(commandBody);
if (parsed.error) {
return `${parsed.error}`;
}
try {
const store = getContactStore();
const results = store.searchMessages({
query: parsed.query,
from: parsed.from,
platforms: parsed.platform ? [parsed.platform] : undefined,
since: parsed.since,
limit: 10,
});
if (results.length === 0) {
let msg = `🔍 No messages found matching "${parsed.query}"`;
if (parsed.from) {
const contactMatches = store.searchContacts(parsed.from, 5);
if (contactMatches.length === 0) {
msg += `\n\n⚠ Note: No contacts found matching "${parsed.from}"`;
}
}
return msg;
}
const lines = [`🔍 Search Results (${results.length})\n`];
for (const result of results) {
const { message, contact, snippet } = result;
const senderName = contact?.displayName ?? message.senderId;
const time = formatTimestamp(message.timestamp);
const platformLabel = message.platform.toUpperCase();
lines.push(`[${platformLabel}] ${senderName} - ${time}`);
lines.push(` ${snippet}`);
lines.push("");
}
if (results.length === 10) {
lines.push('Use the CLI for more results: clawdbot search "' + parsed.query + '" --limit 50');
}
return lines.join("\n").trim();
} catch (err) {
return `❌ Search error: ${err instanceof Error ? err.message : String(err)}`;
}
}

View File

@@ -1,407 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import {
extractDiscordContact,
extractIMessageContact,
extractSignalContact,
extractSlackContact,
extractTelegramContact,
extractWhatsAppContact,
importContactFromMessage,
importDiscordGuildMembers,
importSlackUsers,
importWhatsAppGroupParticipants,
isJidGroup,
jidToE164,
} from "./importer.js";
describe("jidToE164", () => {
it("extracts phone from standard JID", () => {
expect(jidToE164("14155551234@s.whatsapp.net")).toBe("+14155551234");
});
it("extracts phone from JID with device suffix", () => {
expect(jidToE164("14155551234:0@s.whatsapp.net")).toBe("+14155551234");
});
it("returns null for invalid JID", () => {
expect(jidToE164("")).toBeNull();
expect(jidToE164("invalid")).toBeNull();
expect(jidToE164("abc@s.whatsapp.net")).toBeNull();
});
it("returns null for group JID", () => {
// Group JIDs don't have phone numbers
expect(jidToE164("123456789-1234567890@g.us")).toBeNull();
});
});
describe("isJidGroup", () => {
it("returns true for group JIDs", () => {
expect(isJidGroup("123456789-1234567890@g.us")).toBe(true);
expect(isJidGroup("status@broadcast")).toBe(true);
});
it("returns false for user JIDs", () => {
expect(isJidGroup("14155551234@s.whatsapp.net")).toBe(false);
});
});
describe("extractWhatsAppContact", () => {
it("extracts contact data from sender JID", () => {
const data = extractWhatsAppContact({
senderJid: "14155551234@s.whatsapp.net",
pushName: "John Doe",
});
expect(data).toEqual({
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("returns null for group JID", () => {
const data = extractWhatsAppContact({
senderJid: "123-456@g.us",
pushName: "Group Name",
});
expect(data).toBeNull();
});
it("handles missing push name", () => {
const data = extractWhatsAppContact({
senderJid: "14155551234@s.whatsapp.net",
});
expect(data?.displayName).toBeNull();
});
});
describe("extractTelegramContact", () => {
it("extracts contact data from user info", () => {
const data = extractTelegramContact({
userId: 123456789,
username: "johndoe",
firstName: "John",
lastName: "Doe",
});
expect(data).toEqual({
platform: "telegram",
platformId: "123456789",
username: "johndoe",
phone: null,
displayName: "John Doe",
});
});
it("handles missing fields", () => {
const data = extractTelegramContact({
userId: 123456789,
});
expect(data?.username).toBeNull();
expect(data?.displayName).toBeNull();
});
it("handles first name only", () => {
const data = extractTelegramContact({
userId: 123456789,
firstName: "John",
});
expect(data?.displayName).toBe("John");
});
});
describe("extractDiscordContact", () => {
it("extracts contact data from user info", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
globalName: "John Doe",
nick: "Johnny",
});
expect(data).toEqual({
platform: "discord",
platformId: "123456789012345678",
username: "johndoe",
phone: null,
displayName: "Johnny", // Nick takes precedence
});
});
it("falls back to globalName when no nick", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
globalName: "John Doe",
});
expect(data?.displayName).toBe("John Doe");
});
it("falls back to username when no globalName", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
});
expect(data?.displayName).toBe("johndoe");
});
});
describe("extractSlackContact", () => {
it("extracts contact data from user info", () => {
const data = extractSlackContact({
userId: "U12345678",
username: "john.doe",
displayName: "John Doe",
realName: "John Michael Doe",
});
expect(data).toEqual({
platform: "slack",
platformId: "U12345678",
username: "john.doe",
phone: null,
displayName: "John Doe", // displayName takes precedence
});
});
it("falls back to realName when no displayName", () => {
const data = extractSlackContact({
userId: "U12345678",
username: "john.doe",
realName: "John Doe",
});
expect(data?.displayName).toBe("John Doe");
});
});
describe("extractSignalContact", () => {
it("extracts contact data from signal envelope", () => {
const data = extractSignalContact({
sourceNumber: "+14155551234",
sourceUuid: "uuid-123-456",
sourceName: "John Doe",
});
expect(data).toEqual({
platform: "signal",
platformId: "uuid-123-456", // UUID preferred
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("uses phone as platformId when no UUID", () => {
const data = extractSignalContact({
sourceNumber: "+14155551234",
sourceName: "John Doe",
});
expect(data?.platformId).toBe("+14155551234");
});
it("returns null when no identifier", () => {
const data = extractSignalContact({
sourceName: "John Doe",
});
expect(data).toBeNull();
});
});
describe("extractIMessageContact", () => {
it("extracts contact from phone number", () => {
const data = extractIMessageContact({
senderId: "+14155551234",
senderName: "John Doe",
});
expect(data).toEqual({
platform: "imessage",
platformId: "+14155551234",
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("extracts contact from email", () => {
const data = extractIMessageContact({
senderId: "john@example.com",
senderName: "John Doe",
});
expect(data).toEqual({
platform: "imessage",
platformId: "john@example.com",
username: null,
phone: null, // Email is not a phone
displayName: "John Doe",
});
});
});
describe("importContactFromMessage", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
it("creates new contact for unknown sender", () => {
const { contactId, isNew } = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(isNew).toBe(true);
expect(contactId).toMatch(/^john-doe-[a-f0-9]{8}$/);
const contact = store.getContactWithIdentities(contactId);
expect(contact?.displayName).toBe("John Doe");
expect(contact?.identities.length).toBe(1);
expect(contact?.identities[0]?.platform).toBe("telegram");
});
it("returns existing contact for known sender", () => {
// First import
const first = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(first.isNew).toBe(true);
// Second import of same sender
const second = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(second.isNew).toBe(false);
expect(second.contactId).toBe(first.contactId);
});
it("updates identity metadata for known sender", () => {
importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johnny",
displayName: "John D",
phone: "+14155551234",
});
const identity = store.getIdentityByPlatformId("telegram", "123456789");
expect(identity?.username).toBe("johnny");
expect(identity?.displayName).toBe("John D");
expect(identity?.phone).toBe("+14155551234");
});
it("uses platformId as displayName fallback", () => {
const { contactId } = importContactFromMessage(store, {
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
displayName: null,
phone: "+14155551234",
});
const contact = store.getContact(contactId);
expect(contact?.displayName).toBe("14155551234@s.whatsapp.net");
});
});
describe("bulk importers", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("importSlackUsers", () => {
it("imports users from Slack API response", async () => {
const mockUsers = [
{ id: "U1", name: "alice", displayName: "Alice", isBot: false },
{ id: "U2", name: "bob", realName: "Bob Smith", isBot: false },
{ id: "U3", name: "bot", displayName: "Bot", isBot: true }, // Should be skipped
{ id: "U4", name: "deleted", displayName: "Deleted", deleted: true }, // Should be skipped
];
const result = await importSlackUsers(store, async () => mockUsers);
expect(result.platform).toBe("slack");
expect(result.imported).toBe(2); // Only Alice and Bob
expect(result.errors.length).toBe(0);
const contacts = store.listContacts();
expect(contacts.length).toBe(2);
});
it("handles API errors gracefully", async () => {
const result = await importSlackUsers(store, async () => {
throw new Error("API error");
});
expect(result.imported).toBe(0);
expect(result.errors.length).toBe(1);
expect(result.errors[0]).toContain("Failed to list Slack users");
});
});
describe("importDiscordGuildMembers", () => {
it("imports members from Discord API response", async () => {
const mockMembers = [
{ user: { id: "1", username: "alice", global_name: "Alice" }, nick: null },
{ user: { id: "2", username: "bob", global_name: "Bob" }, nick: "Bobby" },
{ user: { id: "3", username: "botuser", bot: true } }, // Should be skipped
];
const result = await importDiscordGuildMembers(store, async () => mockMembers);
expect(result.platform).toBe("discord");
expect(result.imported).toBe(2);
expect(result.errors.length).toBe(0);
const contacts = store.listContacts();
expect(contacts.length).toBe(2);
});
});
describe("importWhatsAppGroupParticipants", () => {
it("imports participants from group metadata", async () => {
const mockGetMetadata = async (_jid: string) => ({
subject: "Test Group",
participants: [
{ id: "14155551111@s.whatsapp.net" },
{ id: "14155552222@s.whatsapp.net" },
{ id: "123-456@g.us" }, // Group JID should be skipped
],
});
const result = await importWhatsAppGroupParticipants(store, mockGetMetadata, "123-456@g.us");
expect(result.platform).toBe("whatsapp");
expect(result.imported).toBe(2);
expect(result.errors.length).toBe(0);
});
});
});

View File

@@ -1,393 +0,0 @@
/**
* Contact importers for each messaging platform.
*
* Since many platforms don't have bulk contact APIs, importers use a combination of:
* 1. Direct API calls where available (Slack users.list, Discord members)
* 2. Message-based discovery (observing incoming messages)
* 3. Group metadata extraction
*/
import type { ContactStore } from "./store.js";
import type { ImportResult, Platform, PlatformIdentityInput } from "./types.js";
/**
* Base interface for platform-specific contact importers.
*/
export type ContactImporter = {
/** Platform this importer handles */
platform: Platform;
/** Import contacts from this platform */
import(store: ContactStore): Promise<ImportResult>;
};
/**
* Extract E.164 phone number from WhatsApp JID.
* JID format: "1234567890@s.whatsapp.net" or "1234567890:0@s.whatsapp.net"
*/
export function jidToE164(jid: string): string | null {
if (!jid) return null;
// Remove suffix
const numberPart = jid.split("@")[0];
if (!numberPart) return null;
// Handle device suffix (e.g., "1234567890:0")
const phone = numberPart.split(":")[0];
if (!phone || !/^\d{7,15}$/.test(phone)) return null;
return `+${phone}`;
}
/**
* Check if a JID is a group.
*/
export function isJidGroup(jid: string): boolean {
return jid.includes("@g.us") || jid.includes("@broadcast");
}
/**
* Data extracted from an incoming message for contact discovery.
*/
export type MessageContactData = {
platform: Platform;
platformId: string;
username?: string | null;
phone?: string | null;
displayName?: string | null;
};
/**
* Import a contact from message data.
* Creates a new contact if the platform identity doesn't exist,
* or updates the existing contact's metadata.
*/
export function importContactFromMessage(
store: ContactStore,
data: MessageContactData,
): { contactId: string; isNew: boolean } {
// Check if identity already exists
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
if (existing) {
const updated = {
contactId: existing.contactId,
platform: existing.platform,
platformId: existing.platformId,
username: data.username ?? existing.username,
phone: data.phone ?? existing.phone,
displayName: data.displayName ?? existing.displayName,
lastSeenAt: Date.now(),
} satisfies PlatformIdentityInput;
store.addIdentity(updated);
return { contactId: existing.contactId, isNew: false };
}
// Create new contact and identity
const displayName = data.displayName || data.username || data.platformId;
const contact = store.createContact(displayName);
const input: PlatformIdentityInput = {
contactId: contact.canonicalId,
platform: data.platform,
platformId: data.platformId,
username: data.username ?? null,
phone: data.phone ?? null,
displayName: data.displayName ?? null,
lastSeenAt: Date.now(),
};
store.addIdentity(input);
return { contactId: contact.canonicalId, isNew: true };
}
/**
* WhatsApp contact data extraction from message.
*/
export function extractWhatsAppContact(params: {
senderJid: string;
pushName?: string | null;
}): MessageContactData | null {
const { senderJid, pushName } = params;
if (!senderJid || isJidGroup(senderJid)) return null;
const phone = jidToE164(senderJid);
return {
platform: "whatsapp",
platformId: senderJid,
username: null,
phone,
displayName: pushName ?? null,
};
}
/**
* Telegram contact data extraction from message.
*/
export function extractTelegramContact(params: {
userId: number | string;
username?: string | null;
firstName?: string | null;
lastName?: string | null;
}): MessageContactData | null {
const { userId, username, firstName, lastName } = params;
if (!userId) return null;
const displayName = [firstName, lastName].filter(Boolean).join(" ") || null;
return {
platform: "telegram",
platformId: String(userId),
username: username ?? null,
phone: null,
displayName,
};
}
/**
* Discord contact data extraction from message.
*/
export function extractDiscordContact(params: {
userId: string;
username?: string | null;
globalName?: string | null;
nick?: string | null;
}): MessageContactData | null {
const { userId, username, globalName, nick } = params;
if (!userId) return null;
// Prefer display names: nick > globalName > username
const displayName = nick || globalName || username || null;
return {
platform: "discord",
platformId: userId,
username: username ?? null,
phone: null,
displayName,
};
}
/**
* Slack contact data extraction from message.
*/
export function extractSlackContact(params: {
userId: string;
username?: string | null;
displayName?: string | null;
realName?: string | null;
}): MessageContactData | null {
const { userId, username, displayName, realName } = params;
if (!userId) return null;
return {
platform: "slack",
platformId: userId,
username: username ?? null,
phone: null,
displayName: displayName || realName || null,
};
}
/**
* Signal contact data extraction from message.
*/
export function extractSignalContact(params: {
sourceNumber?: string | null;
sourceUuid?: string | null;
sourceName?: string | null;
}): MessageContactData | null {
const { sourceNumber, sourceUuid, sourceName } = params;
// Prefer UUID as platformId, fall back to phone
const platformId = sourceUuid || sourceNumber;
if (!platformId) return null;
return {
platform: "signal",
platformId,
username: null,
phone: sourceNumber ?? null,
displayName: sourceName ?? null,
};
}
/**
* iMessage contact data extraction from message.
*/
export function extractIMessageContact(params: {
senderId: string;
senderName?: string | null;
}): MessageContactData | null {
const { senderId, senderName } = params;
if (!senderId) return null;
// iMessage senderId can be phone or email
const isPhone = /^\+?\d{10,}$/.test(senderId.replace(/\D/g, ""));
return {
platform: "imessage",
platformId: senderId,
username: null,
phone: isPhone ? senderId : null,
displayName: senderName ?? null,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// BULK IMPORTERS (for platforms with bulk APIs)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Slack bulk importer using users.list API.
*/
export async function importSlackUsers(
store: ContactStore,
listUsers: () => Promise<
Array<{
id: string;
name?: string;
displayName?: string;
realName?: string;
email?: string;
isBot?: boolean;
deleted?: boolean;
}>
>,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "slack",
imported: 0,
linked: 0,
errors: [],
};
try {
const users = await listUsers();
for (const user of users) {
// Skip bots and deleted users
if (user.isBot || user.deleted) continue;
if (!user.id) continue;
try {
const data = extractSlackContact({
userId: user.id,
username: user.name,
displayName: user.displayName,
realName: user.realName,
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import user ${user.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Slack users: ${String(err)}`);
}
return result;
}
/**
* Discord bulk importer using guild member search.
*/
export async function importDiscordGuildMembers(
store: ContactStore,
listMembers: () => Promise<
Array<{
user: {
id: string;
username?: string;
global_name?: string;
bot?: boolean;
};
nick?: string | null;
}>
>,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "discord",
imported: 0,
linked: 0,
errors: [],
};
try {
const members = await listMembers();
for (const member of members) {
// Skip bots
if (member.user.bot) continue;
if (!member.user.id) continue;
try {
const data = extractDiscordContact({
userId: member.user.id,
username: member.user.username,
globalName: member.user.global_name,
nick: member.nick,
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import member ${member.user.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Discord members: ${String(err)}`);
}
return result;
}
/**
* WhatsApp group participants importer.
*/
export async function importWhatsAppGroupParticipants(
store: ContactStore,
getGroupMetadata: (groupJid: string) => Promise<{
subject?: string;
participants?: Array<{ id: string }>;
}>,
groupJid: string,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "whatsapp",
imported: 0,
linked: 0,
errors: [],
};
try {
const meta = await getGroupMetadata(groupJid);
const participants = meta.participants ?? [];
for (const participant of participants) {
if (!participant.id) continue;
if (isJidGroup(participant.id)) continue;
try {
const data = extractWhatsAppContact({
senderJid: participant.id,
pushName: null, // Group metadata doesn't include push names
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import participant ${participant.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to get group metadata for ${groupJid}: ${String(err)}`);
}
return result;
}

View File

@@ -1,49 +0,0 @@
/**
* Contacts module - Unified contact graph for cross-platform identity resolution.
*
* This module provides:
* - Canonical contact management (create, link, search)
* - Platform identity linking (WhatsApp, Telegram, Discord, Slack, Signal, etc.)
* - Message indexing for cross-platform search
* - Auto-linking heuristics based on phone/email/name matching
*/
export { ContactStore, configureContactStore, getContactStore, closeContactStore } from "./store.js";
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
export {
importContactFromMessage,
extractWhatsAppContact,
extractTelegramContact,
extractDiscordContact,
extractSlackContact,
extractSignalContact,
extractIMessageContact,
importSlackUsers,
importDiscordGuildMembers,
importWhatsAppGroupParticipants,
jidToE164,
isJidGroup,
} from "./importer.js";
export type {
Contact,
ContactSearchOptions,
ContactWithIdentities,
ImportResult,
IndexedMessage,
LinkConfidence,
LinkSuggestion,
MessageSearchOptions,
MessageSearchResult,
Platform,
PlatformIdentity,
PlatformIdentityInput,
} from "./types.js";
export type { ContactImporter, MessageContactData } from "./importer.js";
export {
findPhoneMatches,
findNameMatches,
findLinkSuggestions,
linkContacts,
unlinkIdentity,
autoLinkHighConfidence,
} from "./linker.js";

View File

@@ -1,600 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import {
autoLinkHighConfidence,
findLinkSuggestions,
findNameMatches,
findPhoneMatches,
linkContacts,
unlinkIdentity,
} from "./linker.js";
describe("linker", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("findPhoneMatches", () => {
it("finds contacts with same phone number", () => {
// Create two contacts with same phone on different platforms
const contact1 = store.createContact("John on WhatsApp");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "John",
lastSeenAt: null,
});
const contact2 = store.createContact("John on Signal");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "uuid-john",
username: null,
phone: "+14155551234",
displayName: "John D",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(1);
expect(suggestions[0]?.reason).toBe("phone_match");
expect(suggestions[0]?.confidence).toBe("high");
expect(suggestions[0]?.score).toBe(1.0);
});
it("does not suggest already-linked contacts", () => {
const contact = store.createContact("John");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-john",
username: null,
phone: "+14155551234",
displayName: "John WA",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "signal",
platformId: "signal-john",
username: null,
phone: "+14155551234",
displayName: "John Signal",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(0);
});
it("returns empty for no phone matches", () => {
const contact1 = store.createContact("Alice");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-alice",
username: null,
phone: "+14155551111",
displayName: "Alice",
lastSeenAt: null,
});
const contact2 = store.createContact("Bob");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "whatsapp",
platformId: "wa-bob",
username: null,
phone: "+14155552222",
displayName: "Bob",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(0);
});
});
describe("findNameMatches", () => {
it("finds contacts with similar names", () => {
const contact1 = store.createContact("John Doe");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe",
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const contact2 = store.createContact("John Doe"); // Same name
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: "johndoe",
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const suggestions = findNameMatches(store);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0]?.reason).toBe("name_similarity");
expect(suggestions[0]?.score).toBe(1.0);
});
it("finds contacts with slightly different names", () => {
const contact1 = store.createContact("John Doe");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: null,
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const contact2 = store.createContact("John D"); // Shorter version
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: null,
phone: null,
displayName: "John D",
lastSeenAt: null,
});
// With default threshold of 0.85, these may or may not match
const suggestions = findNameMatches(store, { minScore: 0.6 });
// At least should find something with low threshold
expect(suggestions.length).toBeGreaterThan(0);
});
it("respects minimum score threshold", () => {
const contact1 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const contact2 = store.createContact("Bob Jones");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-bob",
username: null,
phone: null,
displayName: "Bob Jones",
lastSeenAt: null,
});
// Completely different names should not match
const suggestions = findNameMatches(store, { minScore: 0.85 });
expect(suggestions.length).toBe(0);
});
});
describe("findLinkSuggestions", () => {
it("combines phone and name matches", () => {
// Phone match
const contact1 = store.createContact("Phone User 1");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-1",
username: null,
phone: "+14155551234",
displayName: "Phone User",
lastSeenAt: null,
});
const contact2 = store.createContact("Phone User 2");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-1",
username: null,
phone: "+14155551234",
displayName: "Phone User",
lastSeenAt: null,
});
// Name match (different person)
const contact3 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact3.canonicalId,
platform: "telegram",
platformId: "tg-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const contact4 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact4.canonicalId,
platform: "discord",
platformId: "dc-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const suggestions = findLinkSuggestions(store);
expect(suggestions.length).toBeGreaterThanOrEqual(2);
// Phone matches should come first (high confidence)
const phoneMatch = suggestions.find((s) => s.reason === "phone_match");
expect(phoneMatch).toBeDefined();
expect(phoneMatch?.confidence).toBe("high");
});
it("sorts by confidence then score", () => {
// Create multiple potential matches with different confidence levels
const contact1 = store.createContact("Test User A");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-a",
username: null,
phone: "+14155559999",
displayName: "Test User A",
lastSeenAt: null,
});
const contact2 = store.createContact("Test User A");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-a",
username: null,
phone: "+14155559999",
displayName: "Test User A",
lastSeenAt: null,
});
const suggestions = findLinkSuggestions(store);
expect(suggestions.length).toBeGreaterThan(0);
// Verify sorted by confidence
for (let i = 1; i < suggestions.length; i++) {
const prev = suggestions[i - 1]!;
const curr = suggestions[i]!;
const confidenceOrder = { high: 3, medium: 2, low: 1 };
const prevConf = confidenceOrder[prev.confidence];
const currConf = confidenceOrder[curr.confidence];
expect(prevConf).toBeGreaterThanOrEqual(currConf);
}
});
});
describe("linkContacts", () => {
it("merges two contacts", () => {
const contact1 = store.createContact("John on Telegram");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe_tg",
phone: null,
displayName: "John TG",
lastSeenAt: null,
});
const contact2 = store.createContact("John on Discord");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: "johndoe_dc",
phone: null,
displayName: "John DC",
lastSeenAt: null,
});
const result = linkContacts(store, contact1.canonicalId, contact2.canonicalId);
expect(result.success).toBe(true);
// Primary contact should have both identities
const merged = store.getContactWithIdentities(contact1.canonicalId);
expect(merged?.identities.length).toBe(2);
expect(merged?.identities.map((i) => i.platform)).toContain("telegram");
expect(merged?.identities.map((i) => i.platform)).toContain("discord");
// Secondary contact should be deleted
expect(store.getContact(contact2.canonicalId)).toBeNull();
// Aliases should include secondary contact's name
expect(merged?.aliases).toContain("John on Discord");
});
it("reassigns message history when merging contacts", () => {
const primary = store.createContact("Merge Primary");
store.addIdentity({
contactId: primary.canonicalId,
platform: "telegram",
platformId: "tg-merge",
username: null,
phone: null,
displayName: "TG Merge",
lastSeenAt: null,
});
const secondary = store.createContact("Merge Secondary");
store.addIdentity({
contactId: secondary.canonicalId,
platform: "discord",
platformId: "dc-merge",
username: null,
phone: null,
displayName: "DC Merge",
lastSeenAt: null,
});
store.indexMessage({
id: "msg-merge-1",
content: "merge history one",
platform: "telegram",
senderId: "tg-merge",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-merge-2",
content: "merge history two",
platform: "discord",
senderId: "dc-merge",
channelId: "c2",
timestamp: Date.now(),
});
const result = linkContacts(store, primary.canonicalId, secondary.canonicalId);
expect(result.success).toBe(true);
const results = store.searchMessages({ query: "merge history", from: primary.canonicalId });
expect(results.length).toBe(2);
const ids = results.map((entry) => entry.message.id);
expect(ids).toContain("msg-merge-1");
expect(ids).toContain("msg-merge-2");
expect(results.every((entry) => entry.message.contactId === primary.canonicalId)).toBe(true);
});
it("returns error for non-existent primary contact", () => {
const contact = store.createContact("Test");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-test",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = linkContacts(store, "fake-id", contact.canonicalId);
expect(result.success).toBe(false);
expect(result.error).toContain("Primary contact not found");
});
it("returns error for non-existent secondary contact", () => {
const contact = store.createContact("Test");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-test",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = linkContacts(store, contact.canonicalId, "fake-id");
expect(result.success).toBe(false);
expect(result.error).toContain("Secondary contact not found");
});
});
describe("unlinkIdentity", () => {
it("creates new contact for unlinked identity", () => {
const contact = store.createContact("Multi Platform User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-user",
username: "user_tg",
phone: null,
displayName: "TG User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-user",
username: "user_dc",
phone: null,
displayName: "DC User",
lastSeenAt: null,
});
const result = unlinkIdentity(store, "discord", "dc-user");
expect(result.success).toBe(true);
expect(result.newContactId).toBeDefined();
// Original contact should only have telegram identity
const original = store.getContactWithIdentities(contact.canonicalId);
expect(original?.identities.length).toBe(1);
expect(original?.identities[0]?.platform).toBe("telegram");
// New contact should have discord identity
const newContact = store.getContactWithIdentities(result.newContactId!);
expect(newContact?.identities.length).toBe(1);
expect(newContact?.identities[0]?.platform).toBe("discord");
});
it("moves message history to new contact when unlinking", () => {
const contact = store.createContact("Unlink User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-unlink",
username: null,
phone: null,
displayName: "TG Unlink",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-unlink",
username: null,
phone: null,
displayName: "DC Unlink",
lastSeenAt: null,
});
store.indexMessage({
id: "msg-unlink",
content: "unlink history message",
platform: "discord",
senderId: "dc-unlink",
channelId: "c3",
timestamp: Date.now(),
});
const result = unlinkIdentity(store, "discord", "dc-unlink");
expect(result.success).toBe(true);
const newContactId = result.newContactId!;
const newResults = store.searchMessages({
query: "unlink history",
from: newContactId,
});
expect(newResults.length).toBe(1);
expect(newResults[0]?.message.contactId).toBe(newContactId);
const oldResults = store.searchMessages({
query: "unlink history",
from: contact.canonicalId,
});
expect(oldResults.length).toBe(0);
});
it("returns error for non-existent identity", () => {
const result = unlinkIdentity(store, "telegram", "fake-id");
expect(result.success).toBe(false);
expect(result.error).toContain("Identity not found");
});
it("returns error when trying to unlink only identity", () => {
const contact = store.createContact("Single Identity User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-single",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = unlinkIdentity(store, "telegram", "tg-single");
expect(result.success).toBe(false);
expect(result.error).toContain("Cannot unlink the only identity");
});
});
describe("autoLinkHighConfidence", () => {
it("automatically links high confidence matches", () => {
// Create contacts with same phone (high confidence)
const contact1 = store.createContact("Auto Link User 1");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-auto",
username: null,
phone: "+14155557777",
displayName: "Auto User WA",
lastSeenAt: null,
});
const contact2 = store.createContact("Auto Link User 2");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-auto",
username: null,
phone: "+14155557777",
displayName: "Auto User Signal",
lastSeenAt: null,
});
const initialCount = store.listContacts().length;
expect(initialCount).toBe(2);
const result = autoLinkHighConfidence(store);
expect(result.linked).toBe(1);
// Should now have only one contact
const finalCount = store.listContacts().length;
expect(finalCount).toBe(1);
// The remaining contact should have both identities
const contacts = store.listContacts();
const merged = store.getContactWithIdentities(contacts[0]!.canonicalId);
expect(merged?.identities.length).toBe(2);
});
it("does not link medium/low confidence matches", () => {
// Create contacts with similar but not exact names (medium confidence)
const contact1 = store.createContact("John Smith");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: null,
phone: null,
displayName: "John Smith",
lastSeenAt: null,
});
const contact2 = store.createContact("John Smyth"); // Similar but not same
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: null,
phone: null,
displayName: "John Smyth",
lastSeenAt: null,
});
const result = autoLinkHighConfidence(store);
// Name similarity below threshold should not auto-link
// They may or may not be linked depending on exact similarity
// But we verify auto-link only processes high confidence
expect(result.suggestions.every((s) => s.confidence === "high")).toBe(true);
});
});
});

View File

@@ -1,396 +0,0 @@
/**
* Auto-linking heuristics for the unified contact graph.
*
* This module provides algorithms to automatically detect and suggest
* links between platform identities that likely belong to the same person.
*
* Linking heuristics (in priority order):
* 1. Phone match: Same E.164 phone across platforms (high confidence)
* 2. Name similarity: Fuzzy name matching with high threshold (medium confidence)
*/
import type { ContactStore } from "./store.js";
import type { LinkConfidence, LinkSuggestion, PlatformIdentity } from "./types.js";
/**
* Calculate Levenshtein distance between two strings.
*/
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0]![j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
matrix[i]![j] = Math.min(
matrix[i - 1]![j]! + 1, // deletion
matrix[i]![j - 1]! + 1, // insertion
matrix[i - 1]![j - 1]! + cost, // substitution
);
}
}
return matrix[b.length]![a.length]!;
}
/**
* Calculate similarity score between two strings (0-1).
* 1 = identical, 0 = completely different.
*/
function calculateSimilarity(a: string, b: string): number {
if (!a || !b) return 0;
// Normalize: lowercase, trim, remove extra whitespace
const normalizedA = a.toLowerCase().trim().replace(/\s+/g, " ");
const normalizedB = b.toLowerCase().trim().replace(/\s+/g, " ");
if (normalizedA === normalizedB) return 1;
if (normalizedA.length === 0 || normalizedB.length === 0) return 0;
const maxLength = Math.max(normalizedA.length, normalizedB.length);
const distance = levenshteinDistance(normalizedA, normalizedB);
return 1 - distance / maxLength;
}
/**
* Normalize a phone number for comparison.
* Strips non-digit characters except leading +.
*/
function normalizePhone(phone: string | null): string | null {
if (!phone) return null;
// Keep only digits and leading +
let normalized = phone.replace(/[^+\d]/g, "");
// Ensure it starts with +
if (!normalized.startsWith("+") && normalized.length >= 10) {
// Assume US if 10 digits without country code
if (normalized.length === 10) {
normalized = `+1${normalized}`;
} else {
normalized = `+${normalized}`;
}
}
return normalized.length >= 10 ? normalized : null;
}
/**
* Find link suggestions based on phone number matching.
* This is the highest confidence match since phone numbers are unique identifiers.
*/
export function findPhoneMatches(store: ContactStore): LinkSuggestion[] {
const suggestions: LinkSuggestion[] = [];
// Get all contacts with their identities
const contacts = store.listContacts();
// Build phone → identities map
const phoneToIdentities = new Map<string, PlatformIdentity[]>();
for (const contact of contacts) {
const withIdentities = store.getContactWithIdentities(contact.canonicalId);
if (!withIdentities) continue;
for (const identity of withIdentities.identities) {
const phone = normalizePhone(identity.phone);
if (!phone) continue;
const existing = phoneToIdentities.get(phone) ?? [];
existing.push(identity);
phoneToIdentities.set(phone, existing);
}
}
// Find identities with same phone but different contacts
for (const [_phone, identities] of phoneToIdentities) {
if (identities.length < 2) continue;
// Group by contact ID
const byContact = new Map<string, PlatformIdentity[]>();
for (const identity of identities) {
const existing = byContact.get(identity.contactId) ?? [];
existing.push(identity);
byContact.set(identity.contactId, existing);
}
// If all belong to same contact, already linked
if (byContact.size < 2) continue;
// Create suggestions for each pair of contacts
const contactIds = Array.from(byContact.keys());
for (let i = 0; i < contactIds.length; i++) {
for (let j = i + 1; j < contactIds.length; j++) {
const sourceIdentity = byContact.get(contactIds[i]!)![0]!;
const targetIdentity = byContact.get(contactIds[j]!)![0]!;
suggestions.push({
sourceIdentity,
targetIdentity,
reason: "phone_match",
confidence: "high",
score: 1.0,
});
}
}
}
return suggestions;
}
/**
* Find link suggestions based on name similarity.
* Uses fuzzy matching with a configurable threshold.
*/
export function findNameMatches(
store: ContactStore,
options: { minScore?: number } = {},
): LinkSuggestion[] {
const { minScore = 0.85 } = options;
const suggestions: LinkSuggestion[] = [];
// Get all contacts with their identities
const contacts = store.listContacts();
const contactsWithIdentities = contacts
.map((c) => store.getContactWithIdentities(c.canonicalId))
.filter((c): c is NonNullable<typeof c> => c !== null);
// Compare each pair of contacts
for (let i = 0; i < contactsWithIdentities.length; i++) {
for (let j = i + 1; j < contactsWithIdentities.length; j++) {
const contactA = contactsWithIdentities[i]!;
const contactB = contactsWithIdentities[j]!;
// Skip if already same contact
if (contactA.canonicalId === contactB.canonicalId) continue;
// Compare display names
const similarity = calculateSimilarity(contactA.displayName, contactB.displayName);
if (similarity >= minScore) {
// Get representative identities for the suggestion
const sourceIdentity = contactA.identities[0];
const targetIdentity = contactB.identities[0];
if (sourceIdentity && targetIdentity) {
suggestions.push({
sourceIdentity,
targetIdentity,
reason: "name_similarity",
confidence: similarity >= 0.95 ? "high" : "medium",
score: similarity,
});
}
}
// Also compare identity display names
for (const identityA of contactA.identities) {
for (const identityB of contactB.identities) {
if (!identityA.displayName || !identityB.displayName) continue;
const identitySimilarity = calculateSimilarity(
identityA.displayName,
identityB.displayName,
);
if (identitySimilarity >= minScore) {
// Avoid duplicate suggestions
const alreadySuggested = suggestions.some(
(s) =>
(s.sourceIdentity.id === identityA.id && s.targetIdentity.id === identityB.id) ||
(s.sourceIdentity.id === identityB.id && s.targetIdentity.id === identityA.id),
);
if (!alreadySuggested) {
suggestions.push({
sourceIdentity: identityA,
targetIdentity: identityB,
reason: "name_similarity",
confidence: identitySimilarity >= 0.95 ? "high" : "medium",
score: identitySimilarity,
});
}
}
}
}
}
}
return suggestions;
}
/**
* Find all link suggestions across all heuristics.
* Returns suggestions sorted by confidence and score.
*/
export function findLinkSuggestions(
store: ContactStore,
options: { minNameScore?: number } = {},
): LinkSuggestion[] {
const phoneSuggestions = findPhoneMatches(store);
const nameSuggestions = findNameMatches(store, { minScore: options.minNameScore });
// Combine and sort by confidence (high first) then score
const all = [...phoneSuggestions, ...nameSuggestions];
const confidenceOrder: Record<LinkConfidence, number> = {
high: 3,
medium: 2,
low: 1,
};
return all.sort((a, b) => {
const confDiff = confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
if (confDiff !== 0) return confDiff;
return b.score - a.score;
});
}
/**
* Link two contacts by merging all identities into the primary contact.
* The secondary contact is deleted.
*/
export function linkContacts(
store: ContactStore,
primaryContactId: string,
secondaryContactId: string,
): { success: boolean; error?: string } {
const primary = store.getContactWithIdentities(primaryContactId);
const secondary = store.getContactWithIdentities(secondaryContactId);
if (!primary) {
return { success: false, error: `Primary contact not found: ${primaryContactId}` };
}
if (!secondary) {
return { success: false, error: `Secondary contact not found: ${secondaryContactId}` };
}
// Move all identities from secondary to primary
for (const identity of secondary.identities) {
store.addIdentity({
contactId: primary.canonicalId,
platform: identity.platform,
platformId: identity.platformId,
username: identity.username,
phone: identity.phone,
displayName: identity.displayName,
lastSeenAt: identity.lastSeenAt,
});
store.updateMessageContactForIdentity({
contactId: primary.canonicalId,
platform: identity.platform,
senderId: identity.platformId,
});
}
// Merge aliases
const newAliases = [...primary.aliases];
if (!newAliases.includes(secondary.displayName)) {
newAliases.push(secondary.displayName);
}
for (const alias of secondary.aliases) {
if (!newAliases.includes(alias)) {
newAliases.push(alias);
}
}
store.updateContact(primary.canonicalId, { aliases: newAliases });
// Delete secondary contact
store.deleteContact(secondaryContactId);
return { success: true };
}
/**
* Unlink a platform identity from its current contact.
* Creates a new contact for the identity.
*/
export function unlinkIdentity(
store: ContactStore,
platform: string,
platformId: string,
): { success: boolean; newContactId?: string; error?: string } {
const identity = store.getIdentityByPlatformId(platform, platformId);
if (!identity) {
return { success: false, error: `Identity not found: ${platform}:${platformId}` };
}
const currentContact = store.getContactWithIdentities(identity.contactId);
if (!currentContact) {
return { success: false, error: `Contact not found: ${identity.contactId}` };
}
// If this is the only identity, nothing to unlink
if (currentContact.identities.length === 1) {
return { success: false, error: "Cannot unlink the only identity from a contact" };
}
// Create new contact for this identity
const displayName = identity.displayName || identity.username || identity.platformId;
const newContact = store.createContact(displayName);
// Move the identity to the new contact
store.addIdentity({
contactId: newContact.canonicalId,
platform: identity.platform,
platformId: identity.platformId,
username: identity.username,
phone: identity.phone,
displayName: identity.displayName,
lastSeenAt: identity.lastSeenAt,
});
store.updateMessageContactForIdentity({
contactId: newContact.canonicalId,
platform: identity.platform,
senderId: identity.platformId,
});
return { success: true, newContactId: newContact.canonicalId };
}
/**
* Auto-apply high-confidence link suggestions.
* Returns the number of links applied.
*/
export function autoLinkHighConfidence(store: ContactStore): {
linked: number;
suggestions: LinkSuggestion[];
} {
const suggestions = findLinkSuggestions(store);
const highConfidence = suggestions.filter((s) => s.confidence === "high");
let linked = 0;
const processedContacts = new Set<string>();
for (const suggestion of highConfidence) {
const sourceContactId = suggestion.sourceIdentity.contactId;
const targetContactId = suggestion.targetIdentity.contactId;
// Skip if either contact was already processed (merged into another)
if (processedContacts.has(sourceContactId) || processedContacts.has(targetContactId)) {
continue;
}
// Skip if same contact (already linked)
if (sourceContactId === targetContactId) {
continue;
}
const result = linkContacts(store, sourceContactId, targetContactId);
if (result.success) {
linked++;
processedContacts.add(targetContactId);
}
}
return { linked, suggestions: highConfidence };
}

View File

@@ -1,125 +0,0 @@
import type { DatabaseSync } from "node:sqlite";
/**
* Ensures the contact store schema is created in the SQLite database.
* Creates tables for contacts, platform identities, and indexed messages.
*/
export function ensureContactStoreSchema(db: DatabaseSync): {
ftsAvailable: boolean;
ftsError?: string;
} {
// Unified contacts table - canonical contact records
db.exec(`
CREATE TABLE IF NOT EXISTS contacts (
canonical_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Platform identities table - links platform-specific IDs to canonical contacts
db.exec(`
CREATE TABLE IF NOT EXISTS platform_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id TEXT NOT NULL REFERENCES contacts(canonical_id) ON DELETE CASCADE,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
username TEXT,
phone TEXT,
display_name TEXT,
last_seen_at INTEGER,
UNIQUE(platform, platform_id)
);
`);
// Indexed messages table - for cross-platform message search
db.exec(`
CREATE TABLE IF NOT EXISTS indexed_messages (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
contact_id TEXT REFERENCES contacts(canonical_id) ON DELETE SET NULL,
platform TEXT NOT NULL,
sender_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
embedding TEXT
);
`);
// Indexes for efficient queries
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_contact_id ON platform_identities(contact_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_platform ON platform_identities(platform);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_phone ON platform_identities(phone);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_username ON platform_identities(username);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_contact_id ON indexed_messages(contact_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_platform ON indexed_messages(platform);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_sender_id ON indexed_messages(sender_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_channel_id ON indexed_messages(channel_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_timestamp ON indexed_messages(timestamp);`,
);
// Full-text search virtual table for message content
let ftsAvailable = false;
let ftsError: string | undefined;
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
id UNINDEXED,
contact_id UNINDEXED,
platform UNINDEXED,
sender_id UNINDEXED,
channel_id UNINDEXED,
timestamp UNINDEXED
);
`);
ftsAvailable = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ftsAvailable = false;
ftsError = message;
}
// Migration helper - add columns if they don't exist
ensureColumn(db, "contacts", "aliases", "TEXT NOT NULL DEFAULT '[]'");
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
}
/**
* Ensures a column exists on a table, adding it if missing.
*/
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (rows.some((row) => row.name === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
/**
* Drop all contact store tables (for testing/reset).
*/
export function dropContactStoreTables(db: DatabaseSync): void {
db.exec(`DROP TABLE IF EXISTS messages_fts;`);
db.exec(`DROP TABLE IF EXISTS indexed_messages;`);
db.exec(`DROP TABLE IF EXISTS platform_identities;`);
db.exec(`DROP TABLE IF EXISTS contacts;`);
}

View File

@@ -1,547 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import type { Platform } from "./types.js";
describe("ContactStore", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("contacts", () => {
it("creates a contact with generated canonical ID", () => {
const contact = store.createContact("Sarah Jones");
expect(contact.canonicalId).toMatch(/^sarah-jones-[a-f0-9]{8}$/);
expect(contact.displayName).toBe("Sarah Jones");
expect(contact.aliases).toEqual([]);
expect(contact.createdAt).toBeGreaterThan(0);
expect(contact.updatedAt).toBe(contact.createdAt);
});
it("creates a contact with aliases", () => {
const contact = store.createContact("Bob Smith", ["Bobby", "Bob S"]);
expect(contact.aliases).toEqual(["Bobby", "Bob S"]);
});
it("retrieves a contact by canonical ID", () => {
const created = store.createContact("Alice Doe");
const retrieved = store.getContact(created.canonicalId);
expect(retrieved).toEqual(created);
});
it("returns null for non-existent contact", () => {
const retrieved = store.getContact("non-existent-id");
expect(retrieved).toBeNull();
});
it("updates contact display name", () => {
const contact = store.createContact("Old Name");
const success = store.updateContact(contact.canonicalId, { displayName: "New Name" });
expect(success).toBe(true);
const updated = store.getContact(contact.canonicalId);
expect(updated?.displayName).toBe("New Name");
// updatedAt should be >= createdAt (may be same millisecond in fast tests)
expect(updated?.updatedAt).toBeGreaterThanOrEqual(contact.updatedAt);
});
it("updates contact aliases", () => {
const contact = store.createContact("Test User");
store.updateContact(contact.canonicalId, { aliases: ["Tester", "TU"] });
const updated = store.getContact(contact.canonicalId);
expect(updated?.aliases).toEqual(["Tester", "TU"]);
});
it("returns false when updating non-existent contact", () => {
const success = store.updateContact("fake-id", { displayName: "Test" });
expect(success).toBe(false);
});
it("deletes a contact", () => {
const contact = store.createContact("To Delete");
expect(store.getContact(contact.canonicalId)).not.toBeNull();
const deleted = store.deleteContact(contact.canonicalId);
expect(deleted).toBe(true);
expect(store.getContact(contact.canonicalId)).toBeNull();
});
it("returns false when deleting non-existent contact", () => {
const deleted = store.deleteContact("fake-id");
expect(deleted).toBe(false);
});
it("lists all contacts", () => {
store.createContact("Alpha User");
store.createContact("Beta User");
store.createContact("Gamma User");
const contacts = store.listContacts();
expect(contacts.length).toBe(3);
});
it("lists contacts with query filter", () => {
store.createContact("John Doe");
store.createContact("Jane Doe", ["Janey"]);
store.createContact("Bob Smith");
const contacts = store.listContacts({ query: "doe" });
expect(contacts.length).toBe(2);
});
it("lists contacts with limit", () => {
store.createContact("User 1");
store.createContact("User 2");
store.createContact("User 3");
const contacts = store.listContacts({ limit: 2 });
expect(contacts.length).toBe(2);
});
});
describe("platform identities", () => {
it("adds a platform identity to a contact", () => {
const contact = store.createContact("Test User");
const identity = store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "123456789",
username: "testuser",
phone: null,
displayName: "Test User",
lastSeenAt: null,
});
expect(identity.id).toBeGreaterThan(0);
expect(identity.contactId).toBe(contact.canonicalId);
expect(identity.platform).toBe("telegram");
expect(identity.platformId).toBe("123456789");
});
it("retrieves identities by contact ID", () => {
const contact = store.createContact("Multi Platform User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-123",
username: "teleuser",
phone: null,
displayName: "Tele User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-456",
username: "discorduser",
phone: null,
displayName: "Discord User",
lastSeenAt: null,
});
const identities = store.getIdentitiesByContact(contact.canonicalId);
expect(identities.length).toBe(2);
expect(identities.map((i) => i.platform)).toContain("telegram");
expect(identities.map((i) => i.platform)).toContain("discord");
});
it("retrieves identity by platform and platform ID", () => {
const contact = store.createContact("User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "+14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "WA User",
lastSeenAt: null,
});
const identity = store.getIdentityByPlatformId("whatsapp", "+14155551234@s.whatsapp.net");
expect(identity).not.toBeNull();
expect(identity?.contactId).toBe(contact.canonicalId);
expect(identity?.phone).toBe("+14155551234");
});
it("returns null for non-existent identity", () => {
const identity = store.getIdentityByPlatformId("telegram", "fake-id");
expect(identity).toBeNull();
});
it("finds identities by phone number", () => {
const contact = store.createContact("Phone User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-jid-1",
username: null,
phone: "+14155551234",
displayName: "WA User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "signal",
platformId: "signal-uuid-1",
username: null,
phone: "+14155551234",
displayName: "Signal User",
lastSeenAt: null,
});
const identities = store.findIdentitiesByPhone("+14155551234");
expect(identities.length).toBe(2);
});
it("updates last seen timestamp", () => {
const contact = store.createContact("User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-id",
username: "user",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.updateIdentityLastSeen("telegram", "tg-id");
const identity = store.getIdentityByPlatformId("telegram", "tg-id");
expect(identity?.lastSeenAt).toBeGreaterThan(0);
});
it("resolves platform sender to contact ID", () => {
const contact = store.createContact("Resolvable User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "discord-user-id",
username: "discorduser",
phone: null,
displayName: "Discord Display",
lastSeenAt: null,
});
const resolved = store.resolveContact("discord", "discord-user-id");
expect(resolved).toBe(contact.canonicalId);
});
it("returns null when resolving unknown sender", () => {
const resolved = store.resolveContact("telegram", "unknown-id");
expect(resolved).toBeNull();
});
});
describe("contact search", () => {
it("searches contacts by display name", () => {
store.createContact("Alice Wonderland");
store.createContact("Bob Builder");
store.createContact("Alice Cooper");
const results = store.searchContacts("alice");
expect(results.length).toBe(2);
expect(results.map((r) => r.displayName)).toContain("Alice Wonderland");
expect(results.map((r) => r.displayName)).toContain("Alice Cooper");
});
it("searches contacts by username", () => {
const contact = store.createContact("John Doe");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe",
phone: null,
displayName: null,
lastSeenAt: null,
});
const results = store.searchContacts("johndoe");
expect(results.length).toBe(1);
expect(results[0]?.displayName).toBe("John Doe");
});
it("returns contact with all identities", () => {
const contact = store.createContact("Multi User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-multi",
username: "multi_tg",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "slack",
platformId: "slack-multi",
username: "multi_slack",
phone: null,
displayName: null,
lastSeenAt: null,
});
const results = store.searchContacts("multi");
expect(results.length).toBe(1);
expect(results[0]?.identities.length).toBe(2);
});
});
describe("message indexing", () => {
it("indexes a message", () => {
store.indexMessage({
id: "msg-1",
content: "Hello, this is a test message",
platform: "telegram" as Platform,
senderId: "sender-123",
channelId: "channel-456",
timestamp: Date.now(),
});
const stats = store.getStats();
expect(stats.messages).toBe(1);
});
it("links message to contact when sender is resolved", () => {
const contact = store.createContact("Known Sender");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "sender-known",
username: "known",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "msg-linked",
content: "Message from known sender",
platform: "telegram" as Platform,
senderId: "sender-known",
channelId: "chat-1",
timestamp: Date.now(),
});
// Search should find the message
const results = store.searchMessages({ query: "known sender" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
});
it("filters messages by canonical contact id", () => {
const contact = store.createContact("Filter Sender");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "sender-filter",
username: "filter",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "msg-filter",
content: "Message for canonical filter",
platform: "telegram" as Platform,
senderId: "sender-filter",
channelId: "chat-1",
timestamp: Date.now(),
});
const results = store.searchMessages({
query: "canonical filter",
from: contact.canonicalId,
});
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
});
it("searches messages by content", () => {
store.indexMessage({
id: "msg-search-1",
content: "The quick brown fox jumps over the lazy dog",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-search-2",
content: "A slow red turtle crawls under the fence",
platform: "discord" as Platform,
senderId: "s2",
channelId: "c2",
timestamp: Date.now(),
});
const results = store.searchMessages({ query: "quick fox" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.id).toBe("msg-search-1");
});
it("filters messages by platform", () => {
store.indexMessage({
id: "msg-tg",
content: "Telegram message about deadlines",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-dc",
content: "Discord message about deadlines",
platform: "discord" as Platform,
senderId: "s2",
channelId: "c2",
timestamp: Date.now(),
});
const results = store.searchMessages({
query: "deadlines",
platforms: ["telegram"],
});
expect(results.length).toBe(1);
expect(results[0]?.message.platform).toBe("telegram");
});
it("filters messages by timestamp range", () => {
const now = Date.now();
store.indexMessage({
id: "msg-old",
content: "Old message about projects",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: now - 7 * 24 * 60 * 60 * 1000, // 7 days ago
});
store.indexMessage({
id: "msg-new",
content: "New message about projects",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: now,
});
const results = store.searchMessages({
query: "projects",
since: now - 24 * 60 * 60 * 1000, // Last 24 hours
});
expect(results.length).toBe(1);
expect(results[0]?.message.id).toBe("msg-new");
});
it("creates snippet with context", () => {
store.indexMessage({
id: "msg-snippet",
content:
"This is a very long message that contains the word deadline somewhere in the middle and continues with more text after that point to test the snippet creation functionality.",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
const results = store.searchMessages({ query: "deadline" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.snippet).toContain("deadline");
expect(results[0]?.snippet.length).toBeLessThan(250);
});
});
describe("getContactWithIdentities", () => {
it("returns contact with all platform identities", () => {
const contact = store.createContact("Full Contact");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-full",
username: "full_tg",
phone: "+14155551111",
displayName: "TG Full",
lastSeenAt: Date.now(),
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-full",
username: null,
phone: "+14155551111",
displayName: "WA Full",
lastSeenAt: null,
});
const result = store.getContactWithIdentities(contact.canonicalId);
expect(result).not.toBeNull();
expect(result?.displayName).toBe("Full Contact");
expect(result?.identities.length).toBe(2);
});
it("returns null for non-existent contact", () => {
const result = store.getContactWithIdentities("fake-id");
expect(result).toBeNull();
});
});
describe("statistics", () => {
it("returns accurate stats", () => {
const contact1 = store.createContact("Stats User 1");
const contact2 = store.createContact("Stats User 2");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-stats-1",
username: "stats1",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact1.canonicalId,
platform: "discord",
platformId: "dc-stats-1",
username: "stats1dc",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact2.canonicalId,
platform: "telegram",
platformId: "tg-stats-2",
username: "stats2",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "stats-msg-1",
content: "Stats test message",
platform: "telegram" as Platform,
senderId: "tg-stats-1",
channelId: "c1",
timestamp: Date.now(),
});
const stats = store.getStats();
expect(stats.contacts).toBe(2);
expect(stats.identities).toBe(3);
expect(stats.messages).toBe(1);
expect(stats.platforms.telegram).toBe(2);
expect(stats.platforms.discord).toBe(1);
});
});
});

View File

@@ -1,838 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync, StatementSync } from "node:sqlite";
import { requireNodeSqlite } from "../sqlite.js";
import { ensureContactStoreSchema } from "./schema.js";
import type {
Contact,
ContactSearchOptions,
ContactWithIdentities,
IndexedMessage,
MessageSearchOptions,
MessageSearchResult,
Platform,
PlatformIdentity,
PlatformIdentityInput,
} from "./types.js";
const CONTACTS_DB_FILENAME = "contacts.sqlite";
/**
* ContactStore manages the unified contact graph and message index.
*
* Key capabilities:
* - Store and retrieve canonical contacts
* - Link platform-specific identities to canonical contacts
* - Index messages for cross-platform search
* - Resolve sender identities to canonical contacts
*/
export class ContactStore {
private db: DatabaseSync;
private ftsAvailable: boolean;
// Prepared statements for performance
private stmtInsertContact: StatementSync;
private stmtUpdateContact: StatementSync;
private stmtGetContact: StatementSync;
private stmtDeleteContact: StatementSync;
private stmtInsertIdentity: StatementSync;
private stmtGetIdentitiesByContact: StatementSync;
private stmtGetIdentityByPlatformId: StatementSync;
private stmtUpdateIdentityLastSeen: StatementSync;
private stmtInsertMessage: StatementSync;
private stmtInsertMessageFts: StatementSync | null;
private stmtUpdateMessageContactBySender: StatementSync;
private stmtUpdateMessageFtsContactBySender: StatementSync | null;
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
this.db = db;
this.ftsAvailable = ftsAvailable;
// Prepare statements
this.stmtInsertContact = db.prepare(`
INSERT INTO contacts (canonical_id, display_name, aliases, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
this.stmtUpdateContact = db.prepare(`
UPDATE contacts SET display_name = ?, aliases = ?, updated_at = ? WHERE canonical_id = ?
`);
this.stmtGetContact = db.prepare(`
SELECT canonical_id, display_name, aliases, created_at, updated_at
FROM contacts WHERE canonical_id = ?
`);
this.stmtDeleteContact = db.prepare(`DELETE FROM contacts WHERE canonical_id = ?`);
this.stmtInsertIdentity = db.prepare(`
INSERT OR REPLACE INTO platform_identities
(contact_id, platform, platform_id, username, phone, display_name, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
this.stmtGetIdentitiesByContact = db.prepare(`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE contact_id = ?
`);
this.stmtGetIdentityByPlatformId = db.prepare(`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE platform = ? AND platform_id = ?
`);
this.stmtUpdateIdentityLastSeen = db.prepare(`
UPDATE platform_identities SET last_seen_at = ? WHERE platform = ? AND platform_id = ?
`);
this.stmtInsertMessage = db.prepare(`
INSERT OR REPLACE INTO indexed_messages
(id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
this.stmtInsertMessageFts = ftsAvailable
? db.prepare(`
INSERT OR REPLACE INTO messages_fts
(content, id, contact_id, platform, sender_id, channel_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
: null;
this.stmtUpdateMessageContactBySender = db.prepare(`
UPDATE indexed_messages SET contact_id = ? WHERE platform = ? AND sender_id = ?
`);
this.stmtUpdateMessageFtsContactBySender = ftsAvailable
? db.prepare(`
UPDATE messages_fts SET contact_id = ? WHERE platform = ? AND sender_id = ?
`)
: null;
}
/**
* Open or create a contact store database.
*/
static open(params: { dbPath?: string; stateDir?: string } = {}): ContactStore {
const nodeSqlite = requireNodeSqlite();
const resolvedPath =
params.dbPath ??
(params.stateDir ? path.join(params.stateDir, "contacts", CONTACTS_DB_FILENAME) : undefined);
if (!resolvedPath) {
throw new Error("ContactStore.open requires dbPath or stateDir");
}
// Ensure directory exists
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new nodeSqlite.DatabaseSync(resolvedPath);
// Enable foreign keys
db.exec("PRAGMA foreign_keys = ON;");
// Set up schema
const { ftsAvailable } = ensureContactStoreSchema(db);
return new ContactStore(db, ftsAvailable);
}
/**
* Create a new in-memory store (for testing).
*/
static openInMemory(): ContactStore {
const nodeSqlite = requireNodeSqlite();
const db = new nodeSqlite.DatabaseSync(":memory:");
db.exec("PRAGMA foreign_keys = ON;");
const { ftsAvailable } = ensureContactStoreSchema(db);
return new ContactStore(db, ftsAvailable);
}
/**
* Close the database connection.
*/
close(): void {
this.db.close();
}
// ─────────────────────────────────────────────────────────────────────────────
// CONTACT OPERATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Generate a canonical ID from a display name.
*/
private generateCanonicalId(displayName: string): string {
const slug = displayName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${slug || "contact"}-${suffix}`;
}
/**
* Create a new canonical contact.
*/
createContact(displayName: string, aliases: string[] = []): Contact {
const now = Date.now();
const canonicalId = this.generateCanonicalId(displayName);
this.stmtInsertContact.run(canonicalId, displayName, JSON.stringify(aliases), now, now);
return {
canonicalId,
displayName,
aliases,
createdAt: now,
updatedAt: now,
};
}
/**
* Get a contact by canonical ID.
*/
getContact(canonicalId: string): Contact | null {
const row = this.stmtGetContact.get(canonicalId) as
| {
canonical_id: string;
display_name: string;
aliases: string;
created_at: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
canonicalId: row.canonical_id,
displayName: row.display_name,
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Update a contact's display name and/or aliases.
*/
updateContact(
canonicalId: string,
updates: { displayName?: string; aliases?: string[] },
): boolean {
const existing = this.getContact(canonicalId);
if (!existing) return false;
const displayName = updates.displayName ?? existing.displayName;
const aliases = updates.aliases ?? existing.aliases;
const now = Date.now();
this.stmtUpdateContact.run(displayName, JSON.stringify(aliases), now, canonicalId);
return true;
}
/**
* Delete a contact and all its platform identities.
*/
deleteContact(canonicalId: string): boolean {
const result = this.stmtDeleteContact.run(canonicalId);
return result.changes > 0;
}
/**
* List all contacts with optional filtering.
*/
listContacts(options: ContactSearchOptions = {}): Contact[] {
let sql = `SELECT canonical_id, display_name, aliases, created_at, updated_at FROM contacts`;
const params: (string | number)[] = [];
const conditions: string[] = [];
if (options.query) {
conditions.push(`(display_name LIKE ? OR aliases LIKE ?)`);
const pattern = `%${options.query}%`;
params.push(pattern, pattern);
}
if (options.platform) {
conditions.push(
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
);
params.push(this.normalizePlatform(options.platform));
}
if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(" AND ")}`;
}
sql += ` ORDER BY updated_at DESC`;
if (options.limit) {
sql += ` LIMIT ?`;
params.push(options.limit);
}
const stmt = this.db.prepare(sql);
const rows = stmt.all(...params) as Array<{
canonical_id: string;
display_name: string;
aliases: string;
created_at: number;
updated_at: number;
}>;
return rows.map((row) => ({
canonicalId: row.canonical_id,
displayName: row.display_name,
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
/**
* Get a contact with all its platform identities.
*/
getContactWithIdentities(canonicalId: string): ContactWithIdentities | null {
const contact = this.getContact(canonicalId);
if (!contact) return null;
const identities = this.getIdentitiesByContact(canonicalId);
return { ...contact, identities };
}
/**
* Search contacts by name, alias, or username.
*/
searchContacts(query: string, limit = 10): ContactWithIdentities[] {
const pattern = `%${query}%`;
// Search in contacts table
const contactRows = this.db
.prepare(
`
SELECT DISTINCT c.canonical_id
FROM contacts c
LEFT JOIN platform_identities pi ON c.canonical_id = pi.contact_id
WHERE c.display_name LIKE ?
OR c.aliases LIKE ?
OR pi.username LIKE ?
OR pi.display_name LIKE ?
ORDER BY c.updated_at DESC
LIMIT ?
`,
)
.all(pattern, pattern, pattern, pattern, limit) as Array<{ canonical_id: string }>;
return contactRows
.map((row) => this.getContactWithIdentities(row.canonical_id))
.filter((c): c is ContactWithIdentities => c !== null);
}
// ─────────────────────────────────────────────────────────────────────────────
// PLATFORM IDENTITY OPERATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Add a platform identity to a contact.
*/
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
const platform = this.normalizePlatform(input.platform);
const phone = input.phone ? this.normalizePhone(input.phone) : null;
this.stmtInsertIdentity.run(
input.contactId,
platform,
input.platformId,
input.username,
phone,
input.displayName,
input.lastSeenAt,
);
// Get the inserted row to return
const identity = this.getIdentityByPlatformId(platform, input.platformId);
if (!identity) {
throw new Error(
`Failed to retrieve inserted identity: ${platform}:${input.platformId}`,
);
}
return identity;
}
/**
* Get all platform identities for a contact.
*/
getIdentitiesByContact(contactId: string): PlatformIdentity[] {
const rows = this.stmtGetIdentitiesByContact.all(contactId) as Array<{
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}>;
return rows.map((row) => ({
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
}));
}
/**
* Get a platform identity by platform and platform-specific ID.
*/
getIdentityByPlatformId(platform: string, platformId: string): PlatformIdentity | null {
const normalizedPlatform = this.normalizePlatform(platform);
const row = this.stmtGetIdentityByPlatformId.get(normalizedPlatform, platformId) as
| {
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}
| undefined;
if (!row) return null;
return {
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
};
}
/**
* Find identities by phone number across all platforms.
*/
findIdentitiesByPhone(phone: string): PlatformIdentity[] {
const normalized = this.normalizePhone(phone);
const rows = this.db
.prepare(
`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE phone = ?
`,
)
.all(normalized) as Array<{
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}>;
return rows.map((row) => ({
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
}));
}
/**
* Update last seen timestamp for a platform identity.
*/
updateIdentityLastSeen(platform: string, platformId: string): void {
const normalizedPlatform = this.normalizePlatform(platform);
this.stmtUpdateIdentityLastSeen.run(Date.now(), normalizedPlatform, platformId);
}
/**
* Resolve a platform sender to a canonical contact ID.
* Returns null if the sender is not in the contact graph.
*/
resolveContact(platform: string, platformId: string): string | null {
const identity = this.getIdentityByPlatformId(platform, platformId);
return identity?.contactId ?? null;
}
/**
* Reassign indexed messages for a platform identity to a contact.
*/
updateMessageContactForIdentity(params: {
contactId: string | null;
platform: string;
senderId: string;
}): void {
const platform = this.normalizePlatform(params.platform);
this.stmtUpdateMessageContactBySender.run(params.contactId, platform, params.senderId);
this.stmtUpdateMessageFtsContactBySender?.run(params.contactId, platform, params.senderId);
}
/**
* Normalize a phone number to E.164 format.
*/
private normalizePhone(phone: string): string {
// Strip everything except digits and leading +
let normalized = phone.replace(/[^+\d]/g, "");
// Ensure it starts with +
if (!normalized.startsWith("+") && normalized.length >= 10) {
// Assume US if no country code and 10 digits
if (normalized.length === 10) {
normalized = `+1${normalized}`;
} else {
normalized = `+${normalized}`;
}
}
return normalized;
}
private normalizePlatform(platform: string): string {
return platform.trim().toLowerCase();
}
// ─────────────────────────────────────────────────────────────────────────────
// MESSAGE INDEXING
// ─────────────────────────────────────────────────────────────────────────────
/**
* Index a message for cross-platform search.
*/
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
const platform = this.normalizePlatform(message.platform);
// Try to resolve the sender to a canonical contact
const contactId = this.resolveContact(platform, message.senderId);
this.stmtInsertMessage.run(
message.id,
message.content,
contactId,
platform,
message.senderId,
message.channelId,
message.timestamp,
message.embedding ?? null,
);
// Also insert into FTS table
if (this.stmtInsertMessageFts && message.content) {
this.stmtInsertMessageFts.run(
message.content,
message.id,
contactId,
platform,
message.senderId,
message.channelId,
message.timestamp,
);
}
// Update last seen timestamp for the sender
if (contactId) {
this.updateIdentityLastSeen(platform, message.senderId);
}
}
/**
* Search indexed messages.
*/
searchMessages(options: MessageSearchOptions): MessageSearchResult[] {
const results: MessageSearchResult[] = [];
if (!options.query) return results;
const normalizedPlatforms = options.platforms?.map((platform) =>
this.normalizePlatform(platform),
);
// Resolve "from" filter to contact IDs
let contactIds: string[] | null = null;
if (options.from) {
const normalized = options.from.trim();
const exact = normalized ? this.getContact(normalized) : null;
const matches = this.searchContacts(options.from, 10);
const ids = new Set<string>(matches.map((m) => m.canonicalId));
if (exact) ids.add(exact.canonicalId);
if (ids.size === 0) {
return results;
}
contactIds = [...ids];
}
// Build query based on FTS availability
const normalizedOptions = {
...options,
platforms: normalizedPlatforms,
};
if (this.ftsAvailable) {
return this.searchMessagesFts(normalizedOptions, contactIds);
}
return this.searchMessagesLike(normalizedOptions, contactIds);
}
private searchMessagesFts(
options: MessageSearchOptions,
contactIds: string[] | null,
): MessageSearchResult[] {
let sql = `
SELECT m.id, m.content, m.contact_id, m.platform, m.sender_id, m.channel_id, m.timestamp, m.embedding,
bm25(messages_fts) as score
FROM messages_fts fts
JOIN indexed_messages m ON fts.id = m.id
WHERE messages_fts MATCH ?
`;
const params: (string | number)[] = [options.query];
if (contactIds && contactIds.length > 0) {
const placeholders = contactIds.map(() => "?").join(",");
sql += ` AND m.contact_id IN (${placeholders})`;
params.push(...contactIds);
}
if (options.platforms && options.platforms.length > 0) {
const placeholders = options.platforms.map(() => "?").join(",");
sql += ` AND m.platform IN (${placeholders})`;
params.push(...options.platforms);
}
if (options.channelId) {
sql += ` AND m.channel_id = ?`;
params.push(options.channelId);
}
if (options.since) {
sql += ` AND m.timestamp >= ?`;
params.push(options.since);
}
if (options.until) {
sql += ` AND m.timestamp <= ?`;
params.push(options.until);
}
sql += ` ORDER BY score LIMIT ?`;
params.push(options.limit ?? 50);
const rows = this.db.prepare(sql).all(...params) as Array<{
id: string;
content: string;
contact_id: string | null;
platform: string;
sender_id: string;
channel_id: string;
timestamp: number;
embedding: string | null;
score: number;
}>;
return rows.map((row) => {
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
return {
message: {
id: row.id,
content: row.content,
contactId: row.contact_id,
platform: row.platform as Platform,
senderId: row.sender_id,
channelId: row.channel_id,
timestamp: row.timestamp,
embedding: row.embedding,
},
contact,
score: Math.abs(row.score), // BM25 returns negative scores
snippet: this.createSnippet(row.content, options.query),
};
});
}
private searchMessagesLike(
options: MessageSearchOptions,
contactIds: string[] | null,
): MessageSearchResult[] {
let sql = `
SELECT id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding
FROM indexed_messages
WHERE content LIKE ?
`;
const params: (string | number)[] = [`%${options.query}%`];
if (contactIds && contactIds.length > 0) {
const placeholders = contactIds.map(() => "?").join(",");
sql += ` AND contact_id IN (${placeholders})`;
params.push(...contactIds);
}
if (options.platforms && options.platforms.length > 0) {
const placeholders = options.platforms.map(() => "?").join(",");
sql += ` AND platform IN (${placeholders})`;
params.push(...options.platforms);
}
if (options.channelId) {
sql += ` AND channel_id = ?`;
params.push(options.channelId);
}
if (options.since) {
sql += ` AND timestamp >= ?`;
params.push(options.since);
}
if (options.until) {
sql += ` AND timestamp <= ?`;
params.push(options.until);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
params.push(options.limit ?? 50);
const rows = this.db.prepare(sql).all(...params) as Array<{
id: string;
content: string;
contact_id: string | null;
platform: string;
sender_id: string;
channel_id: string;
timestamp: number;
embedding: string | null;
}>;
return rows.map((row) => {
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
return {
message: {
id: row.id,
content: row.content,
contactId: row.contact_id,
platform: row.platform as Platform,
senderId: row.sender_id,
channelId: row.channel_id,
timestamp: row.timestamp,
embedding: row.embedding,
},
contact,
score: 1.0, // Simple LIKE doesn't provide scoring
snippet: this.createSnippet(row.content, options.query),
};
});
}
/**
* Create a snippet with the query highlighted.
*/
private createSnippet(content: string, query: string, maxLength = 200): string {
const lowerContent = content.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerContent.indexOf(lowerQuery);
if (index === -1) {
return content.slice(0, maxLength) + (content.length > maxLength ? "..." : "");
}
// Center the snippet around the match
const contextBefore = 50;
const contextAfter = 100;
let start = Math.max(0, index - contextBefore);
let end = Math.min(content.length, index + query.length + contextAfter);
// Adjust to word boundaries if possible
if (start > 0) {
const spaceIndex = content.lastIndexOf(" ", start + 10);
if (spaceIndex > start - 20) start = spaceIndex + 1;
}
if (end < content.length) {
const spaceIndex = content.indexOf(" ", end - 10);
if (spaceIndex !== -1 && spaceIndex < end + 20) end = spaceIndex;
}
let snippet = content.slice(start, end);
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet = snippet + "...";
return snippet;
}
// ─────────────────────────────────────────────────────────────────────────────
// STATISTICS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get statistics about the contact store.
*/
getStats(): {
contacts: number;
identities: number;
messages: number;
platforms: Record<string, number>;
} {
const contactCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM contacts`).get() as { count: number }
).count;
const identityCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM platform_identities`).get() as {
count: number;
}
).count;
const messageCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM indexed_messages`).get() as { count: number }
).count;
const platformRows = this.db
.prepare(`SELECT platform, COUNT(*) as count FROM platform_identities GROUP BY platform`)
.all() as Array<{ platform: string; count: number }>;
const platforms: Record<string, number> = {};
for (const row of platformRows) {
platforms[row.platform] = row.count;
}
return {
contacts: contactCount,
identities: identityCount,
messages: messageCount,
platforms,
};
}
}
// Singleton instance
let _store: ContactStore | null = null;
let _storeConfig: { dbPath?: string; stateDir?: string } = {};
export function configureContactStore(params: { dbPath?: string; stateDir?: string }): void {
_storeConfig = params;
if (_store) {
_store.close();
_store = null;
}
}
/**
* Get the global contact store instance.
*/
export function getContactStore(): ContactStore {
if (!_store) {
_store = ContactStore.open(_storeConfig);
}
return _store;
}
/**
* Close the global contact store instance.
*/
export function closeContactStore(): void {
if (_store) {
_store.close();
_store = null;
}
}

View File

@@ -1,165 +0,0 @@
import type { ChannelId } from "clawdbot/plugin-sdk";
/**
* Types for the unified contact graph.
*
* The contact graph allows cross-platform identity resolution:
* - Multiple platform identities can be linked to a single canonical contact
* - Enables unified message search across all messaging channels
*/
/**
* A canonical contact in the unified contact graph.
* Represents a single person who may have multiple platform identities.
*/
export type Contact = {
/** Unique canonical identifier (e.g., "sarah-jones-abc123") */
canonicalId: string;
/** Primary display name for this contact */
displayName: string;
/** Alternative names/aliases for this contact */
aliases: string[];
/** When this contact was first created */
createdAt: number;
/** When this contact was last updated */
updatedAt: number;
};
/**
* Supported messaging platforms.
*/
export type Platform = ChannelId;
/**
* A platform-specific identity linked to a canonical contact.
* Each person may have one or more of these across different platforms.
*/
export type PlatformIdentity = {
/** Database row ID */
id: number;
/** Reference to the canonical contact */
contactId: string;
/** Which platform this identity belongs to */
platform: Platform;
/** Platform-specific user identifier (JID, user ID, etc.) */
platformId: string;
/** Platform-specific username (@handle) if available */
username: string | null;
/** E.164 phone number if available */
phone: string | null;
/** Platform-specific display name */
displayName: string | null;
/** When this identity was last seen in a message */
lastSeenAt: number | null;
};
/**
* Input for creating a new platform identity.
*/
export type PlatformIdentityInput = Omit<PlatformIdentity, "id">;
/**
* Result of a contact search/lookup.
*/
export type ContactWithIdentities = Contact & {
/** All platform identities associated with this contact */
identities: PlatformIdentity[];
};
/**
* Auto-link match confidence levels.
*/
export type LinkConfidence = "high" | "medium" | "low";
/**
* A suggested link between platform identities.
*/
export type LinkSuggestion = {
/** The source identity that was analyzed */
sourceIdentity: PlatformIdentity;
/** The target identity to potentially link with */
targetIdentity: PlatformIdentity;
/** Why this link is suggested */
reason: "phone_match" | "email_match" | "name_similarity";
/** How confident we are in this match */
confidence: LinkConfidence;
/** Score for ranking (0-1) */
score: number;
};
/**
* Contact import result from a platform.
*/
export type ImportResult = {
platform: Platform;
imported: number;
linked: number;
errors: string[];
};
/**
* Options for contact search.
*/
export type ContactSearchOptions = {
/** Search query (matches name, aliases, username) */
query?: string;
/** Filter by platform */
platform?: Platform;
/** Maximum results to return */
limit?: number;
};
/**
* A message indexed for cross-platform search.
*/
export type IndexedMessage = {
/** Unique message ID */
id: string;
/** Message content (may be empty for media) */
content: string;
/** Reference to canonical contact ID of sender */
contactId: string | null;
/** Platform this message came from */
platform: Platform;
/** Platform-specific sender ID */
senderId: string;
/** Platform-specific channel/chat ID */
channelId: string;
/** When the message was sent */
timestamp: number;
/** Optional: pre-computed embedding for semantic search */
embedding: string | null;
};
/**
* Options for message search.
*/
export type MessageSearchOptions = {
/** Text query to search for */
query: string;
/** Filter by sender (canonical contact ID or platform identity) */
from?: string;
/** Filter by platform */
platforms?: Platform[];
/** Filter by channel/chat ID */
channelId?: string;
/** Filter messages after this timestamp */
since?: number;
/** Filter messages before this timestamp */
until?: number;
/** Maximum results */
limit?: number;
};
/**
* Message search result.
*/
export type MessageSearchResult = {
message: IndexedMessage;
/** The contact who sent this message (if resolved) */
contact: Contact | null;
/** Search relevance score */
score: number;
/** Snippet with highlighted match */
snippet: string;
};

View File

@@ -1,104 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { importContactFromMessage, getContactStore } from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
function normalizePlatform(value: string): Platform {
return value.trim().toLowerCase() as Platform;
}
function resolveMessageId(params: {
messageId?: string;
platform: string;
senderId: string;
timestamp?: number;
content: string;
}): string {
if (params.messageId) {
return `${params.platform}:${params.messageId}`;
}
if (!params.timestamp) return randomUUID();
const hash = createHash("sha1");
hash.update(params.platform);
hash.update("|");
hash.update(params.senderId);
hash.update("|");
hash.update(String(params.timestamp));
hash.update("|");
hash.update(params.content);
return hash.digest("hex");
}
export function indexInboundMessage(params: {
event: {
from: string;
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
};
ctx: {
channelId: string;
accountId?: string;
conversationId?: string;
};
logger?: { warn?: (message: string) => void };
}): void {
const { event, ctx, logger } = params;
const channelId = (ctx.channelId ?? "").trim();
if (!channelId) return;
const metadata = event.metadata ?? {};
const meta = metadata as {
senderId?: string;
messageId?: string;
senderUsername?: string;
senderE164?: string;
senderName?: string;
};
const senderId = String(meta.senderId ?? event.from ?? "").trim();
if (!senderId) return;
const content = typeof event.content === "string" ? event.content.trim() : "";
const platform = normalizePlatform(channelId);
const timestamp =
typeof event.timestamp === "number" && Number.isFinite(event.timestamp)
? event.timestamp
: Date.now();
const metadataMessageId = meta.messageId;
const messageId = resolveMessageId({
messageId: typeof metadataMessageId === "string" ? metadataMessageId : undefined,
platform,
senderId,
timestamp,
content,
});
const conversationId = (ctx.conversationId ?? "").trim() || senderId;
try {
const store = getContactStore();
importContactFromMessage(store, {
platform,
platformId: senderId,
username: typeof meta.senderUsername === "string" ? meta.senderUsername : null,
phone: typeof meta.senderE164 === "string" ? meta.senderE164 : null,
displayName: typeof meta.senderName === "string" ? meta.senderName : null,
});
if (!content) return;
store.indexMessage({
id: messageId,
content,
contactId: null,
platform,
senderId,
channelId: conversationId,
timestamp,
embedding: null,
});
} catch (err) {
logger?.warn?.(
`[contacts-search] failed indexing message: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

View File

@@ -1,22 +0,0 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
export function requireNodeSqlite(): typeof import("node:sqlite") {
const onWarning = (warning: Error & { name?: string; message?: string }) => {
if (
warning.name === "ExperimentalWarning" &&
warning.message?.includes("SQLite is an experimental feature")
) {
return;
}
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
};
process.on("warning", onWarning);
try {
return require("node:sqlite") as typeof import("node:sqlite");
} finally {
process.off("warning", onWarning);
}
}

View File

@@ -65,7 +65,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
try {
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com");
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
let graph:
| {
ok: boolean;

View File

@@ -1,33 +0,0 @@
# Flawd - Clawdbot Fly.io demo deployment
# See https://fly.io/docs/reference/configuration/
app = "flawd-bot"
primary_region = "lhr"
[build]
dockerfile = "Dockerfile"
[env]
NODE_ENV = "production"
CLAWDBOT_PREFER_PNPM = "1"
CLAWDBOT_STATE_DIR = "/data"
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
processes = ["app"]
[[vm]]
size = "shared-cpu-2x"
memory = "2048mb"
[mounts]
source = "clawdbot_data"
destination = "/data"

2
pnpm-lock.yaml generated
View File

@@ -393,8 +393,6 @@ importers:
extensions/telegram: {}
extensions/telegram-tts: {}
extensions/tlon:
dependencies:
'@urbit/aura':

View File

@@ -2,7 +2,7 @@
name: bird
description: X/Twitter CLI for reading, searching, posting, and engagement via cookies.
homepage: https://bird.fast
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)","os":["darwin"]},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"},{"id":"npm","kind":"node","package":"@steipete/bird","bins":["bird"],"label":"Install bird (npm)"}]}}
---
# bird 🐦

View File

@@ -1,215 +0,0 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import { resolveStateDir } from "../config/paths.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
type PayloadLogStage = "request" | "usage";
type PayloadLogEvent = {
ts: string;
stage: PayloadLogStage;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
payload?: unknown;
usage?: Record<string, unknown>;
error?: string;
payloadDigest?: string;
};
type PayloadLogConfig = {
enabled: boolean;
filePath: string;
};
type PayloadLogWriter = {
filePath: string;
write: (line: string) => void;
};
const writers = new Map<string, PayloadLogWriter>();
const log = createSubsystemLogger("agent/anthropic-payload");
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
const filePath = fileOverride
? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
return { enabled, filePath };
}
function getWriter(filePath: string): PayloadLogWriter {
const existing = writers.get(filePath);
if (existing) return existing;
const dir = path.dirname(filePath);
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
let queue = Promise.resolve();
const writer: PayloadLogWriter = {
filePath,
write: (line: string) => {
queue = queue
.then(() => ready)
.then(() => fs.appendFile(filePath, line, "utf8"))
.catch(() => undefined);
},
};
writers.set(filePath, writer);
return writer;
}
function safeJsonStringify(value: unknown): string | null {
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === "bigint") return val.toString();
if (typeof val === "function") return "[Function]";
if (val instanceof Error) {
return { name: val.name, message: val.message, stack: val.stack };
}
if (val instanceof Uint8Array) {
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
}
return val;
});
} catch {
return null;
}
}
function formatError(error: unknown): string | undefined {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
return String(error);
}
if (error && typeof error === "object") {
return safeJsonStringify(error) ?? "unknown error";
}
return undefined;
}
function digest(value: unknown): string | undefined {
const serialized = safeJsonStringify(value);
if (!serialized) return undefined;
return crypto.createHash("sha256").update(serialized).digest("hex");
}
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
return (model as { api?: unknown })?.api === "anthropic-messages";
}
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i] as { role?: unknown; usage?: unknown };
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
return msg.usage as Record<string, unknown>;
}
}
return null;
}
export type AnthropicPayloadLogger = {
enabled: true;
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
};
export function createAnthropicPayloadLogger(params: {
env?: NodeJS.ProcessEnv;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
}): AnthropicPayloadLogger | null {
const env = params.env ?? process.env;
const cfg = resolvePayloadLogConfig(env);
if (!cfg.enabled) return null;
const writer = getWriter(cfg.filePath);
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const record = (event: PayloadLogEvent) => {
const line = safeJsonStringify(event);
if (!line) return;
writer.write(`${line}\n`);
};
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
const wrapped: StreamFn = (model, context, options) => {
if (!isAnthropicModel(model as Model<Api>)) {
return streamFn(model, context, options);
}
const nextOnPayload = (payload: unknown) => {
record({
...base,
ts: new Date().toISOString(),
stage: "request",
payload,
payloadDigest: digest(payload),
});
options?.onPayload?.(payload);
};
return streamFn(model, context, {
...options,
onPayload: nextOnPayload,
});
};
return wrapped;
};
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
const usage = findLastAssistantUsage(messages);
const errorMessage = formatError(error);
if (!usage) {
if (errorMessage) {
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
error: errorMessage,
});
}
return;
}
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
usage,
error: errorMessage,
});
log.info("anthropic usage", {
runId: params.runId,
sessionId: params.sessionId,
usage,
});
};
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
return { enabled: true, wrapStreamFn, recordUsage };
}

View File

@@ -17,7 +17,6 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createTtsTool } from "./tools/tts-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -97,10 +96,6 @@ export function createClawdbotTools(options?: {
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
}),
createTtsTool({
agentChannel: options?.agentChannel,
config: options?.config,
}),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,

View File

@@ -1,74 +0,0 @@
import { describe, expect, it } from "vitest";
import { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers.js";
describe("downgradeOpenAIReasoningBlocks", () => {
it("keeps reasoning signatures when followed by content", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "internal reasoning",
thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }),
},
{ type: "text", text: "answer" },
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
it("drops orphaned reasoning blocks without following content", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinkingSignature: JSON.stringify({ id: "rs_abc", type: "reasoning" }),
},
],
},
{ role: "user", content: "next" },
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([
{ role: "user", content: "next" },
]);
});
it("drops object-form orphaned signatures", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinkingSignature: { id: "rs_obj", type: "reasoning" },
},
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual([]);
});
it("keeps non-reasoning thinking signatures", () => {
const input = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "t",
thinkingSignature: "reasoning_content",
},
],
},
];
expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input);
});
});

View File

@@ -31,8 +31,6 @@ export {
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
export { downgradeOpenAIReasoningBlocks } from "./pi-embedded-helpers/openai.js";
export {
isEmptyAssistantMessageContent,
sanitizeSessionMessagesImages,

View File

@@ -1,118 +0,0 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
type OpenAIThinkingBlock = {
type?: unknown;
thinking?: unknown;
thinkingSignature?: unknown;
};
type OpenAIReasoningSignature = {
id: string;
type: string;
};
function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null {
if (!value) return null;
let candidate: { id?: unknown; type?: unknown } | null = null;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try {
candidate = JSON.parse(trimmed) as { id?: unknown; type?: unknown };
} catch {
return null;
}
} else if (typeof value === "object") {
candidate = value as { id?: unknown; type?: unknown };
}
if (!candidate) return null;
const id = typeof candidate.id === "string" ? candidate.id : "";
const type = typeof candidate.type === "string" ? candidate.type : "";
if (!id.startsWith("rs_")) return null;
if (type === "reasoning" || type.startsWith("reasoning.")) {
return { id, type };
}
return null;
}
function hasFollowingNonThinkingBlock(
content: Extract<AgentMessage, { role: "assistant" }>["content"],
index: number,
): boolean {
for (let i = index + 1; i < content.length; i++) {
const block = content[i];
if (!block || typeof block !== "object") return true;
if ((block as { type?: unknown }).type !== "thinking") return true;
}
return false;
}
/**
* OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
* without the required following item.
*
* Clawdbot persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata
* is incomplete, drop the block to keep history usable.
*/
export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] {
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
}
const role = (msg as { role?: unknown }).role;
if (role !== "assistant") {
out.push(msg);
continue;
}
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistantMsg.content)) {
out.push(msg);
continue;
}
let changed = false;
type AssistantContentBlock = (typeof assistantMsg.content)[number];
const nextContent: AssistantContentBlock[] = [];
for (let i = 0; i < assistantMsg.content.length; i++) {
const block = assistantMsg.content[i];
if (!block || typeof block !== "object") {
nextContent.push(block as AssistantContentBlock);
continue;
}
const record = block as OpenAIThinkingBlock;
if (record.type !== "thinking") {
nextContent.push(block as AssistantContentBlock);
continue;
}
const signature = parseOpenAIReasoningSignature(record.thinkingSignature);
if (!signature) {
nextContent.push(block as AssistantContentBlock);
continue;
}
if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) {
nextContent.push(block as AssistantContentBlock);
continue;
}
changed = true;
}
if (!changed) {
out.push(msg);
continue;
}
if (nextContent.length === 0) {
continue;
}
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
}
return out;
}

View File

@@ -161,92 +161,4 @@ describe("sanitizeSessionHistory", () => {
expect(result).toHaveLength(1);
expect(result[0]?.role).toBe("assistant");
});
it("does not downgrade openai reasoning when the model has not changed", async () => {
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
{
type: "custom",
customType: "model-snapshot",
data: {
timestamp: Date.now(),
provider: "openai",
modelApi: "openai-responses",
modelId: "gpt-5.2-codex",
},
},
];
const sessionManager = {
getEntries: vi.fn(() => sessionEntries),
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
sessionEntries.push({ type: "custom", customType, data });
}),
} as unknown as SessionManager;
const messages: AgentMessage[] = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature: JSON.stringify({ id: "rs_test", type: "reasoning" }),
},
],
},
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual(messages);
});
it("downgrades openai reasoning only when the model changes", async () => {
const sessionEntries: Array<{ type: string; customType: string; data: unknown }> = [
{
type: "custom",
customType: "model-snapshot",
data: {
timestamp: Date.now(),
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-3-7",
},
},
];
const sessionManager = {
getEntries: vi.fn(() => sessionEntries),
appendCustomEntry: vi.fn((customType: string, data: unknown) => {
sessionEntries.push({ type: "custom", customType, data });
}),
} as unknown as SessionManager;
const messages: AgentMessage[] = [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "reasoning",
thinkingSignature: { id: "rs_test", type: "reasoning" },
},
],
},
];
const result = await sanitizeSessionHistory({
messages,
modelApi: "openai-responses",
provider: "openai",
modelId: "gpt-5.2-codex",
sessionManager,
sessionId: "test-session",
});
expect(result).toEqual([]);
});
});

View File

@@ -6,7 +6,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import {
downgradeOpenAIReasoningBlocks,
isCompactionFailureError,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
@@ -212,50 +211,7 @@ registerUnhandledRejectionHandler((reason) => {
return true;
});
type CustomEntryLike = { type?: unknown; customType?: unknown; data?: unknown };
type ModelSnapshotEntry = {
timestamp: number;
provider?: string;
modelApi?: string | null;
modelId?: string;
};
const MODEL_SNAPSHOT_CUSTOM_TYPE = "model-snapshot";
function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null {
try {
const entries = sessionManager.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i] as CustomEntryLike;
if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) continue;
const data = entry?.data as ModelSnapshotEntry | undefined;
if (data && typeof data === "object") {
return data;
}
}
} catch {
return null;
}
return null;
}
function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void {
try {
sessionManager.appendCustomEntry(MODEL_SNAPSHOT_CUSTOM_TYPE, data);
} catch {
// ignore persistence failures
}
}
function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): boolean {
const normalize = (value?: string | null) => value ?? "";
return (
normalize(a.provider) === normalize(b.provider) &&
normalize(a.modelApi) === normalize(b.modelApi) &&
normalize(a.modelId) === normalize(b.modelId)
);
}
type CustomEntryLike = { type?: unknown; customType?: unknown };
function hasGoogleTurnOrderingMarker(sessionManager: SessionManager): boolean {
try {
@@ -336,38 +292,12 @@ export async function sanitizeSessionHistory(params: {
? sanitizeToolUseResultPairing(sanitizedThinking)
: sanitizedThinking;
const isOpenAIResponsesApi =
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null;
const modelChanged = priorSnapshot
? !isSameModelSnapshot(priorSnapshot, {
timestamp: 0,
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
})
: false;
const sanitizedOpenAI =
isOpenAIResponsesApi && modelChanged
? downgradeOpenAIReasoningBlocks(repairedTools)
: repairedTools;
if (hasSnapshot && (!priorSnapshot || modelChanged)) {
appendModelSnapshot(params.sessionManager, {
timestamp: Date.now(),
provider: params.provider,
modelApi: params.modelApi,
modelId: params.modelId,
});
}
if (!policy.applyGoogleTurnOrdering) {
return sanitizedOpenAI;
return repairedTools;
}
return applyGoogleTurnOrderingFix({
messages: sanitizedOpenAI,
messages: repairedTools,
modelApi: params.modelApi,
sessionManager: params.sessionManager,
sessionId: params.sessionId,

View File

@@ -20,7 +20,6 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { createCacheTrace } from "../../cache-trace.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
@@ -459,16 +458,6 @@ export async function runEmbeddedAttempt(
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
const anthropicPayloadLogger = createAnthropicPayloadLogger({
env: process.env,
runId: params.runId,
sessionId: activeSession.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
@@ -489,11 +478,6 @@ export async function runEmbeddedAttempt(
});
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
}
if (anthropicPayloadLogger) {
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
activeSession.agent.streamFn,
);
}
try {
const prior = await sanitizeSessionHistory({
@@ -788,7 +772,6 @@ export async function runEmbeddedAttempt(
messages: messagesSnapshot,
note: promptError ? "prompt error" : undefined,
});
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await

View File

@@ -148,35 +148,6 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads[0]?.text).toBe("All good");
});
it("adds tool error fallback when the assistant only invoked tools", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: {
stopReason: "toolUse",
content: [
{
type: "toolCall",
id: "toolu_01",
name: "exec",
arguments: { command: "echo hi" },
},
],
} as AssistantMessage,
lastToolError: { toolName: "exec", error: "Command exited with code 1" },
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("Exec");
expect(payloads[0]?.text).toContain("code 1");
});
it("suppresses recoverable tool errors containing 'required'", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],

View File

@@ -169,16 +169,7 @@ export function buildEmbeddedRunPayloads(params: {
}
if (params.lastToolError) {
const lastAssistantHasToolCalls =
Array.isArray(params.lastAssistant?.content) &&
params.lastAssistant?.content.some((block) =>
block && typeof block === "object"
? (block as { type?: unknown }).type === "toolCall"
: false,
);
const lastAssistantWasToolUse = params.lastAssistant?.stopReason === "toolUse";
const hasUserFacingReply =
replyItems.length > 0 && !lastAssistantHasToolCalls && !lastAssistantWasToolUse;
const hasUserFacingReply = replyItems.length > 0;
// Check if this is a recoverable/internal tool error that shouldn't be shown to users
// when there's already a user-facing reply (the model should have retried).
const errorLower = (params.lastToolError.error ?? "").toLowerCase();

View File

@@ -44,12 +44,10 @@ import {
buildPluginToolGroups,
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
normalizeToolName,
resolveToolProfilePolicy,
stripPluginOnlyAllowlist,
} from "./tool-policy.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { logWarn } from "../logger.js";
function isOpenAIProvider(provider?: string) {
const normalized = provider?.trim().toLowerCase();
@@ -255,6 +253,11 @@ export function createClawdbotCodingTools(options?: {
}
: undefined,
});
const bashTool = {
...(execTool as unknown as AnyAgentTool),
name: "bash",
label: "bash",
} satisfies AnyAgentTool;
const processTool = createProcessTool({
cleanupMs: cleanupMsOverride ?? execConfig.cleanupMs,
scopeKey,
@@ -275,6 +278,7 @@ export function createClawdbotCodingTools(options?: {
: []),
...(applyPatchTool ? [applyPatchTool as unknown as AnyAgentTool] : []),
execTool as unknown as AnyAgentTool,
bashTool,
processTool as unknown as AnyAgentTool,
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
@@ -315,46 +319,38 @@ export function createClawdbotCodingTools(options?: {
modelHasVision: options?.modelHasVision,
}),
];
const coreToolNames = new Set(
tools
.filter((tool) => !getPluginToolMeta(tool as AnyAgentTool))
.map((tool) => normalizeToolName(tool.name))
.filter(Boolean),
);
const pluginGroups = buildPluginToolGroups({
tools,
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
});
const resolvePolicy = (policy: typeof profilePolicy, label: string) => {
const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
? "Ignoring allowlist so core tools remain available."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
profilePolicy,
profile ? `tools.profile (${profile})` : "tools.profile",
const profilePolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
pluginGroups,
);
const providerProfileExpanded = resolvePolicy(
providerProfilePolicy,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
const providerProfileExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
pluginGroups,
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
const globalProviderExpanded = resolvePolicy(globalProviderPolicy, "tools.byProvider.allow");
const agentPolicyExpanded = resolvePolicy(
agentPolicy,
agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow",
const globalPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
pluginGroups,
);
const agentProviderExpanded = resolvePolicy(
agentProviderPolicy,
agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow",
const globalProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
pluginGroups,
);
const agentPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
pluginGroups,
);
const agentProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
pluginGroups,
);
const groupPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(groupPolicy, pluginGroups),
pluginGroups,
);
const groupPolicyExpanded = resolvePolicy(groupPolicy, "group tools.allow");
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);

View File

@@ -124,7 +124,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("includes user timezone when provided (12-hour)", () => {
it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -133,10 +133,11 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
expect(prompt).toContain("Time format: 12-hour");
});
it("includes user timezone when provided (24-hour)", () => {
it("includes user time when provided (24-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -145,10 +146,11 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
expect(prompt).toContain("Time format: 24-hour");
});
it("shows timezone when only timezone is provided", () => {
it("shows UTC fallback when only timezone is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -156,7 +158,9 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Time zone: America/Chicago");
expect(prompt).toContain(
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
);
});
it("includes model alias guidance when aliases are provided", () => {

View File

@@ -49,9 +49,22 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: { userTimezone?: string }) {
if (!params.userTimezone) return [];
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
}
function buildReplyTagsSection(isMinimal: boolean) {
@@ -199,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
@@ -289,6 +302,7 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const userTime = params.userTime?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const heartbeatPromptLine = heartbeatPrompt
@@ -451,6 +465,8 @@ export function buildAgentSystemPrompt(params: {
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
userTime,
userTimeFormat: params.userTimeFormat,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",

View File

@@ -30,6 +30,12 @@
"title": "Exec",
"detailKeys": ["command"]
},
"bash": {
"emoji": "🛠️",
"title": "Exec",
"label": "exec",
"detailKeys": ["command"]
},
"process": {
"emoji": "🧰",
"title": "Process",

View File

@@ -6,46 +6,20 @@ const pluginGroups: PluginToolGroups = {
all: ["lobster", "workflow_tool"],
byPlugin: new Map([["lobster", ["lobster", "workflow_tool"]]]),
};
const coreTools = new Set(["read", "write", "exec", "session_status"]);
describe("stripPluginOnlyAllowlist", () => {
it("strips allowlist when it only targets plugin tools", () => {
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups, coreTools);
expect(policy.policy?.allow).toBeUndefined();
expect(policy.unknownAllowlist).toEqual([]);
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, pluginGroups);
expect(policy?.allow).toBeUndefined();
});
it("strips allowlist when it only targets plugin groups", () => {
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups, coreTools);
expect(policy.policy?.allow).toBeUndefined();
expect(policy.unknownAllowlist).toEqual([]);
const policy = stripPluginOnlyAllowlist({ allow: ["group:plugins"] }, pluginGroups);
expect(policy?.allow).toBeUndefined();
});
it("keeps allowlist when it mixes plugin and core entries", () => {
const policy = stripPluginOnlyAllowlist(
{ allow: ["lobster", "read"] },
pluginGroups,
coreTools,
);
expect(policy.policy?.allow).toEqual(["lobster", "read"]);
expect(policy.unknownAllowlist).toEqual([]);
});
it("strips allowlist with unknown entries when no core tools match", () => {
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
const policy = stripPluginOnlyAllowlist({ allow: ["lobster"] }, emptyPlugins, coreTools);
expect(policy.policy?.allow).toBeUndefined();
expect(policy.unknownAllowlist).toEqual(["lobster"]);
});
it("keeps allowlist with core tools and reports unknown entries", () => {
const emptyPlugins: PluginToolGroups = { all: [], byPlugin: new Map() };
const policy = stripPluginOnlyAllowlist(
{ allow: ["read", "lobster"] },
emptyPlugins,
coreTools,
);
expect(policy.policy?.allow).toEqual(["read", "lobster"]);
expect(policy.unknownAllowlist).toEqual(["lobster"]);
const policy = stripPluginOnlyAllowlist({ allow: ["lobster", "read"] }, pluginGroups);
expect(policy?.allow).toEqual(["lobster", "read"]);
});
});

View File

@@ -6,8 +6,8 @@ describe("tool-policy", () => {
const expanded = expandToolGroups(["group:runtime", "BASH", "apply-patch", "group:fs"]);
const set = new Set(expanded);
expect(set.has("exec")).toBe(true);
expect(set.has("bash")).toBe(true);
expect(set.has("process")).toBe(true);
expect(set.has("bash")).toBe(false);
expect(set.has("apply_patch")).toBe(true);
expect(set.has("read")).toBe(true);
expect(set.has("write")).toBe(true);

View File

@@ -17,7 +17,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// Basic workspace/file tools
"group:fs": ["read", "write", "edit", "apply_patch"],
// Host/runtime execution tools
"group:runtime": ["exec", "process"],
"group:runtime": ["exec", "bash", "process"],
// Session management tools
"group:sessions": [
"sessions_list",
@@ -95,12 +95,6 @@ export type PluginToolGroups = {
byPlugin: Map<string, string[]>;
};
export type AllowlistResolution = {
policy: ToolPolicyLike | undefined;
unknownAllowlist: string[];
strippedAllowlist: boolean;
};
export function expandToolGroups(list?: string[]) {
const normalized = normalizeToolList(list);
const expanded: string[] = [];
@@ -187,33 +181,17 @@ export function expandPolicyWithPluginGroups(
export function stripPluginOnlyAllowlist(
policy: ToolPolicyLike | undefined,
groups: PluginToolGroups,
coreTools: Set<string>,
): AllowlistResolution {
if (!policy?.allow || policy.allow.length === 0) {
return { policy, unknownAllowlist: [], strippedAllowlist: false };
}
): ToolPolicyLike | undefined {
if (!policy?.allow || policy.allow.length === 0) return policy;
const normalized = normalizeToolList(policy.allow);
if (normalized.length === 0) {
return { policy, unknownAllowlist: [], strippedAllowlist: false };
}
if (normalized.length === 0) return policy;
const pluginIds = new Set(groups.byPlugin.keys());
const pluginTools = new Set(groups.all);
const unknownAllowlist: string[] = [];
let hasCoreEntry = false;
for (const entry of normalized) {
const isPluginEntry =
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
const expanded = expandToolGroups([entry]);
const isCoreEntry = expanded.some((tool) => coreTools.has(tool));
if (isCoreEntry) hasCoreEntry = true;
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
}
const strippedAllowlist = !hasCoreEntry;
return {
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
strippedAllowlist,
};
const isPluginEntry = (entry: string) =>
entry === "group:plugins" || pluginIds.has(entry) || pluginTools.has(entry);
const isPluginOnly = normalized.every((entry) => isPluginEntry(entry));
if (!isPluginOnly) return policy;
return { ...policy, allow: undefined };
}
export function resolveToolProfilePolicy(profile?: string): ToolProfilePolicy | undefined {

View File

@@ -15,7 +15,6 @@ import {
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
@@ -216,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card (usage + time + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -325,13 +324,6 @@ export function createSessionStatusTool(opts?: {
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
);
const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const timeLine = userTime
? `🕒 Time: ${userTime} (${userTimezone})`
: `🕒 Time zone: ${userTimezone}`;
const agentDefaults = cfg.agents?.defaults ?? {};
const defaultLabel = `${configured.provider}/${configured.model}`;
const agentModel =
@@ -354,7 +346,6 @@ export function createSessionStatusTool(opts?: {
agentDir,
}),
usageLine,
timeLine,
queue: {
mode: queueSettings.mode,
depth: queueDepth,

View File

@@ -1,60 +0,0 @@
import { Type } from "@sinclair/typebox";
import { loadConfig } from "../../config/config.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import { textToSpeech } from "../../tts/tts.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
const TtsToolSchema = Type.Object({
text: Type.String({ description: "Text to convert to speech." }),
channel: Type.Optional(
Type.String({ description: "Optional channel id to pick output format (e.g. telegram)." }),
),
});
export function createTtsTool(opts?: {
config?: ClawdbotConfig;
agentChannel?: GatewayMessageChannel;
}): AnyAgentTool {
return {
label: "TTS",
name: "tts",
description:
"Convert text to speech and return a MEDIA: path. Use when the user requests audio or TTS is enabled. Copy the MEDIA line exactly.",
parameters: TtsToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const text = readStringParam(params, "text", { required: true });
const channel = readStringParam(params, "channel");
const cfg = opts?.config ?? loadConfig();
const result = await textToSpeech({
text,
cfg,
channel: channel ?? opts?.agentChannel,
});
if (result.success && result.audioPath) {
const lines: string[] = [];
// Tag Telegram Opus output as a voice bubble instead of a file attachment.
if (result.voiceCompatible) lines.push("[[audio_as_voice]]");
lines.push(`MEDIA:${result.audioPath}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { audioPath: result.audioPath, provider: result.provider },
};
}
return {
content: [
{
type: "text",
text: result.error ?? "TTS conversion failed",
},
],
details: { error: result.error },
};
},
};
}

View File

@@ -272,81 +272,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
],
argsMenu: "auto",
}),
defineChatCommand({
key: "audio",
nativeName: "audio",
description: "Convert text to a TTS audio reply.",
textAlias: "/audio",
args: [
{
name: "text",
description: "Text to speak",
type: "string",
captureRemaining: true,
},
],
}),
defineChatCommand({
key: "tts_on",
nativeName: "tts_on",
description: "Enable text-to-speech for replies.",
textAlias: "/tts_on",
}),
defineChatCommand({
key: "tts_off",
nativeName: "tts_off",
description: "Disable text-to-speech for replies.",
textAlias: "/tts_off",
}),
defineChatCommand({
key: "tts_provider",
nativeName: "tts_provider",
description: "Set or show the TTS provider.",
textAlias: "/tts_provider",
args: [
{
name: "provider",
description: "openai or elevenlabs",
type: "string",
choices: ["openai", "elevenlabs"],
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts_limit",
nativeName: "tts_limit",
description: "Set or show the max TTS text length.",
textAlias: "/tts_limit",
args: [
{
name: "maxLength",
description: "Max chars before summarizing",
type: "number",
},
],
}),
defineChatCommand({
key: "tts_summary",
nativeName: "tts_summary",
description: "Enable or disable TTS auto-summary.",
textAlias: "/tts_summary",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["on", "off"],
},
],
argsMenu: "auto",
}),
defineChatCommand({
key: "tts_status",
nativeName: "tts_status",
description: "Show TTS status and last attempt.",
textAlias: "/tts_status",
}),
defineChatCommand({
key: "stop",
nativeName: "stop",

View File

@@ -8,6 +8,7 @@ import {
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
@@ -15,15 +16,18 @@ import {
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
describe("commands registry", () => {
@@ -42,6 +46,20 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("normalizes telegram native command specs", () => {
const specs = [
{ name: "OK", description: "Ok", acceptsArgs: false },
{ name: "bad-name", description: "Bad", acceptsArgs: false },
{ name: "fine_name", description: "Fine", acceptsArgs: false },
{ name: "ok", description: "Dup", acceptsArgs: false },
];
const normalized = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs,
});
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
@@ -85,6 +103,19 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("includes plugin commands in native specs", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands: [] },
);
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -1,8 +1,13 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { getPluginCommandSpecs } from "../plugins/commands.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import type {
ChatCommandDefinition,
CommandArgChoiceContext,
@@ -108,7 +113,7 @@ export function listChatCommandsForConfig(
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
const base = listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -116,13 +121,18 @@ export function listNativeCommandSpecs(params?: {
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
const base = listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -130,6 +140,42 @@ export function listNativeCommandSpecsForConfig(
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (surface === "telegram") {
const normalized = normalizeTelegramCommandName(trimmed);
if (!normalized) return null;
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
return normalized;
}
return trimmed;
}
export function normalizeNativeCommandSpecsForSurface(params: {
surface: string;
specs: NativeCommandSpec[];
}): NativeCommandSpec[] {
const surface = params.surface.toLowerCase();
if (!surface) return params.specs;
const normalized: NativeCommandSpec[] = [];
const seen = new Set<string>();
for (const spec of params.specs) {
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
if (!normalizedName) continue;
const key = normalizedName.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
}
return normalized;
}
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {

View File

@@ -16,7 +16,6 @@ import {
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
import {
handleAbortTrigger,
handleActivationCommand,
@@ -40,7 +39,6 @@ const HANDLERS: CommandHandler[] = [
handleSendPolicyCommand,
handleUsageCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handlePluginCommand } from "./commands-plugin.js";
describe("handlePluginCommand", () => {
beforeEach(() => {
clearPluginCommands();
});
it("skips plugin commands when text commands are disabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, false);
expect(result).toBeNull();
});
it("executes plugin commands when text commands are enabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, true);
expect(result?.reply?.text).toBe("pong");
});
});

View File

@@ -15,8 +15,9 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
*/
export const handlePluginCommand: CommandHandler = async (
params,
_allowTextCommands,
allowTextCommands,
): Promise<CommandHandlerResult | null> => {
if (!allowTextCommands) return null;
const { command, cfg } = params;
// Try to match a plugin command

View File

@@ -1,214 +0,0 @@
import { logVerbose } from "../../globals.js";
import type { ReplyPayload } from "../types.js";
import type { CommandHandler } from "./commands-types.js";
import {
getLastTtsAttempt,
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
isTtsEnabled,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
setLastTtsAttempt,
setSummarizationEnabled,
setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
function parseCommandArg(normalized: string, command: string): string | null {
if (normalized === command) return "";
if (normalized.startsWith(`${command} `)) return normalized.slice(command.length).trim();
return null;
}
export const handleTtsCommands: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
if (
!normalized.startsWith("/tts_") &&
normalized !== "/audio" &&
!normalized.startsWith("/audio ")
) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring TTS command from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
if (normalized === "/tts_on") {
setTtsEnabled(prefsPath, true);
return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
}
if (normalized === "/tts_off") {
setTtsEnabled(prefsPath, false);
return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
const audioArg = parseCommandArg(normalized, "/audio");
if (audioArg !== null) {
if (!audioArg.trim()) {
return { shouldContinue: false, reply: { text: "⚙️ Usage: /audio <text>" } };
}
const start = Date.now();
const result = await textToSpeech({
text: audioArg,
cfg: params.cfg,
channel: params.command.channel,
prefsPath,
});
if (result.success && result.audioPath) {
setLastTtsAttempt({
timestamp: Date.now(),
success: true,
textLength: audioArg.length,
summarized: false,
provider: result.provider,
latencyMs: result.latencyMs,
});
const payload: ReplyPayload = {
mediaUrl: result.audioPath,
audioAsVoice: result.voiceCompatible === true,
};
return { shouldContinue: false, reply: payload };
}
setLastTtsAttempt({
timestamp: Date.now(),
success: false,
textLength: audioArg.length,
summarized: false,
error: result.error,
latencyMs: Date.now() - start,
});
return {
shouldContinue: false,
reply: { text: `❌ Error generating audio: ${result.error ?? "unknown error"}` },
};
}
const providerArg = parseCommandArg(normalized, "/tts_provider");
if (providerArg !== null) {
const currentProvider = getTtsProvider(config, prefsPath);
if (!providerArg.trim()) {
const fallback = currentProvider === "openai" ? "elevenlabs" : "openai";
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
return {
shouldContinue: false,
reply: {
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
`Fallback: ${fallback}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Usage: /tts_provider openai | elevenlabs`,
},
};
}
const requested = providerArg.trim().toLowerCase();
if (requested !== "openai" && requested !== "elevenlabs") {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /tts_provider openai | elevenlabs" },
};
}
setTtsProvider(prefsPath, requested);
const fallback = requested === "openai" ? "elevenlabs" : "openai";
return {
shouldContinue: false,
reply: { text: `✅ TTS provider set to ${requested} (fallback: ${fallback}).` },
};
}
const limitArg = parseCommandArg(normalized, "/tts_limit");
if (limitArg !== null) {
if (!limitArg.trim()) {
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
};
}
const next = Number.parseInt(limitArg.trim(), 10);
if (!Number.isFinite(next) || next < 100 || next > 10_000) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /tts_limit <100-10000>" },
};
}
setTtsMaxLength(prefsPath, next);
return {
shouldContinue: false,
reply: { text: `✅ TTS limit set to ${next} characters.` },
};
}
const summaryArg = parseCommandArg(normalized, "/tts_summary");
if (summaryArg !== null) {
if (!summaryArg.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
return {
shouldContinue: false,
reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
};
}
const requested = summaryArg.trim().toLowerCase();
if (requested !== "on" && requested !== "off") {
return { shouldContinue: false, reply: { text: "⚙️ Usage: /tts_summary on|off" } };
}
setSummarizationEnabled(prefsPath, requested === "on");
return {
shouldContinue: false,
reply: {
text: requested === "on" ? "✅ TTS auto-summary enabled." : "❌ TTS auto-summary disabled.",
},
};
}
if (normalized === "/tts_status") {
const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = Boolean(resolveTtsApiKey(config, provider));
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
const lines = [
"📊 TTS status",
`State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
`Provider: ${provider} (${hasKey ? "✅ key" : "❌ no key"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
if (last) {
const timeAgo = Math.round((Date.now() - last.timestamp) / 1000);
lines.push("");
lines.push(`Last attempt (${timeAgo}s ago): ${last.success ? "✅" : "❌"}`);
lines.push(`Text: ${last.textLength} chars${last.summarized ? " (summarized)" : ""}`);
if (last.success) {
lines.push(`Provider: ${last.provider ?? "unknown"}`);
lines.push(`Latency: ${last.latencyMs ?? 0}ms`);
} else if (last.error) {
lines.push(`Error: ${last.error}`);
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
return null;
};

View File

@@ -6,7 +6,6 @@ import {
logMessageQueued,
logSessionStateChange,
} from "../../logging/diagnostic.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { getReplyFromConfig } from "../reply.js";
import type { FinalizedMsgContext } from "../templating.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
@@ -14,7 +13,6 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { maybeApplyTtsToPayload } from "../../tts/tts.js";
export type DispatchFromConfigResult = {
queuedFinal: boolean;
@@ -81,56 +79,6 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_received")) {
const timestamp =
typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
? ctx.Timestamp
: undefined;
const messageIdForHook =
ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast;
const content =
typeof ctx.BodyForCommands === "string"
? ctx.BodyForCommands
: typeof ctx.RawBody === "string"
? ctx.RawBody
: typeof ctx.Body === "string"
? ctx.Body
: "";
const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase();
const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined;
void hookRunner
.runMessageReceived(
{
from: ctx.From ?? "",
content,
timestamp,
metadata: {
to: ctx.To,
provider: ctx.Provider,
surface: ctx.Surface,
threadId: ctx.MessageThreadId,
originatingChannel: ctx.OriginatingChannel,
originatingTo: ctx.OriginatingTo,
messageId: messageIdForHook,
senderId: ctx.SenderId,
senderName: ctx.SenderName,
senderUsername: ctx.SenderUsername,
senderE164: ctx.SenderE164,
},
},
{
channelId,
accountId: ctx.AccountId,
conversationId,
},
)
.catch((err) => {
logVerbose(`dispatch-from-config: message_received hook failed: ${String(err)}`);
});
}
// Check if we should route replies to originating channel instead of dispatcher.
// Only route when the originating channel is DIFFERENT from the current surface.
// This handles cross-provider routing (e.g., message from Telegram being processed
@@ -143,7 +91,6 @@ export async function dispatchReplyFromConfig(params: {
const currentSurface = (ctx.Surface ?? ctx.Provider)?.toLowerCase();
const shouldRouteToOriginating =
isRoutableChannel(originatingChannel) && originatingTo && originatingChannel !== currentSurface;
const ttsChannel = shouldRouteToOriginating ? originatingChannel : currentSurface;
/**
* Helper to send a payload via route-reply (async).
@@ -217,36 +164,22 @@ export async function dispatchReplyFromConfig(params: {
{
...params.replyOptions,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "tool",
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload);
} else {
dispatcher.sendToolResult(ttsPayload);
}
};
return run();
if (shouldRouteToOriginating) {
// Fire-and-forget for streaming tool results when routing.
void sendPayloadAsync(payload);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendToolResult(payload);
}
},
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
channel: ttsChannel,
kind: "block",
});
if (shouldRouteToOriginating) {
await sendPayloadAsync(ttsPayload, context?.abortSignal);
} else {
dispatcher.sendBlockReply(ttsPayload);
}
};
return run();
if (shouldRouteToOriginating) {
// Await routed sends so upstream can enforce ordering/timeouts.
return sendPayloadAsync(payload, context?.abortSignal);
} else {
// Synchronous dispatch to preserve callback timing.
dispatcher.sendBlockReply(payload);
}
},
},
cfg,
@@ -257,16 +190,10 @@ export async function dispatchReplyFromConfig(params: {
let queuedFinal = false;
let routedFinalCount = 0;
for (const reply of replies) {
const ttsReply = await maybeApplyTtsToPayload({
payload: reply,
cfg,
channel: ttsChannel,
kind: "final",
});
if (shouldRouteToOriginating && originatingChannel && originatingTo) {
// Route final reply to originating channel.
const result = await routeReply({
payload: ttsReply,
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
@@ -282,7 +209,7 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = result.ok || queuedFinal;
if (result.ok) routedFinalCount += 1;
} else {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
queuedFinal = dispatcher.sendFinalReply(reply) || queuedFinal;
}
}
await dispatcher.waitForIdle();

View File

@@ -72,8 +72,8 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
});
if (!normalized) return { ok: true };
let text = normalized.text ?? "";
let mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
const text = normalized.text ?? "";
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]

View File

@@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
afterEach(() => {
clearPluginCommands();
vi.restoreAllMocks();
});
@@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => {
);
expect(text).toContain("/demo_skill - Demo skill");
});
it("includes plugin commands when registered", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("🔌 Plugin commands");
expect(text).toContain("/plugstatus - Plugin status");
});
});
describe("buildHelpMessage", () => {

View File

@@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@@ -52,7 +53,6 @@ type StatusArgs = {
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
usageLine?: string;
timeLine?: string;
queue?: QueueStatus;
mediaDecisions?: MediaUnderstandingDecision[];
subagentsLine?: string;
@@ -382,7 +382,6 @@ export function buildStatusMessage(args: StatusArgs): string {
return [
versionLine,
args.timeLine,
modelLine,
usageCostLine,
`📚 ${contextLine}`,
@@ -444,5 +443,12 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("🔌 Plugin commands");
for (const command of pluginCommands) {
lines.push(`/${command.name} - ${command.description}`);
}
}
return lines.join("\n");
}

View File

@@ -87,7 +87,6 @@ export type MsgContext = {
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
Timestamp?: number;
/** Provider label (e.g. whatsapp, telegram). */
Provider?: string;
/** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */

View File

@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -83,16 +82,13 @@ function stripWindowsNodeExec(argv: string[]): string[] {
const execBase = path.basename(execPath).toLowerCase();
const isExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const normalized = normalizeCandidate(value);
if (!normalized) return false;
const lower = normalized.toLowerCase();
const lower = normalizeCandidate(value).toLowerCase();
return (
lower === execPathLower ||
path.basename(lower) === execBase ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.includes("node.exe") ||
(path.basename(lower) === "node.exe" && fs.existsSync(normalized))
lower.includes("node.exe")
);
};
const filtered = argv.filter((arg, index) => index === 0 || !isExecPath(arg));

View File

@@ -1,5 +1,4 @@
import type { QueueDropPolicy, QueueMode, QueueModeByProvider } from "./types.queue.js";
import type { TtsConfig } from "./types.tts.js";
export type GroupChatConfig = {
mentionPatterns?: string[];
@@ -82,8 +81,6 @@ export type MessagesConfig = {
ackReactionScope?: "group-mentions" | "group-all" | "direct" | "all";
/** Remove ack reaction after reply is sent (default: false). */
removeAckAfterReply?: boolean;
/** Text-to-speech settings for outbound replies. */
tts?: TtsConfig;
};
export type NativeCommandsSetting = boolean | "auto";

View File

@@ -23,6 +23,5 @@ export * from "./types.signal.js";
export * from "./types.skills.js";
export * from "./types.slack.js";
export * from "./types.telegram.js";
export * from "./types.tts.js";
export * from "./types.tools.js";
export * from "./types.whatsapp.js";

View File

@@ -1,30 +0,0 @@
export type TtsProvider = "elevenlabs" | "openai";
export type TtsMode = "final" | "all";
export type TtsConfig = {
/** Enable auto-TTS (can be overridden by local prefs). */
enabled?: boolean;
/** Apply TTS to final replies only or to all replies (tool/block/final). */
mode?: TtsMode;
/** Primary TTS provider (fallbacks are automatic). */
provider?: TtsProvider;
/** ElevenLabs configuration. */
elevenlabs?: {
apiKey?: string;
voiceId?: string;
modelId?: string;
};
/** OpenAI configuration. */
openai?: {
apiKey?: string;
model?: string;
voice?: string;
};
/** Optional path for local TTS user preferences JSON. */
prefsPath?: string;
/** Hard cap for text sent to TTS (chars). */
maxTextLength?: number;
/** API request timeout (ms). */
timeoutMs?: number;
};

View File

@@ -155,36 +155,6 @@ export const MarkdownConfigSchema = z
.strict()
.optional();
export const TtsProviderSchema = z.enum(["elevenlabs", "openai"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsConfigSchema = z
.object({
enabled: z.boolean().optional(),
mode: TtsModeSchema.optional(),
provider: TtsProviderSchema.optional(),
elevenlabs: z
.object({
apiKey: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional();
export const HumanDelaySchema = z
.object({
mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(),

View File

@@ -5,7 +5,6 @@ import {
InboundDebounceSchema,
NativeCommandsSettingSchema,
QueueSchema,
TtsConfigSchema,
} from "./zod-schema.core.js";
const SessionResetConfigSchema = z
@@ -91,7 +90,6 @@ export const MessagesSchema = z
ackReaction: z.string().optional(),
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
removeAckAfterReply: z.boolean().optional(),
tts: TtsConfigSchema,
})
.strict()
.optional();

View File

@@ -125,43 +125,6 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
it("appends current time after the cron header line", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({ kind: "agentTurn", message: "do it", deliver: false }),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
const call = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0] as {
prompt?: string;
};
const lines = call?.prompt?.split("\n") ?? [];
expect(lines[0]).toContain("[cron:job-1");
expect(lines[0]).toContain("do it");
expect(lines[1]).toMatch(/^Current time: .+ \(.+\)$/);
});
});
it("uses agentId for workspace, session key, and store paths", async () => {
await withTempHome(async (home) => {
const deps: CliDeps = {

View File

@@ -25,11 +25,6 @@ import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import {
formatUserTime,
resolveUserTimeFormat,
resolveUserTimezone,
} from "../../agents/date-time.js";
import {
formatXHighModelHint,
normalizeThinkLevel,
@@ -231,12 +226,7 @@ export async function runCronIsolatedAgentTurn(params: {
});
const base = `[cron:${params.job.id} ${params.job.name}] ${params.message}`.trim();
const userTimezone = resolveUserTimezone(params.cfg.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.cfg.agents?.defaults?.timeFormat);
const formattedTime =
formatUserTime(new Date(now), userTimezone, userTimeFormat) ?? new Date(now).toISOString();
const timeLine = `Current time: ${formattedTime} (${userTimezone})`;
const commandBody = `${base}\n${timeLine}`.trim();
const commandBody = base;
const existingSnapshot = cronSession.sessionEntry.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);

View File

@@ -8,12 +8,6 @@ const BASE_METHODS = [
"status",
"usage.status",
"usage.cost",
"tts.status",
"tts.providers",
"tts.enable",
"tts.disable",
"tts.convert",
"tts.setProvider",
"config.get",
"config.set",
"config.apply",

View File

@@ -17,7 +17,6 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
import { skillsHandlers } from "./server-methods/skills.js";
import { systemHandlers } from "./server-methods/system.js";
import { talkHandlers } from "./server-methods/talk.js";
import { ttsHandlers } from "./server-methods/tts.js";
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
import { updateHandlers } from "./server-methods/update.js";
import { usageHandlers } from "./server-methods/usage.js";
@@ -54,8 +53,6 @@ const READ_METHODS = new Set([
"status",
"usage.status",
"usage.cost",
"tts.status",
"tts.providers",
"models.list",
"agents.list",
"agent.identity.get",
@@ -78,10 +75,6 @@ const WRITE_METHODS = new Set([
"agent.wait",
"wake",
"talk.mode",
"tts.enable",
"tts.disable",
"tts.convert",
"tts.setProvider",
"voicewake.set",
"node.invoke",
"chat.send",
@@ -158,7 +151,6 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
...configHandlers,
...wizardHandlers,
...talkHandlers,
...ttsHandlers,
...skillsHandlers,
...sessionsHandlers,
...systemHandlers,

View File

@@ -1,138 +0,0 @@
import { loadConfig } from "../../config/config.js";
import {
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
getTtsProvider,
isTtsEnabled,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
setTtsEnabled,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import { formatForLog } from "../ws-log.js";
import type { GatewayRequestHandlers } from "./types.js";
export const ttsHandlers: GatewayRequestHandlers = {
"tts.status": async ({ respond }) => {
try {
const cfg = loadConfig();
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
const provider = getTtsProvider(config, prefsPath);
respond(true, {
enabled: isTtsEnabled(config, prefsPath),
provider,
fallbackProvider: provider === "openai" ? "elevenlabs" : "openai",
prefsPath,
hasOpenAIKey: Boolean(resolveTtsApiKey(config, "openai")),
hasElevenLabsKey: Boolean(resolveTtsApiKey(config, "elevenlabs")),
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"tts.enable": async ({ respond }) => {
try {
const cfg = loadConfig();
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
setTtsEnabled(prefsPath, true);
respond(true, { enabled: true });
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"tts.disable": async ({ respond }) => {
try {
const cfg = loadConfig();
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
setTtsEnabled(prefsPath, false);
respond(true, { enabled: false });
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"tts.convert": async ({ params, respond }) => {
const text = typeof params.text === "string" ? params.text.trim() : "";
if (!text) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "tts.convert requires text"),
);
return;
}
try {
const cfg = loadConfig();
const channel = typeof params.channel === "string" ? params.channel.trim() : undefined;
const result = await textToSpeech({ text, cfg, channel });
if (result.success && result.audioPath) {
respond(true, {
audioPath: result.audioPath,
provider: result.provider,
outputFormat: result.outputFormat,
voiceCompatible: result.voiceCompatible,
});
return;
}
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, result.error ?? "TTS conversion failed"),
);
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"tts.setProvider": async ({ params, respond }) => {
const provider = typeof params.provider === "string" ? params.provider.trim() : "";
if (provider !== "openai" && provider !== "elevenlabs") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "Invalid provider. Use openai or elevenlabs."),
);
return;
}
try {
const cfg = loadConfig();
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
setTtsProvider(prefsPath, provider);
respond(true, { provider });
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"tts.providers": async ({ respond }) => {
try {
const cfg = loadConfig();
const config = resolveTtsConfig(cfg);
const prefsPath = resolveTtsPrefsPath(config);
respond(true, {
providers: [
{
id: "openai",
name: "OpenAI",
configured: Boolean(resolveTtsApiKey(config, "openai")),
models: [...OPENAI_TTS_MODELS],
voices: [...OPENAI_TTS_VOICES],
},
{
id: "elevenlabs",
name: "ElevenLabs",
configured: Boolean(resolveTtsApiKey(config, "elevenlabs")),
models: ["eleven_multilingual_v2", "eleven_turbo_v2_5", "eleven_monolingual_v1"],
},
],
active: getTtsProvider(config, prefsPath),
});
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
};

View File

@@ -1,13 +1,10 @@
import { createHash } from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
async function makeEnv() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
@@ -25,41 +22,6 @@ async function makeEnv() {
};
}
function resolveLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
return { lockPath: path.join(stateDir, `gateway.${hash}.lock`), configPath };
}
function makeProcStat(pid: number, startTime: number) {
const fields = [
"R",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
"1",
String(startTime),
"1",
"1",
];
return `${pid} (node) ${fields.join(" ")}`;
}
describe("gateway lock", () => {
it("blocks concurrent acquisition until release", async () => {
const { env, cleanup } = await makeEnv();
@@ -90,98 +52,4 @@ describe("gateway lock", () => {
await lock2?.release();
await cleanup();
});
it("treats recycled linux pid as stale when start time mismatches", async () => {
const { env, cleanup } = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
startTime: 111,
};
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const readFileSync = fsSync.readFileSync;
const statValue = makeProcStat(process.pid, 222);
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
return statValue;
}
return readFileSync(filePath as never, encoding as never) as never;
});
const lock = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 200,
pollIntervalMs: 20,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
spy.mockRestore();
await cleanup();
});
it("keeps lock on linux when proc access fails unless stale", async () => {
const { env, cleanup } = await makeEnv();
const { lockPath, configPath } = resolveLockPath(env);
const payload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
startTime: 111,
};
await fs.writeFile(lockPath, JSON.stringify(payload), "utf8");
const readFileSync = fsSync.readFileSync;
const spy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
throw new Error("EACCES");
}
return readFileSync(filePath as never, encoding as never) as never;
});
await expect(
acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 120,
pollIntervalMs: 20,
staleMs: 10_000,
platform: "linux",
}),
).rejects.toBeInstanceOf(GatewayLockError);
spy.mockRestore();
const stalePayload = {
...payload,
createdAt: new Date(0).toISOString(),
};
await fs.writeFile(lockPath, JSON.stringify(stalePayload), "utf8");
const staleSpy = vi.spyOn(fsSync, "readFileSync").mockImplementation((filePath, encoding) => {
if (filePath === `/proc/${process.pid}/stat`) {
throw new Error("EACCES");
}
return readFileSync(filePath as never, encoding as never) as never;
});
const lock = await acquireGatewayLock({
env,
allowInTests: true,
timeoutMs: 200,
pollIntervalMs: 20,
staleMs: 1,
platform: "linux",
});
expect(lock).not.toBeNull();
await lock?.release();
staleSpy.mockRestore();
await cleanup();
});
});

View File

@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
@@ -13,7 +12,6 @@ type LockPayload = {
pid: number;
createdAt: string;
configPath: string;
startTime?: number;
};
export type GatewayLockHandle = {
@@ -28,7 +26,6 @@ export type GatewayLockOptions = {
pollIntervalMs?: number;
staleMs?: number;
allowInTests?: boolean;
platform?: NodeJS.Platform;
};
export class GatewayLockError extends Error {
@@ -41,8 +38,6 @@ export class GatewayLockError extends Error {
}
}
type LockOwnerStatus = "alive" | "dead" | "unknown";
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
@@ -53,80 +48,6 @@ function isAlive(pid: number): boolean {
}
}
function normalizeProcArg(arg: string): string {
return arg.replaceAll("\\", "/").toLowerCase();
}
function parseProcCmdline(raw: string): string[] {
return raw
.split("\0")
.map((entry) => entry.trim())
.filter(Boolean);
}
function isGatewayArgv(args: string[]): boolean {
const normalized = args.map(normalizeProcArg);
if (!normalized.includes("gateway")) return false;
const entryCandidates = [
"dist/index.js",
"dist/index.mjs",
"dist/entry.js",
"dist/entry.mjs",
"scripts/run-node.mjs",
"src/index.ts",
];
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
return true;
}
const exe = normalized[0] ?? "";
return exe.endsWith("/clawdbot") || exe === "clawdbot";
}
function readLinuxCmdline(pid: number): string[] | null {
try {
const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8");
return parseProcCmdline(raw);
} catch {
return null;
}
}
function readLinuxStartTime(pid: number): number | null {
try {
const raw = fsSync.readFileSync(`/proc/${pid}/stat`, "utf8").trim();
const closeParen = raw.lastIndexOf(")");
if (closeParen < 0) return null;
const rest = raw.slice(closeParen + 1).trim();
const fields = rest.split(/\s+/);
const startTime = Number.parseInt(fields[19] ?? "", 10);
return Number.isFinite(startTime) ? startTime : null;
} catch {
return null;
}
}
function resolveGatewayOwnerStatus(
pid: number,
payload: LockPayload | null,
platform: NodeJS.Platform,
): LockOwnerStatus {
if (!isAlive(pid)) return "dead";
if (platform !== "linux") return "alive";
const payloadStartTime = payload?.startTime;
if (Number.isFinite(payloadStartTime)) {
const currentStartTime = readLinuxStartTime(pid);
if (currentStartTime == null) return "unknown";
return currentStartTime === payloadStartTime ? "alive" : "dead";
}
const args = readLinuxCmdline(pid);
if (!args) return "unknown";
return isGatewayArgv(args) ? "alive" : "dead";
}
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
@@ -134,12 +55,10 @@ async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
if (typeof parsed.pid !== "number") return null;
if (typeof parsed.createdAt !== "string") return null;
if (typeof parsed.configPath !== "string") return null;
const startTime = typeof parsed.startTime === "number" ? parsed.startTime : undefined;
return {
pid: parsed.pid,
createdAt: parsed.createdAt,
configPath: parsed.configPath,
startTime,
};
} catch {
return null;
@@ -169,7 +88,6 @@ export async function acquireGatewayLock(
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
const platform = opts.platform ?? process.platform;
const { lockPath, configPath } = resolveGatewayLockPath(env);
await fs.mkdir(path.dirname(lockPath), { recursive: true });
@@ -179,15 +97,11 @@ export async function acquireGatewayLock(
while (Date.now() - startedAt < timeoutMs) {
try {
const handle = await fs.open(lockPath, "wx");
const startTime = platform === "linux" ? readLinuxStartTime(process.pid) : null;
const payload: LockPayload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
};
if (typeof startTime === "number" && Number.isFinite(startTime)) {
payload.startTime = startTime;
}
await handle.writeFile(JSON.stringify(payload), "utf8");
return {
lockPath,
@@ -205,14 +119,12 @@ export async function acquireGatewayLock(
lastPayload = await readLockPayload(lockPath);
const ownerPid = lastPayload?.pid;
const ownerStatus = ownerPid
? resolveGatewayOwnerStatus(ownerPid, lastPayload, platform)
: "unknown";
if (ownerStatus === "dead" && ownerPid) {
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
if (!ownerAlive && ownerPid) {
await fs.rm(lockPath, { force: true });
continue;
}
if (ownerStatus !== "alive") {
if (!ownerAlive) {
let stale = false;
if (lastPayload?.createdAt) {
const createdAt = Date.parse(lastPayload.createdAt);

View File

@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
clearPluginCommands,
executePluginCommand,
matchPluginCommand,
registerPluginCommand,
validateCommandName,
} from "./commands.js";
describe("validateCommandName", () => {
it("rejects reserved aliases from built-in commands", () => {
const error = validateCommandName("id");
expect(error).toContain("reserved");
});
});
describe("plugin command registry", () => {
beforeEach(() => {
clearPluginCommands();
});
it("normalizes command names for registration and matching", () => {
const result = registerPluginCommand("plugin-core", {
name: " ping ",
description: "Ping",
handler: () => ({ text: "pong" }),
});
expect(result.ok).toBe(true);
const match = matchPluginCommand("/ping");
expect(match?.command.name).toBe("ping");
});
it("blocks registration while a command is executing", async () => {
let nestedResult: { ok: boolean; error?: string } | undefined;
registerPluginCommand("plugin-core", {
name: "outer",
description: "Outer",
handler: () => {
nestedResult = registerPluginCommand("plugin-inner", {
name: "inner",
description: "Inner",
handler: () => ({ text: "ok" }),
});
return { text: "done" };
},
});
await executePluginCommand({
command: matchPluginCommand("/outer")!.command,
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
commandBody: "/outer",
config: {} as ClawdbotConfig,
});
expect(nestedResult?.ok).toBe(false);
expect(nestedResult?.error).toContain("processing is in progress");
});
});

View File

@@ -6,6 +6,7 @@
*/
import type { ClawdbotConfig } from "../config/config.js";
import { listChatCommands } from "../auto-reply/commands-registry.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import { logVerbose } from "../globals.js";
@@ -16,53 +17,29 @@ type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
// Lock to prevent modifications during command execution
let registryLocked = false;
// Lock counter to prevent modifications during command execution
let registryLockCount = 0;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
/**
* Reserved command names that plugins cannot override.
* These are built-in commands from commands-registry.data.ts.
*/
const RESERVED_COMMANDS = new Set([
// Core commands
"help",
"commands",
"status",
"whoami",
"context",
// Session management
"stop",
"restart",
"reset",
"new",
"compact",
// Configuration
"config",
"debug",
"allowlist",
"activation",
// Agent control
"skill",
"subagents",
"model",
"models",
"queue",
// Messaging
"send",
// Execution
"bash",
"exec",
// Mode toggles
"think",
"verbose",
"reasoning",
"elevated",
// Billing
"usage",
]);
function getReservedCommands(): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
const normalized = command.nativeName.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
for (const alias of command.textAliases ?? []) {
const trimmed = alias.trim();
if (!trimmed) continue;
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
const normalized = withoutSlash.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
}
return reserved;
}
/**
* Validate a command name.
@@ -82,7 +59,7 @@ export function validateCommandName(name: string): string | null {
}
// Check reserved commands
if (RESERVED_COMMANDS.has(trimmed)) {
if (getReservedCommands().has(trimmed)) {
return `Command name "${trimmed}" is reserved by a built-in command`;
}
@@ -103,7 +80,7 @@ export function registerPluginCommand(
command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLocked) {
if (registryLockCount > 0) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
@@ -117,7 +94,8 @@ export function registerPluginCommand(
return { ok: false, error: validationError };
}
const key = `/${command.name.toLowerCase()}`;
const normalizedName = command.name.trim();
const key = `/${normalizedName.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
@@ -128,7 +106,7 @@ export function registerPluginCommand(
};
}
pluginCommands.set(key, { ...command, pluginId });
pluginCommands.set(key, { ...command, name: normalizedName, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };
}
@@ -139,6 +117,7 @@ export function registerPluginCommand(
*/
export function clearPluginCommands(): void {
pluginCommands.clear();
registryLockCount = 0;
}
/**
@@ -190,16 +169,26 @@ function sanitizeArgs(args: string | undefined): string | undefined {
if (!args) return undefined;
// Enforce length limit
if (args.length > MAX_ARGS_LENGTH) {
return args.slice(0, MAX_ARGS_LENGTH);
}
const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args;
// Remove control characters (except newlines and tabs which may be intentional)
let needsSanitize = false;
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a) continue;
if (code < 0x20 || code === 0x7f) {
needsSanitize = true;
break;
}
}
if (!needsSanitize) return trimmed;
let sanitized = "";
for (const char of args) {
const code = char.charCodeAt(0);
const isControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f;
if (!isControl) sanitized += char;
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) {
sanitized += trimmed[i];
}
}
return sanitized;
}
@@ -243,7 +232,7 @@ export async function executePluginCommand(params: {
};
// Lock registry during execution to prevent concurrent modifications
registryLocked = true;
registryLockCount += 1;
try {
const result = await command.handler(ctx);
logVerbose(
@@ -256,7 +245,7 @@ export async function executePluginCommand(params: {
// Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLocked = false;
registryLockCount = Math.max(0, registryLockCount - 1);
}
}
@@ -282,9 +271,11 @@ export function listPluginCommands(): Array<{
export function getPluginCommandSpecs(): Array<{
name: string;
description: string;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
acceptsArgs: Boolean(cmd.acceptsArgs),
}));
}

View File

@@ -376,7 +376,8 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
// Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command);
const normalizedCommand = { ...command, name };
const result = registerPluginCommand(record.id, normalizedCommand);
if (!result.ok) {
pushDiagnostic({
level: "error",
@@ -390,7 +391,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
command,
command: normalizedCommand,
source: record.source,
});
};

View File

@@ -60,61 +60,3 @@ describe("resolveSlackSystemEventSessionKey", () => {
);
});
});
describe("isChannelAllowed with groupPolicy and channelsConfig", () => {
it("allows unlisted channels when groupPolicy is open even with channelsConfig entries", () => {
// Bug fix: when groupPolicy="open" and channels has some entries,
// unlisted channels should still be allowed (not blocked)
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: {
C_LISTED: { requireMention: true },
},
});
// Listed channel should be allowed
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
// Unlisted channel should ALSO be allowed when policy is "open"
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
});
it("blocks unlisted channels when groupPolicy is allowlist", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "allowlist",
channelsConfig: {
C_LISTED: { requireMention: true },
},
});
// Listed channel should be allowed
expect(ctx.isChannelAllowed({ channelId: "C_LISTED", channelType: "channel" })).toBe(true);
// Unlisted channel should be blocked when policy is "allowlist"
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(false);
});
it("blocks explicitly denied channels even when groupPolicy is open", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: {
C_ALLOWED: { allow: true },
C_DENIED: { allow: false },
},
});
// Explicitly allowed channel
expect(ctx.isChannelAllowed({ channelId: "C_ALLOWED", channelType: "channel" })).toBe(true);
// Explicitly denied channel should be blocked even with open policy
expect(ctx.isChannelAllowed({ channelId: "C_DENIED", channelType: "channel" })).toBe(false);
// Unlisted channel should be allowed with open policy
expect(ctx.isChannelAllowed({ channelId: "C_UNLISTED", channelType: "channel" })).toBe(true);
});
it("allows all channels when groupPolicy is open and channelsConfig is empty", () => {
const ctx = createSlackMonitorContext({
...baseParams(),
groupPolicy: "open",
channelsConfig: undefined,
});
expect(ctx.isChannelAllowed({ channelId: "C_ANY", channelType: "channel" })).toBe(true);
});
});

View File

@@ -327,11 +327,7 @@ export function createSlackMonitorContext(params: {
);
return false;
}
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
// (i.e., have a matching config entry with allow:false). Channels not in the
// config (matchSource undefined) should be allowed under open policy.
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
if (!channelAllowed) {
logVerbose(`slack: drop channel ${p.channelId} (${channelMatchMeta})`);
return false;
}

View File

@@ -1,186 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSlackMonitorSlashCommands } from "./slash.js";
const dispatchMock = vi.fn();
const readAllowFromStoreMock = vi.fn();
const upsertPairingRequestMock = vi.fn();
const resolveAgentRouteMock = vi.fn();
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
}));
vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../routing/resolve-route.js", () => ({
resolveAgentRoute: (...args: unknown[]) => resolveAgentRouteMock(...args),
}));
vi.mock("../../agents/identity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/identity.js")>();
return {
...actual,
resolveEffectiveMessagesConfig: () => ({ responsePrefix: "" }),
};
});
function createHarness(overrides?: {
groupPolicy?: "open" | "allowlist";
channelsConfig?: Record<string, { allow?: boolean; requireMention?: boolean }>;
channelId?: string;
channelName?: string;
}) {
const commands = new Map<unknown, (args: unknown) => Promise<void>>();
const postEphemeral = vi.fn().mockResolvedValue({ ok: true });
const app = {
client: { chat: { postEphemeral } },
command: (name: unknown, handler: (args: unknown) => Promise<void>) => {
commands.set(name, handler);
},
};
const channelId = overrides?.channelId ?? "C_UNLISTED";
const channelName = overrides?.channelName ?? "unlisted";
const ctx = {
cfg: { commands: { native: false } },
runtime: {},
botToken: "bot-token",
botUserId: "bot",
teamId: "T1",
allowFrom: ["*"],
dmEnabled: true,
dmPolicy: "open",
groupDmEnabled: false,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: overrides?.groupPolicy ?? "open",
useAccessGroups: true,
channelsConfig: overrides?.channelsConfig,
slashCommand: { enabled: true, name: "clawd", ephemeral: true, sessionPrefix: "slack:slash" },
textLimit: 4000,
app,
isChannelAllowed: () => true,
resolveChannelName: async () => ({ name: channelName, type: "channel" }),
resolveUserName: async () => ({ name: "Ada" }),
} as unknown;
const account = { accountId: "acct", config: { commands: { native: false } } } as unknown;
return { commands, ctx, account, postEphemeral, channelId, channelName };
}
beforeEach(() => {
dispatchMock.mockReset().mockResolvedValue({ counts: { final: 1, tool: 0, block: 0 } });
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
resolveAgentRouteMock.mockReset().mockReturnValue({
agentId: "main",
sessionKey: "session:1",
accountId: "acct",
});
});
describe("slack slash commands channel policy", () => {
it("allows unlisted channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "open",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).toHaveBeenCalledTimes(1);
expect(respond).not.toHaveBeenCalledWith(
expect.objectContaining({ text: "This channel is not allowed." }),
);
});
it("blocks explicitly denied channels when groupPolicy is open", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "open",
channelsConfig: { C_DENIED: { allow: false } },
channelId: "C_DENIED",
channelName: "denied",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
});
it("blocks unlisted channels when groupPolicy is allowlist", async () => {
const { commands, ctx, account, channelId, channelName } = createHarness({
groupPolicy: "allowlist",
channelsConfig: { C_LISTED: { requireMention: true } },
channelId: "C_UNLISTED",
channelName: "unlisted",
});
registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
const handler = [...commands.values()][0];
if (!handler) throw new Error("Missing slash handler");
const respond = vi.fn().mockResolvedValue(undefined);
await handler({
command: {
user_id: "U1",
user_name: "Ada",
channel_id: channelId,
channel_name: channelName,
text: "hello",
trigger_id: "t1",
},
ack: vi.fn().mockResolvedValue(undefined),
respond,
});
expect(dispatchMock).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
});
});

View File

@@ -262,7 +262,8 @@ export function registerSlackMonitorSlashCommands(params: {
groupPolicy: ctx.groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
}) ||
!channelAllowed
) {
await respond({
text: "This channel is not allowed.",
@@ -270,17 +271,13 @@ export function registerSlackMonitorSlashCommands(params: {
});
return;
}
// When groupPolicy is "open", only block channels that are EXPLICITLY denied
// (i.e., have a matching config entry with allow:false). Channels not in the
// config (matchSource undefined) should be allowed under open policy.
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (ctx.groupPolicy !== "open" || hasExplicitConfig)) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}
if (ctx.useAccessGroups && channelConfig?.allowed === false) {
await respond({
text: "This channel is not allowed.",
response_type: "ephemeral",
});
return;
}
}

View File

@@ -6,6 +6,7 @@ import {
findCommandByNativeName,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
parseCommandArgs,
resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js";
@@ -84,13 +85,28 @@ export const registerTelegramNativeCommands = ({
}: RegisterTelegramNativeCommandsParams) => {
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled
const rawNativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: [];
const nativeCommands = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: rawNativeCommands,
});
const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: listNativeCommandSpecs(),
}).map((command) => command.name.toLowerCase()),
);
for (const command of skillCommands) {
const reservedSkillSpecs = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: skillCommands.map((command) => ({
name: command.name,
description: command.description,
acceptsArgs: true,
})),
});
for (const command of reservedSkillSpecs) {
reservedCommands.add(command.name.toLowerCase());
}
const customResolution = resolveTelegramCustomCommands({

View File

@@ -1,234 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { _test } from "./tts.js";
const {
isValidVoiceId,
isValidOpenAIVoice,
isValidOpenAIModel,
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
summarizeText,
resolveOutputFormat,
} = _test;
describe("tts", () => {
describe("isValidVoiceId", () => {
it("accepts valid ElevenLabs voice IDs", () => {
expect(isValidVoiceId("pMsXgVXv3BLzUgSXRplE")).toBe(true);
expect(isValidVoiceId("21m00Tcm4TlvDq8ikWAM")).toBe(true);
expect(isValidVoiceId("EXAVITQu4vr4xnSDxMaL")).toBe(true);
});
it("accepts voice IDs of varying valid lengths", () => {
expect(isValidVoiceId("a1b2c3d4e5")).toBe(true);
expect(isValidVoiceId("a".repeat(40))).toBe(true);
});
it("rejects too short voice IDs", () => {
expect(isValidVoiceId("")).toBe(false);
expect(isValidVoiceId("abc")).toBe(false);
expect(isValidVoiceId("123456789")).toBe(false);
});
it("rejects too long voice IDs", () => {
expect(isValidVoiceId("a".repeat(41))).toBe(false);
expect(isValidVoiceId("a".repeat(100))).toBe(false);
});
it("rejects voice IDs with invalid characters", () => {
expect(isValidVoiceId("pMsXgVXv3BLz-gSXRplE")).toBe(false);
expect(isValidVoiceId("pMsXgVXv3BLz_gSXRplE")).toBe(false);
expect(isValidVoiceId("pMsXgVXv3BLz gSXRplE")).toBe(false);
expect(isValidVoiceId("../../../etc/passwd")).toBe(false);
expect(isValidVoiceId("voice?param=value")).toBe(false);
});
});
describe("isValidOpenAIVoice", () => {
it("accepts all valid OpenAI voices", () => {
for (const voice of OPENAI_TTS_VOICES) {
expect(isValidOpenAIVoice(voice)).toBe(true);
}
});
it("rejects invalid voice names", () => {
expect(isValidOpenAIVoice("invalid")).toBe(false);
expect(isValidOpenAIVoice("")).toBe(false);
expect(isValidOpenAIVoice("ALLOY")).toBe(false);
expect(isValidOpenAIVoice("alloy ")).toBe(false);
expect(isValidOpenAIVoice(" alloy")).toBe(false);
});
});
describe("isValidOpenAIModel", () => {
it("accepts gpt-4o-mini-tts model", () => {
expect(isValidOpenAIModel("gpt-4o-mini-tts")).toBe(true);
});
it("rejects other models", () => {
expect(isValidOpenAIModel("tts-1")).toBe(false);
expect(isValidOpenAIModel("tts-1-hd")).toBe(false);
expect(isValidOpenAIModel("invalid")).toBe(false);
expect(isValidOpenAIModel("")).toBe(false);
expect(isValidOpenAIModel("gpt-4")).toBe(false);
});
});
describe("OPENAI_TTS_MODELS", () => {
it("contains only gpt-4o-mini-tts", () => {
expect(OPENAI_TTS_MODELS).toContain("gpt-4o-mini-tts");
expect(OPENAI_TTS_MODELS).toHaveLength(1);
});
it("is a non-empty array", () => {
expect(Array.isArray(OPENAI_TTS_MODELS)).toBe(true);
expect(OPENAI_TTS_MODELS.length).toBeGreaterThan(0);
});
});
describe("resolveOutputFormat", () => {
it("uses Opus for Telegram", () => {
const output = resolveOutputFormat("telegram");
expect(output.openai).toBe("opus");
expect(output.elevenlabs).toBe("opus_48000_64");
expect(output.extension).toBe(".opus");
expect(output.voiceCompatible).toBe(true);
});
it("uses MP3 for other channels", () => {
const output = resolveOutputFormat("discord");
expect(output.openai).toBe("mp3");
expect(output.elevenlabs).toBe("mp3_44100_128");
expect(output.extension).toBe(".mp3");
expect(output.voiceCompatible).toBe(false);
});
});
describe("summarizeText", () => {
const mockApiKey = "test-api-key";
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("summarizes text and returns result with metrics", async () => {
const mockSummary = "This is a summarized version of the text.";
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: mockSummary } }],
}),
});
const longText = "A".repeat(2000);
const result = await summarizeText(longText, 1500, mockApiKey, 30_000);
expect(result.summary).toBe(mockSummary);
expect(result.inputLength).toBe(2000);
expect(result.outputLength).toBe(mockSummary.length);
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
it("calls OpenAI API with correct parameters", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "Summary" } }],
}),
});
await summarizeText("Long text to summarize", 500, mockApiKey, 30_000);
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://api.openai.com/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
Authorization: `Bearer ${mockApiKey}`,
"Content-Type": "application/json",
},
}),
);
const callArgs = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(callArgs[1].body);
expect(body.model).toBe("gpt-4o-mini");
expect(body.temperature).toBe(0.3);
expect(body.max_tokens).toBe(250);
});
it("rejects targetLength below minimum (100)", async () => {
await expect(summarizeText("text", 99, mockApiKey, 30_000)).rejects.toThrow(
"Invalid targetLength: 99",
);
});
it("rejects targetLength above maximum (10000)", async () => {
await expect(summarizeText("text", 10001, mockApiKey, 30_000)).rejects.toThrow(
"Invalid targetLength: 10001",
);
});
it("accepts targetLength at boundaries", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "Summary" } }],
}),
});
await expect(summarizeText("text", 100, mockApiKey, 30_000)).resolves.toBeDefined();
await expect(summarizeText("text", 10000, mockApiKey, 30_000)).resolves.toBeDefined();
});
it("throws error when API returns non-ok response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
"Summarization service unavailable",
);
});
it("throws error when no summary is returned", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [],
}),
});
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
"No summary returned",
);
});
it("throws error when summary content is empty", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: " " } }],
}),
});
await expect(summarizeText("text", 500, mockApiKey, 30_000)).rejects.toThrow(
"No summary returned",
);
});
});
});

View File

@@ -1,630 +0,0 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
mkdtempSync,
rmSync,
renameSync,
unlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { ReplyPayload } from "../auto-reply/types.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { TtsConfig, TtsMode, TtsProvider } from "../config/types.tts.js";
import { logVerbose } from "../globals.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4000;
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_ELEVENLABS_VOICE_ID = "pMsXgVXv3BLzUgSXRplE";
const DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2";
const DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts";
const DEFAULT_OPENAI_VOICE = "alloy";
const TELEGRAM_OUTPUT = {
openai: "opus" as const,
// ElevenLabs output formats use codec_sample_rate_bitrate naming.
// Opus @ 48kHz/64kbps is a good voice-note tradeoff for Telegram.
elevenlabs: "opus_48000_64",
extension: ".opus",
voiceCompatible: true,
};
const DEFAULT_OUTPUT = {
openai: "mp3" as const,
elevenlabs: "mp3_44100_128",
extension: ".mp3",
voiceCompatible: false,
};
export type ResolvedTtsConfig = {
enabled: boolean;
mode: TtsMode;
provider: TtsProvider;
elevenlabs: {
apiKey?: string;
voiceId: string;
modelId: string;
};
openai: {
apiKey?: string;
model: string;
voice: string;
};
prefsPath?: string;
maxTextLength: number;
timeoutMs: number;
};
type TtsUserPrefs = {
tts?: {
enabled?: boolean;
provider?: TtsProvider;
maxLength?: number;
summarize?: boolean;
};
};
export type TtsResult = {
success: boolean;
audioPath?: string;
error?: string;
latencyMs?: number;
provider?: string;
outputFormat?: string;
voiceCompatible?: boolean;
};
type TtsStatusEntry = {
timestamp: number;
success: boolean;
textLength: number;
summarized: boolean;
provider?: string;
latencyMs?: number;
error?: string;
};
let lastTtsAttempt: TtsStatusEntry | undefined;
export function resolveTtsConfig(cfg: ClawdbotConfig): ResolvedTtsConfig {
const raw: TtsConfig = cfg.messages?.tts ?? {};
return {
enabled: raw.enabled ?? false,
mode: raw.mode ?? "final",
provider: raw.provider ?? "elevenlabs",
elevenlabs: {
apiKey: raw.elevenlabs?.apiKey,
voiceId: raw.elevenlabs?.voiceId ?? DEFAULT_ELEVENLABS_VOICE_ID,
modelId: raw.elevenlabs?.modelId ?? DEFAULT_ELEVENLABS_MODEL_ID,
},
openai: {
apiKey: raw.openai?.apiKey,
model: raw.openai?.model ?? DEFAULT_OPENAI_MODEL,
voice: raw.openai?.voice ?? DEFAULT_OPENAI_VOICE,
},
prefsPath: raw.prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs: raw.timeoutMs ?? DEFAULT_TIMEOUT_MS,
};
}
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
if (config.prefsPath?.trim()) return resolveUserPath(config.prefsPath.trim());
const envPath = process.env.CLAWDBOT_TTS_PREFS?.trim();
if (envPath) return resolveUserPath(envPath);
return path.join(CONFIG_DIR, "settings", "tts.json");
}
function readPrefs(prefsPath: string): TtsUserPrefs {
try {
if (!existsSync(prefsPath)) return {};
return JSON.parse(readFileSync(prefsPath, "utf8")) as TtsUserPrefs;
} catch {
return {};
}
}
function atomicWriteFileSync(filePath: string, content: string): void {
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
writeFileSync(tmpPath, content);
try {
renameSync(tmpPath, filePath);
} catch (err) {
try {
unlinkSync(tmpPath);
} catch {
// ignore
}
throw err;
}
}
function updatePrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void {
const prefs = readPrefs(prefsPath);
update(prefs);
mkdirSync(path.dirname(prefsPath), { recursive: true });
atomicWriteFileSync(prefsPath, JSON.stringify(prefs, null, 2));
}
export function isTtsEnabled(config: ResolvedTtsConfig, prefsPath: string): boolean {
const prefs = readPrefs(prefsPath);
if (prefs.tts?.enabled !== undefined) return prefs.tts.enabled === true;
return config.enabled;
}
export function setTtsEnabled(prefsPath: string, enabled: boolean): void {
updatePrefs(prefsPath, (prefs) => {
prefs.tts = { ...prefs.tts, enabled };
});
}
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
const prefs = readPrefs(prefsPath);
return prefs.tts?.provider ?? config.provider;
}
export function setTtsProvider(prefsPath: string, provider: TtsProvider): void {
updatePrefs(prefsPath, (prefs) => {
prefs.tts = { ...prefs.tts, provider };
});
}
export function getTtsMaxLength(prefsPath: string): number {
const prefs = readPrefs(prefsPath);
return prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
}
export function setTtsMaxLength(prefsPath: string, maxLength: number): void {
updatePrefs(prefsPath, (prefs) => {
prefs.tts = { ...prefs.tts, maxLength };
});
}
export function isSummarizationEnabled(prefsPath: string): boolean {
const prefs = readPrefs(prefsPath);
return prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
}
export function setSummarizationEnabled(prefsPath: string, enabled: boolean): void {
updatePrefs(prefsPath, (prefs) => {
prefs.tts = { ...prefs.tts, summarize: enabled };
});
}
export function getLastTtsAttempt(): TtsStatusEntry | undefined {
return lastTtsAttempt;
}
export function setLastTtsAttempt(entry: TtsStatusEntry | undefined): void {
lastTtsAttempt = entry;
}
function resolveOutputFormat(channelId?: string | null) {
if (channelId === "telegram") return TELEGRAM_OUTPUT;
return DEFAULT_OUTPUT;
}
function resolveChannelId(channel: string | undefined): ChannelId | null {
return channel ? normalizeChannelId(channel) : null;
}
export function resolveTtsApiKey(
config: ResolvedTtsConfig,
provider: TtsProvider,
): string | undefined {
if (provider === "elevenlabs") {
return config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY;
}
if (provider === "openai") {
return config.openai.apiKey || process.env.OPENAI_API_KEY;
}
return undefined;
}
function isValidVoiceId(voiceId: string): boolean {
return /^[a-zA-Z0-9]{10,40}$/.test(voiceId);
}
export const OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"] as const;
export const OPENAI_TTS_VOICES = [
"alloy",
"ash",
"coral",
"echo",
"fable",
"onyx",
"nova",
"sage",
"shimmer",
] as const;
type OpenAiTtsVoice = (typeof OPENAI_TTS_VOICES)[number];
function isValidOpenAIModel(model: string): boolean {
return OPENAI_TTS_MODELS.includes(model as (typeof OPENAI_TTS_MODELS)[number]);
}
function isValidOpenAIVoice(voice: string): voice is OpenAiTtsVoice {
return OPENAI_TTS_VOICES.includes(voice as OpenAiTtsVoice);
}
type SummarizeResult = {
summary: string;
latencyMs: number;
inputLength: number;
outputLength: number;
};
async function summarizeText(
text: string,
targetLength: number,
apiKey: string,
timeoutMs: number,
): Promise<SummarizeResult> {
if (targetLength < 100 || targetLength > 10_000) {
throw new Error(`Invalid targetLength: ${targetLength}`);
}
const startTime = Date.now();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `You are an assistant that summarizes texts concisely while keeping the most important information. Summarize the text to approximately ${targetLength} characters. Maintain the original tone and style. Reply only with the summary, without additional explanations.`,
},
{
role: "user",
content: `<text_to_summarize>\n${text}\n</text_to_summarize>`,
},
],
max_tokens: Math.ceil(targetLength / 2),
temperature: 0.3,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error("Summarization service unavailable");
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const summary = data.choices?.[0]?.message?.content?.trim();
if (!summary) {
throw new Error("No summary returned");
}
return {
summary,
latencyMs: Date.now() - startTime,
inputLength: text.length,
outputLength: summary.length,
};
} finally {
clearTimeout(timeout);
}
}
function scheduleCleanup(tempDir: string, delayMs: number = TEMP_FILE_CLEANUP_DELAY_MS): void {
const timer = setTimeout(() => {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}, delayMs);
timer.unref();
}
async function elevenLabsTTS(params: {
text: string;
apiKey: string;
voiceId: string;
modelId: string;
outputFormat: string;
timeoutMs: number;
}): Promise<Buffer> {
const { text, apiKey, voiceId, modelId, outputFormat, timeoutMs } = params;
if (!isValidVoiceId(voiceId)) {
throw new Error("Invalid voiceId format");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const url = new URL(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`);
if (outputFormat) {
url.searchParams.set("output_format", outputFormat);
}
const response = await fetch(url.toString(), {
method: "POST",
headers: {
"xi-api-key": apiKey,
"Content-Type": "application/json",
Accept: "audio/mpeg",
},
body: JSON.stringify({
text,
model_id: modelId,
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
style: 0.0,
use_speaker_boost: true,
},
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`ElevenLabs API error (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
} finally {
clearTimeout(timeout);
}
}
async function openaiTTS(params: {
text: string;
apiKey: string;
model: string;
voice: string;
responseFormat: "mp3" | "opus";
timeoutMs: number;
}): Promise<Buffer> {
const { text, apiKey, model, voice, responseFormat, timeoutMs } = params;
if (!isValidOpenAIModel(model)) {
throw new Error(`Invalid model: ${model}`);
}
if (!isValidOpenAIVoice(voice)) {
throw new Error(`Invalid voice: ${voice}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch("https://api.openai.com/v1/audio/speech", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
input: text,
voice,
response_format: responseFormat,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`OpenAI TTS API error (${response.status})`);
}
return Buffer.from(await response.arrayBuffer());
} finally {
clearTimeout(timeout);
}
}
export async function textToSpeech(params: {
text: string;
cfg: ClawdbotConfig;
prefsPath?: string;
channel?: string;
}): Promise<TtsResult> {
const config = resolveTtsConfig(params.cfg);
const prefsPath = params.prefsPath ?? resolveTtsPrefsPath(config);
const channelId = resolveChannelId(params.channel);
const output = resolveOutputFormat(channelId);
if (params.text.length > config.maxTextLength) {
return {
success: false,
error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})`,
};
}
const userProvider = getTtsProvider(config, prefsPath);
const providers: TtsProvider[] = [
userProvider,
userProvider === "openai" ? "elevenlabs" : "openai",
];
let lastError: string | undefined;
for (const provider of providers) {
const apiKey = resolveTtsApiKey(config, provider);
if (!apiKey) {
lastError = `No API key for ${provider}`;
continue;
}
const providerStart = Date.now();
try {
let audioBuffer: Buffer;
if (provider === "elevenlabs") {
audioBuffer = await elevenLabsTTS({
text: params.text,
apiKey,
voiceId: config.elevenlabs.voiceId,
modelId: config.elevenlabs.modelId,
outputFormat: output.elevenlabs,
timeoutMs: config.timeoutMs,
});
} else {
audioBuffer = await openaiTTS({
text: params.text,
apiKey,
model: config.openai.model,
voice: config.openai.voice,
responseFormat: output.openai,
timeoutMs: config.timeoutMs,
});
}
const latencyMs = Date.now() - providerStart;
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
const audioPath = path.join(tempDir, `voice-${Date.now()}${output.extension}`);
writeFileSync(audioPath, audioBuffer);
scheduleCleanup(tempDir);
return {
success: true,
audioPath,
latencyMs,
provider,
outputFormat: provider === "openai" ? output.openai : output.elevenlabs,
voiceCompatible: output.voiceCompatible,
};
} catch (err) {
const error = err as Error;
if (error.name === "AbortError") {
lastError = `${provider}: request timed out`;
} else {
lastError = `${provider}: ${error.message}`;
}
}
}
return {
success: false,
error: `TTS conversion failed: ${lastError || "no providers available"}`,
};
}
export async function maybeApplyTtsToPayload(params: {
payload: ReplyPayload;
cfg: ClawdbotConfig;
channel?: string;
kind?: "tool" | "block" | "final";
}): Promise<ReplyPayload> {
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
if (!isTtsEnabled(config, prefsPath)) return params.payload;
const mode = config.mode ?? "final";
if (mode === "final" && params.kind && params.kind !== "final") return params.payload;
const text = params.payload.text ?? "";
if (!text.trim()) return params.payload;
if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) return params.payload;
if (text.includes("MEDIA:")) return params.payload;
if (text.trim().length < 10) return params.payload;
const maxLength = getTtsMaxLength(prefsPath);
let textForAudio = text.trim();
let wasSummarized = false;
if (textForAudio.length > maxLength) {
if (!isSummarizationEnabled(prefsPath)) {
logVerbose(
`TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
return params.payload;
}
const openaiKey = resolveTtsApiKey(config, "openai");
if (!openaiKey) {
logVerbose("TTS: skipping summarization - OpenAI key missing.");
return params.payload;
}
try {
const summary = await summarizeText(textForAudio, maxLength, openaiKey, config.timeoutMs);
textForAudio = summary.summary;
wasSummarized = true;
if (textForAudio.length > config.maxTextLength) {
logVerbose(
`TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
);
textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
}
} catch (err) {
const error = err as Error;
logVerbose(`TTS: summarization failed: ${error.message}`);
return params.payload;
}
}
const ttsStart = Date.now();
const result = await textToSpeech({
text: textForAudio,
cfg: params.cfg,
prefsPath,
channel: params.channel,
});
if (result.success && result.audioPath) {
lastTtsAttempt = {
timestamp: Date.now(),
success: true,
textLength: text.length,
summarized: wasSummarized,
provider: result.provider,
latencyMs: result.latencyMs,
};
const channelId = resolveChannelId(params.channel);
const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
return {
...params.payload,
mediaUrl: result.audioPath,
audioAsVoice: shouldVoice || params.payload.audioAsVoice,
};
}
lastTtsAttempt = {
timestamp: Date.now(),
success: false,
textLength: text.length,
summarized: wasSummarized,
error: result.error,
};
const latency = Date.now() - ttsStart;
logVerbose(`TTS: conversion failed after ${latency}ms (${result.error ?? "unknown"}).`);
return params.payload;
}
export const _test = {
isValidVoiceId,
isValidOpenAIVoice,
isValidOpenAIModel,
OPENAI_TTS_MODELS,
OPENAI_TTS_VOICES,
summarizeText,
resolveOutputFormat,
};

View File

@@ -1,216 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createEventHandlers } from "./tui-event-handlers.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type MockChatLog = {
startTool: ReturnType<typeof vi.fn>;
updateToolResult: ReturnType<typeof vi.fn>;
addSystem: ReturnType<typeof vi.fn>;
updateAssistant: ReturnType<typeof vi.fn>;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
agentDefaultId: "main",
sessionMainKey: "agent:main:main",
sessionScope: "global",
agents: [],
currentAgentId: "main",
currentSessionKey: "agent:main:main",
currentSessionId: "session-1",
activeChatRunId: "run-1",
historyLoaded: true,
sessionInfo: {},
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
toolsExpanded: false,
showThinking: false,
connectionStatus: "connected",
activityStatus: "idle",
statusTimeout: null,
lastCtrlCAt: 0,
...overrides,
});
const makeContext = (state: TuiStateAccess) => {
const chatLog: MockChatLog = {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
};
const tui = { requestRender: vi.fn() };
const setActivityStatus = vi.fn();
return { chatLog, tui, state, setActivityStatus };
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-123",
stream: "tool",
data: {
phase: "start",
toolCallId: "tc1",
name: "exec",
args: { command: "echo hi" },
},
};
handleAgentEvent(evt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("ignores tool events when runId does not match activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-2",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(evt);
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(chatLog.updateToolResult).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("processes lifecycle events when runId matches activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-9",
stream: "lifecycle",
data: { phase: "start" },
};
handleAgentEvent(evt);
expect(setActivityStatus).toHaveBeenCalledWith("running");
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("captures runId from chat events when activeChatRunId is unset", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const chatEvt: ChatEvent = {
runId: "run-42",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
};
handleChatEvent(chatEvt);
expect(state.activeChatRunId).toBe("run-42");
const agentEvt: AgentEvent = {
runId: "run-42",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(agentEvt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
});
it("clears run mapping when the session changes", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-old",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
});
state.currentSessionKey = "agent:main:other";
state.activeChatRunId = null;
tui.requestRender.mockClear();
handleAgentEvent({
runId: "run-old",
stream: "tool",
data: { phase: "start", toolCallId: "tc2", name: "exec" },
});
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("ignores lifecycle updates for non-active runs in the same session", () => {
const state = makeState({ activeChatRunId: "run-active" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-other",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
});
setActivityStatus.mockClear();
tui.requestRender.mockClear();
handleAgentEvent({
runId: "run-other",
stream: "lifecycle",
data: { phase: "end" },
});
expect(setActivityStatus).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
});

View File

@@ -15,58 +15,33 @@ type EventHandlerContext = {
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const finalizedRuns = new Map<string, number>();
const sessionRuns = new Map<string, number>();
let streamAssembler = new TuiStreamAssembler();
let lastSessionKey = state.currentSessionKey;
const pruneRunMap = (runs: Map<string, number>) => {
if (runs.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of runs) {
if (runs.size <= 150) break;
if (ts < keepUntil) runs.delete(key);
}
if (runs.size > 200) {
for (const key of runs.keys()) {
runs.delete(key);
if (runs.size <= 150) break;
}
}
};
const syncSessionKey = () => {
if (state.currentSessionKey === lastSessionKey) return;
lastSessionKey = state.currentSessionKey;
finalizedRuns.clear();
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
};
const noteSessionRun = (runId: string) => {
sessionRuns.set(runId, Date.now());
pruneRunMap(sessionRuns);
};
const streamAssembler = new TuiStreamAssembler();
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
sessionRuns.delete(runId);
streamAssembler.drop(runId);
pruneRunMap(finalizedRuns);
if (finalizedRuns.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of finalizedRuns) {
if (finalizedRuns.size <= 150) break;
if (ts < keepUntil) finalizedRuns.delete(key);
}
if (finalizedRuns.size > 200) {
for (const key of finalizedRuns.keys()) {
finalizedRuns.delete(key);
if (finalizedRuns.size <= 150) break;
}
}
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
syncSessionKey();
if (evt.sessionKey !== state.currentSessionKey) return;
if (finalizedRuns.has(evt.runId)) {
if (evt.state === "delta") return;
if (evt.state === "final") return;
}
noteSessionRun(evt.runId);
if (!state.activeChatRunId) {
state.activeChatRunId = evt.runId;
}
if (evt.state === "delta") {
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
if (!displayText) return;
@@ -103,7 +78,6 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "aborted") {
chatLog.addSystem("run aborted");
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("aborted");
void refreshSessionInfo?.();
@@ -111,7 +85,6 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("error");
void refreshSessionInfo?.();
@@ -122,11 +95,7 @@ export function createEventHandlers(context: EventHandlerContext) {
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
syncSessionKey();
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
// active chat run id, not the session id.
const isActiveRun = evt.runId === state.activeChatRunId;
if (!isActiveRun && !sessionRuns.has(evt.runId)) return;
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
if (evt.stream === "tool") {
const data = evt.data ?? {};
const phase = asString(data.phase, "");
@@ -148,7 +117,6 @@ export function createEventHandlers(context: EventHandlerContext) {
return;
}
if (evt.stream === "lifecycle") {
if (!isActiveRun) return;
const phase = typeof evt.data?.phase === "string" ? evt.data.phase : "";
if (phase === "start") setActivityStatus("running");
if (phase === "end") setActivityStatus("idle");