Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b924af44c | ||
|
|
e083e49756 | ||
|
|
0dc131e9f3 | ||
|
|
cab8dcf456 | ||
|
|
0845b24e9d | ||
|
|
09a72f1ede | ||
|
|
2b8b3c4b10 | ||
|
|
8ea8801d06 | ||
|
|
c97bf23a4a | ||
|
|
3fff943ba1 | ||
|
|
90685ef814 | ||
|
|
a8f2ac5411 | ||
|
|
dea96a2c3d | ||
|
|
90ae2f541c | ||
|
|
d9a467fe3b | ||
|
|
aef88cd9f1 | ||
|
|
104d977d12 | ||
|
|
4b24753be7 | ||
|
|
df09e583aa | ||
|
|
46e6546bb9 | ||
|
|
5428c97685 | ||
|
|
202d7af855 | ||
|
|
72020b37c3 | ||
|
|
b051621bd4 | ||
|
|
ff52aec38e | ||
|
|
15620b1092 | ||
|
|
ad7fc4964a | ||
|
|
8f4426052c | ||
|
|
6a60d47c53 | ||
|
|
b1482957f5 | ||
|
|
4d2e9e8113 | ||
|
|
72d62a54c6 | ||
|
|
ae48066d28 | ||
|
|
f56f799990 | ||
|
|
7e498ab94a | ||
|
|
6bd6ae41b1 | ||
|
|
f648aae440 | ||
|
|
b56587f26e | ||
|
|
4ee808dbcb | ||
|
|
66eec295b8 | ||
|
|
675019cb6f | ||
|
|
795b592286 | ||
|
|
9d98e55ed5 | ||
|
|
c07949a99c | ||
|
|
e51bf46abe | ||
|
|
eba0625a70 | ||
|
|
886752217d | ||
|
|
5662a9cdfc | ||
|
|
fd23b9b209 | ||
|
|
975f5a5284 | ||
|
|
63176ccb8a | ||
|
|
6c3a9fc092 | ||
|
|
d9f173a03d | ||
|
|
c3cb26f7ca | ||
|
|
dd06028827 | ||
|
|
71203829d8 | ||
|
|
dfa80e1e5d | ||
|
|
951a4ea065 | ||
|
|
4fa1517e6d | ||
|
|
de2d986008 | ||
|
|
d57cb2e1a8 | ||
|
|
b697374ce5 | ||
|
|
b9106ba5f9 | ||
|
|
3ba9821254 | ||
|
|
17f2a990a8 | ||
|
|
71f7bd1cfd | ||
|
|
c4c01089ab | ||
|
|
b6591c3f69 | ||
|
|
e6fdbae79b | ||
|
|
a4e57d3ac4 | ||
|
|
1d862cf5c2 | ||
|
|
0840029982 | ||
|
|
309fcc5321 | ||
|
|
00ae21bed2 | ||
|
|
00fd57b8f5 | ||
|
|
aabe0bed30 | ||
|
|
350131b4d7 | ||
|
|
95d45c0aa7 | ||
|
|
cb06e133ca | ||
|
|
4e77483051 | ||
|
|
81535d512a | ||
|
|
8effb557d5 | ||
|
|
c66b1fd18b | ||
|
|
c04f8ba1ea | ||
|
|
c1b7f6b6ba | ||
|
|
e4708b3b99 | ||
|
|
f938f6617b | ||
|
|
e882f7d207 | ||
|
|
e38fd8603f | ||
|
|
89283aa788 | ||
|
|
f7dc27f2d0 | ||
|
|
ed560e466f | ||
|
|
b5f1dc9d95 | ||
|
|
f58ad7625f | ||
|
|
49c6d8019f | ||
|
|
86db180a17 | ||
|
|
c69111a4e6 | ||
|
|
31e59cd583 | ||
|
|
d2bfcd70e7 | ||
|
|
12d22e1c89 | ||
|
|
75cb78a5b1 | ||
|
|
791b568f78 | ||
|
|
d46642319b | ||
|
|
a96d37ca69 | ||
|
|
f8046268bc | ||
|
|
9cdd0c28be | ||
|
|
05b0b82937 | ||
|
|
908d9331af | ||
|
|
29f0463f65 | ||
|
|
66f353fe7a | ||
|
|
511a0c22b7 | ||
|
|
24de8cecf6 |
No files matched your search
@@ -7,6 +7,7 @@
|
||||
- Tests: colocated `*.test.ts`.
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
@@ -76,6 +77,7 @@
|
||||
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
|
||||
- Goal: merge PRs. Prefer **rebase** when commits are clean; **squash** when history is messy.
|
||||
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
|
||||
|
||||
+57
-18
@@ -2,30 +2,69 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.23
|
||||
## 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
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
|
||||
|
||||
### Fixes
|
||||
- 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.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Sandbox: log docker image inspect failures during doctor checks. (#1548) Thanks @sweepies.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
||||
- CLI: move auth probe errors below the table to reduce wrapping.
|
||||
- CLI: prevent ANSI color bleed when table cells wrap.
|
||||
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- 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
|
||||
|
||||
@@ -73,22 +112,22 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
|
||||
@@ -478,28 +478,30 @@ 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/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/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/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/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/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/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/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/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/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/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>
|
||||
</p>
|
||||
@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -1973,25 +1989,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
public let security: String?
|
||||
public let ask: String?
|
||||
public let agentid: String?
|
||||
public let resolvedpath: String?
|
||||
public let sessionkey: String?
|
||||
public let cwd: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
public let security: AnyCodable?
|
||||
public let ask: AnyCodable?
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
security: String?,
|
||||
ask: String?,
|
||||
agentid: String?,
|
||||
resolvedpath: String?,
|
||||
sessionkey: String?,
|
||||
cwd: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
security: AnyCodable?,
|
||||
ask: AnyCodable?,
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
|
||||
@@ -385,6 +385,7 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -395,6 +396,7 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -404,6 +406,7 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -414,6 +417,7 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -478,6 +482,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -500,6 +507,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -521,6 +531,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -543,6 +556,9 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
@@ -1973,25 +1989,25 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
public let security: String?
|
||||
public let ask: String?
|
||||
public let agentid: String?
|
||||
public let resolvedpath: String?
|
||||
public let sessionkey: String?
|
||||
public let cwd: AnyCodable?
|
||||
public let host: AnyCodable?
|
||||
public let security: AnyCodable?
|
||||
public let ask: AnyCodable?
|
||||
public let agentid: AnyCodable?
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
security: String?,
|
||||
ask: String?,
|
||||
agentid: String?,
|
||||
resolvedpath: String?,
|
||||
sessionkey: String?,
|
||||
cwd: AnyCodable?,
|
||||
host: AnyCodable?,
|
||||
security: AnyCodable?,
|
||||
ask: AnyCodable?,
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"gateway",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||
"--port",
|
||||
|
||||
@@ -3,9 +3,12 @@ summary: "Cron jobs + wakeups for the Gateway scheduler"
|
||||
read_when:
|
||||
- Scheduling background jobs or wakeups
|
||||
- Wiring automation that should run with or alongside heartbeats
|
||||
- Deciding between heartbeat and cron for scheduled tasks
|
||||
---
|
||||
# Cron jobs (Gateway scheduler)
|
||||
|
||||
> **Cron vs Heartbeat?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
||||
|
||||
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at
|
||||
the right time, and can optionally deliver output back to a chat.
|
||||
|
||||
@@ -260,15 +263,15 @@ Run history:
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Immediate wake without creating a job:
|
||||
Immediate system event without creating a job:
|
||||
```bash
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## Gateway API surface
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
summary: "Guidance for choosing between heartbeat and cron jobs for automation"
|
||||
read_when:
|
||||
- Deciding how to schedule recurring tasks
|
||||
- Setting up background monitoring or notifications
|
||||
- Optimizing token usage for periodic checks
|
||||
---
|
||||
# Cron vs Heartbeat: When to Use Each
|
||||
|
||||
Both heartbeats and cron jobs let you run tasks on a schedule. This guide helps you choose the right mechanism for your use case.
|
||||
|
||||
## Quick Decision Guide
|
||||
|
||||
| Use Case | Recommended | Why |
|
||||
|----------|-------------|-----|
|
||||
| Check inbox every 30 min | Heartbeat | Batches with other checks, context-aware |
|
||||
| Send daily report at 9am sharp | Cron (isolated) | Exact timing needed |
|
||||
| Monitor calendar for upcoming events | Heartbeat | Natural fit for periodic awareness |
|
||||
| Run weekly deep analysis | Cron (isolated) | Standalone task, can use different model |
|
||||
| Remind me in 20 minutes | Cron (main, `--at`) | One-shot with precise timing |
|
||||
| Background project health check | Heartbeat | Piggybacks on existing cycle |
|
||||
|
||||
## Heartbeat: Periodic Awareness
|
||||
|
||||
Heartbeats run in the **main session** at a regular interval (default: 30 min). They're designed for the agent to check on things and surface anything important.
|
||||
|
||||
### When to use heartbeat
|
||||
|
||||
- **Multiple periodic checks**: Instead of 5 separate cron jobs checking inbox, calendar, weather, notifications, and project status, a single heartbeat can batch all of these.
|
||||
- **Context-aware decisions**: The agent has full main-session context, so it can make smart decisions about what's urgent vs. what can wait.
|
||||
- **Conversational continuity**: Heartbeat runs share the same session, so the agent remembers recent conversations and can follow up naturally.
|
||||
- **Low-overhead monitoring**: One heartbeat replaces many small polling tasks.
|
||||
|
||||
### Heartbeat advantages
|
||||
|
||||
- **Batches multiple checks**: One agent turn can review inbox, calendar, and notifications together.
|
||||
- **Reduces API calls**: A single heartbeat is cheaper than 5 isolated cron jobs.
|
||||
- **Context-aware**: The agent knows what you've been working on and can prioritize accordingly.
|
||||
- **Smart suppression**: If nothing needs attention, the agent replies `HEARTBEAT_OK` and no message is delivered.
|
||||
- **Natural timing**: Drifts slightly based on queue load, which is fine for most monitoring.
|
||||
|
||||
### Heartbeat example: HEARTBEAT.md checklist
|
||||
|
||||
```md
|
||||
# Heartbeat checklist
|
||||
|
||||
- Check email for urgent messages
|
||||
- Review calendar for events in next 2 hours
|
||||
- If a background task finished, summarize results
|
||||
- If idle for 8+ hours, send a brief check-in
|
||||
```
|
||||
|
||||
The agent reads this on each heartbeat and handles all items in one turn.
|
||||
|
||||
### Configuring heartbeat
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "30m", // interval
|
||||
target: "last", // where to deliver alerts
|
||||
activeHours: { start: "08:00", end: "22:00" } // optional
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [Heartbeat](/gateway/heartbeat) for full configuration.
|
||||
|
||||
## Cron: Precise Scheduling
|
||||
|
||||
Cron jobs run at **exact times** and can run in isolated sessions without affecting main context.
|
||||
|
||||
### When to use cron
|
||||
|
||||
- **Exact timing required**: "Send this at 9:00 AM every Monday" (not "sometime around 9").
|
||||
- **Standalone tasks**: Tasks that don't need conversational context.
|
||||
- **Different model/thinking**: Heavy analysis that warrants a more powerful model.
|
||||
- **One-shot reminders**: "Remind me in 20 minutes" with `--at`.
|
||||
- **Noisy/frequent tasks**: Tasks that would clutter main session history.
|
||||
- **External triggers**: Tasks that should run independently of whether the agent is otherwise active.
|
||||
|
||||
### Cron advantages
|
||||
|
||||
- **Exact timing**: 5-field cron expressions with timezone support.
|
||||
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
|
||||
- **Model overrides**: Use a cheaper or more powerful model per job.
|
||||
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
|
||||
- **No agent context needed**: Runs even if main session is idle or compacted.
|
||||
- **One-shot support**: `--at` for precise future timestamps.
|
||||
|
||||
### Cron example: Daily morning briefing
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Morning briefing" \
|
||||
--cron "0 7 * * *" \
|
||||
--tz "America/New_York" \
|
||||
--session isolated \
|
||||
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
|
||||
--model opus \
|
||||
--deliver \
|
||||
--channel whatsapp \
|
||||
--to "+15551234567"
|
||||
```
|
||||
|
||||
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
|
||||
|
||||
### Cron example: One-shot reminder
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Meeting reminder" \
|
||||
--at "20m" \
|
||||
--session main \
|
||||
--system-event "Reminder: standup meeting starts in 10 minutes." \
|
||||
--wake now \
|
||||
--delete-after-run
|
||||
```
|
||||
|
||||
See [Cron jobs](/automation/cron-jobs) for full CLI reference.
|
||||
|
||||
## Decision Flowchart
|
||||
|
||||
```
|
||||
Does the task need to run at an EXACT time?
|
||||
YES -> Use cron
|
||||
NO -> Continue...
|
||||
|
||||
Does the task need isolation from main session?
|
||||
YES -> Use cron (isolated)
|
||||
NO -> Continue...
|
||||
|
||||
Can this task be batched with other periodic checks?
|
||||
YES -> Use heartbeat (add to HEARTBEAT.md)
|
||||
NO -> Use cron
|
||||
|
||||
Is this a one-shot reminder?
|
||||
YES -> Use cron with --at
|
||||
NO -> Continue...
|
||||
|
||||
Does it need a different model or thinking level?
|
||||
YES -> Use cron (isolated) with --model/--thinking
|
||||
NO -> Use heartbeat
|
||||
```
|
||||
|
||||
## Combining Both
|
||||
|
||||
The most efficient setup uses **both**:
|
||||
|
||||
1. **Heartbeat** handles routine monitoring (inbox, calendar, notifications) in one batched turn every 30 minutes.
|
||||
2. **Cron** handles precise schedules (daily reports, weekly reviews) and one-shot reminders.
|
||||
|
||||
### Example: Efficient automation setup
|
||||
|
||||
**HEARTBEAT.md** (checked every 30 min):
|
||||
```md
|
||||
# Heartbeat checklist
|
||||
- Scan inbox for urgent emails
|
||||
- Check calendar for events in next 2h
|
||||
- Review any pending tasks
|
||||
- Light check-in if quiet for 8+ hours
|
||||
```
|
||||
|
||||
**Cron jobs** (precise timing):
|
||||
```bash
|
||||
# Daily morning briefing at 7am
|
||||
clawdbot cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
|
||||
|
||||
# Weekly project review on Mondays at 9am
|
||||
clawdbot cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
|
||||
|
||||
# One-shot reminder
|
||||
clawdbot cron add --name "Call back" --at "2h" --session main --system-event "Call back the client" --wake now
|
||||
```
|
||||
|
||||
|
||||
## Lobster: Deterministic workflows with approvals
|
||||
|
||||
Lobster is the workflow runtime for **multi-step tool pipelines** that need deterministic execution and explicit approvals.
|
||||
Use it when the task is more than a single agent turn, and you want a resumable workflow with human checkpoints.
|
||||
|
||||
### When Lobster fits
|
||||
|
||||
- **Multi-step automation**: You need a fixed pipeline of tool calls, not a one-off prompt.
|
||||
- **Approval gates**: Side effects should pause until you approve, then resume.
|
||||
- **Resumable runs**: Continue a paused workflow without re-running earlier steps.
|
||||
|
||||
### How it pairs with heartbeat and cron
|
||||
|
||||
- **Heartbeat/cron** decide *when* a run happens.
|
||||
- **Lobster** defines *what steps* happen once the run starts.
|
||||
|
||||
For scheduled workflows, use cron or heartbeat to trigger an agent turn that calls Lobster.
|
||||
For ad-hoc workflows, call Lobster directly.
|
||||
|
||||
### Operational notes (from the code)
|
||||
|
||||
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
|
||||
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
|
||||
- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
|
||||
- If you pass `lobsterPath`, it must be an **absolute path**.
|
||||
|
||||
See [Lobster](/tools/lobster) for full usage and examples.
|
||||
|
||||
## Main Session vs Isolated Session
|
||||
|
||||
Both heartbeat and cron can interact with the main session, but differently:
|
||||
|
||||
| | Heartbeat | Cron (main) | Cron (isolated) |
|
||||
|---|---|---|---|
|
||||
| Session | Main | Main (via system event) | `cron:<jobId>` |
|
||||
| History | Shared | Shared | Fresh each run |
|
||||
| Context | Full | Full | None (starts clean) |
|
||||
| Model | Main session model | Main session model | Can override |
|
||||
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
|
||||
|
||||
### When to use main session cron
|
||||
|
||||
Use `--session main` with `--system-event` when you want:
|
||||
- The reminder/event to appear in main session context
|
||||
- The agent to handle it during the next heartbeat with full context
|
||||
- No separate isolated run
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Check project" \
|
||||
--every "4h" \
|
||||
--session main \
|
||||
--system-event "Time for a project health check" \
|
||||
--wake now
|
||||
```
|
||||
|
||||
### When to use isolated cron
|
||||
|
||||
Use `--session isolated` when you want:
|
||||
- A clean slate without prior context
|
||||
- Different model or thinking settings
|
||||
- Output delivered directly to a channel (summary still posts to main by default)
|
||||
- History that doesn't clutter main session
|
||||
|
||||
```bash
|
||||
clawdbot cron add \
|
||||
--name "Deep analysis" \
|
||||
--cron "0 6 * * 0" \
|
||||
--session isolated \
|
||||
--message "Weekly codebase analysis..." \
|
||||
--model opus \
|
||||
--thinking high \
|
||||
--deliver
|
||||
```
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
| Mechanism | Cost Profile |
|
||||
|-----------|--------------|
|
||||
| Heartbeat | One turn every N minutes; scales with HEARTBEAT.md size |
|
||||
| Cron (main) | Adds event to next heartbeat (no isolated turn) |
|
||||
| Cron (isolated) | Full agent turn per job; can use cheaper model |
|
||||
|
||||
**Tips**:
|
||||
- Keep `HEARTBEAT.md` small to minimize token overhead.
|
||||
- Batch similar checks into heartbeat instead of multiple cron jobs.
|
||||
- Use `target: "none"` on heartbeat if you only want internal processing.
|
||||
- Use isolated cron with a cheaper model for routine tasks.
|
||||
|
||||
## Related
|
||||
|
||||
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
||||
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
||||
- [System](/cli/system) - system events + heartbeat controls
|
||||
@@ -17,6 +17,37 @@ not an API key.
|
||||
- Auth: AWS credentials (env vars, shared config, or instance role)
|
||||
- Region: `AWS_REGION` or `AWS_DEFAULT_REGION` (default: `us-east-1`)
|
||||
|
||||
## Automatic model discovery
|
||||
|
||||
If AWS credentials are detected, Clawdbot can automatically discover Bedrock
|
||||
models that support **streaming** and **text output**. Discovery uses
|
||||
`bedrock:ListFoundationModels` and is cached (default: 1 hour).
|
||||
|
||||
Config options live under `models.bedrockDiscovery`:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
providerFilter: ["anthropic", "amazon"],
|
||||
refreshInterval: 3600,
|
||||
defaultContextWindow: 32000,
|
||||
defaultMaxTokens: 4096
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `enabled` defaults to `true` when AWS credentials are present.
|
||||
- `region` defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`, then `us-east-1`.
|
||||
- `providerFilter` matches Bedrock provider names (for example `anthropic`).
|
||||
- `refreshInterval` is seconds; set to `0` to disable caching.
|
||||
- `defaultContextWindow` (default: `32000`) and `defaultMaxTokens` (default: `4096`)
|
||||
are used for discovered models (override if you know your model limits).
|
||||
|
||||
## Setup (manual)
|
||||
|
||||
1) Ensure AWS credentials are available on the **gateway host**:
|
||||
@@ -67,6 +98,7 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
## Notes
|
||||
|
||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||
- Automatic discovery needs the `bedrock:ListFoundationModels` permission.
|
||||
- If you use profiles, set `AWS_PROFILE` on the gateway host.
|
||||
- Clawdbot surfaces the credential source in this order: `AWS_BEARER_TOKEN_BEDROCK`,
|
||||
then `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`, then `AWS_PROFILE`, then the
|
||||
|
||||
@@ -23,6 +23,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
summary: "Tlon/Urbit support status, capabilities, and configuration"
|
||||
read_when:
|
||||
- Working on Tlon/Urbit channel features
|
||||
---
|
||||
# Tlon (plugin)
|
||||
|
||||
Tlon is a decentralized messenger built on Urbit. Clawdbot connects to your Urbit ship and can
|
||||
respond to DMs and group chat messages. Group replies require an @ mention by default and can
|
||||
be further restricted via allowlists.
|
||||
|
||||
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
|
||||
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
|
||||
|
||||
## Plugin required
|
||||
|
||||
Tlon ships as a plugin and is not bundled with the core install.
|
||||
|
||||
Install via CLI (npm registry):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/tlon
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/tlon
|
||||
```
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
|
||||
## Setup
|
||||
|
||||
1) Install the Tlon plugin.
|
||||
2) Gather your ship URL and login code.
|
||||
3) Configure `channels.tlon`.
|
||||
4) Restart the gateway.
|
||||
5) DM the bot or mention it in a group channel.
|
||||
|
||||
Minimal config (single account):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
enabled: true,
|
||||
ship: "~sampel-palnet",
|
||||
url: "https://your-ship-host",
|
||||
code: "lidlut-tabwed-pillex-ridrup"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Group channels
|
||||
|
||||
Auto-discovery is enabled by default. You can also pin channels manually:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
groupChannels: [
|
||||
"chat/~host-ship/general",
|
||||
"chat/~host-ship/support"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Disable auto-discovery:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
autoDiscoverChannels: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Access control
|
||||
|
||||
DM allowlist (empty = allow all):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
dmAllowlist: ["~zod", "~nec"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Group authorization (restricted by default):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
defaultAuthorizedShips: ["~zod"],
|
||||
authorization: {
|
||||
channelRules: {
|
||||
"chat/~host-ship/general": {
|
||||
mode: "restricted",
|
||||
allowedShips: ["~zod", "~nec"]
|
||||
},
|
||||
"chat/~host-ship/announcements": {
|
||||
mode: "open"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
|
||||
Use these with `clawdbot message send` or cron delivery:
|
||||
|
||||
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
|
||||
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
|
||||
|
||||
## Notes
|
||||
|
||||
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
|
||||
- Thread replies: if the inbound message is in a thread, Clawdbot replies in-thread.
|
||||
- Media: `sendMedia` falls back to text + URL (no native upload).
|
||||
@@ -44,6 +44,7 @@ clawdbot channels logout --channel whatsapp
|
||||
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
- `clawdbot channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot contacts` (unified contact graph)"
|
||||
read_when:
|
||||
- You want to list or link contacts across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot contacts`
|
||||
|
||||
Unified contact graph and identity linking.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
Concept overview: [Contact graph](/contact).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts list --query "sarah" --platform slack
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot contacts search "alice"
|
||||
clawdbot contacts link <primary-id> <secondary-id>
|
||||
clawdbot contacts unlink slack U12345678
|
||||
clawdbot contacts suggestions
|
||||
clawdbot contacts auto-link --dry-run
|
||||
clawdbot contacts stats
|
||||
clawdbot contacts alias <contact-id> "Alias Name"
|
||||
clawdbot contacts alias <contact-id> "Old Alias" --remove
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- `list`: list contacts (supports `--query`, `--platform`, `--limit`, `--json`).
|
||||
- `show <id>`: show a contact + identities (accepts a canonical id or a search query).
|
||||
- `search <query>`: search contacts by name/alias/username.
|
||||
- `link <primary> <secondary>`: merge two contacts.
|
||||
- `unlink <platform> <platformId>`: detach an identity into a new contact.
|
||||
- `suggestions`: show link suggestions.
|
||||
- `auto-link`: link high-confidence matches (use `--dry-run` to preview).
|
||||
- `stats`: store statistics by platform.
|
||||
- `alias <contactId> <alias>`: add or remove aliases (`--remove`).
|
||||
|
||||
## Notes
|
||||
|
||||
- `--platform` expects a channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `unlink` uses the platform id stored on the identity (not the contact id).
|
||||
+26
-5
@@ -29,8 +29,11 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`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)
|
||||
@@ -38,7 +41,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
- [`wake`](/cli/wake)
|
||||
- [`cron`](/cli/cron)
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
@@ -122,6 +124,8 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
status
|
||||
index
|
||||
search
|
||||
contacts
|
||||
search
|
||||
message
|
||||
agent
|
||||
agents
|
||||
@@ -145,6 +149,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
restart
|
||||
run
|
||||
logs
|
||||
system
|
||||
event
|
||||
heartbeat last|enable|disable
|
||||
presence
|
||||
models
|
||||
list
|
||||
status
|
||||
@@ -160,7 +168,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
recreate
|
||||
explain
|
||||
wake
|
||||
cron
|
||||
status
|
||||
list
|
||||
@@ -763,9 +770,9 @@ Options:
|
||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||
- `clear`: `--provider <name>`, `--agent <id>`
|
||||
|
||||
## Cron + wake
|
||||
## System
|
||||
|
||||
### `wake`
|
||||
### `system event`
|
||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||
|
||||
Required:
|
||||
@@ -776,7 +783,21 @@ Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `cron`
|
||||
### `system heartbeat last|enable|disable`
|
||||
Heartbeat controls (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `system presence`
|
||||
List system presence entries (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
## Cron
|
||||
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||
|
||||
Subcommands:
|
||||
|
||||
@@ -23,6 +23,24 @@ Common use cases:
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Browser proxy (zero-config)
|
||||
|
||||
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
|
||||
disabled on the node. This lets the agent use browser automation on that node
|
||||
without extra configuration.
|
||||
|
||||
Disable it on the node if needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
nodeHost: {
|
||||
browserProxy: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot search` (cross-platform message search)"
|
||||
read_when:
|
||||
- You want to search indexed messages across channels
|
||||
- You are using the contacts-search plugin
|
||||
---
|
||||
|
||||
# `clawdbot search`
|
||||
|
||||
Search indexed messages across channels.
|
||||
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot search "meeting tomorrow"
|
||||
clawdbot search "deadline" --from alice
|
||||
clawdbot search "project" --platform slack --since 1w
|
||||
clawdbot search "invoice" --since 2025-12-01 --until 2025-12-31
|
||||
clawdbot search "handoff" --limit 50 --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--from <contact>`: filter by sender name/alias/username or contact id.
|
||||
- `--platform <name>`: filter by channel id (e.g. `slack`, `discord`, `whatsapp`).
|
||||
- `--since <time>`: start time (`1h`, `2d`, `1w`, `1m`, or ISO date).
|
||||
- `--until <time>`: end time (same formats as `--since`).
|
||||
- `--limit <n>`: limit results (default `20`).
|
||||
- `--json`: raw JSON output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Results come from the local contacts store (`~/.clawdbot/contacts/contacts.sqlite`).
|
||||
- Only inbound messages are indexed (no backfill).
|
||||
- Concept overview: [Contact graph](/contact).
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot system` (system events, heartbeat, presence)"
|
||||
read_when:
|
||||
- You want to enqueue a system event without creating a cron job
|
||||
- You need to enable or disable heartbeats
|
||||
- You want to inspect system presence entries
|
||||
---
|
||||
|
||||
# `clawdbot system`
|
||||
|
||||
System-level helpers for the Gateway: enqueue system events, control heartbeats,
|
||||
and view presence.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot system heartbeat enable
|
||||
clawdbot system heartbeat last
|
||||
clawdbot system presence
|
||||
```
|
||||
|
||||
## `system event`
|
||||
|
||||
Enqueue a system event on the **main** session. The next heartbeat will inject
|
||||
it as a `System:` line in the prompt. Use `--mode now` to trigger the heartbeat
|
||||
immediately; `next-heartbeat` waits for the next scheduled tick.
|
||||
|
||||
Flags:
|
||||
- `--text <text>`: required system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system heartbeat last|enable|disable`
|
||||
|
||||
Heartbeat controls:
|
||||
- `last`: show the last heartbeat event.
|
||||
- `enable`: turn heartbeats back on (use this if they were disabled).
|
||||
- `disable`: pause heartbeats.
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system presence`
|
||||
|
||||
List the current system presence entries the Gateway knows about (nodes,
|
||||
instances, and similar status lines).
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- System events are ephemeral and not persisted across restarts.
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot wake` (enqueue a system event and optionally trigger an immediate heartbeat)"
|
||||
read_when:
|
||||
- You want to “poke” a running Gateway to process a system event
|
||||
- You use `wake` with cron jobs or remote nodes
|
||||
---
|
||||
|
||||
# `clawdbot wake`
|
||||
|
||||
Enqueue a system event on the Gateway and optionally trigger an immediate heartbeat.
|
||||
|
||||
This is a lightweight “poke” for automation flows where you don’t want to run a full command, but you do want the Gateway to react quickly.
|
||||
|
||||
Related:
|
||||
- Cron jobs: [Cron](/cli/cron)
|
||||
- Gateway heartbeat: [Heartbeat](/gateway/heartbeat)
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot wake --text "sync"
|
||||
clawdbot wake --text "sync" --mode now
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--text <text>`: system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- If you’re using sandboxing, `wake` still targets the Gateway; sandboxing does not block the command itself.
|
||||
|
||||
@@ -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 user
|
||||
time or timezone is known. It is explicit about:
|
||||
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 user’s **local time** (already converted).
|
||||
- The **time zone** used for the conversion.
|
||||
- The **time format** (12-hour / 24-hour).
|
||||
Use `session_status` when the agent needs the current time; the status card
|
||||
includes a timestamp line.
|
||||
|
||||
Configure with:
|
||||
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
---
|
||||
summary: "Unified contacts: contact graph, identity linking, and message indexing"
|
||||
read_when:
|
||||
- You want to understand how Clawdbot merges identities across channels
|
||||
- You are using the Contacts + Search plugin
|
||||
---
|
||||
|
||||
# Contact graph
|
||||
|
||||
Clawdbot can maintain a **unified contact graph** that links the same person across multiple channels (Slack, Discord, WhatsApp, etc.).
|
||||
This powers cross-platform message search and manual identity linking.
|
||||
|
||||
The contact graph is provided by the **Contacts + Search** plugin and is **disabled by default**.
|
||||
|
||||
## Enable
|
||||
|
||||
Install/enable the plugin on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Config equivalent:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
- [Contacts + Search plugin](/plugins/contacts-search)
|
||||
- [Plugins overview](/plugin)
|
||||
|
||||
## Data model
|
||||
|
||||
The contact graph has three layers:
|
||||
|
||||
1) **Canonical contact**
|
||||
- One logical person.
|
||||
- Has a `canonicalId`, display name, and optional aliases.
|
||||
|
||||
2) **Platform identity**
|
||||
- One account on one channel (e.g. `slack:U123...`).
|
||||
- Links back to a canonical contact.
|
||||
- Optional username, phone, display name, and last-seen time.
|
||||
|
||||
3) **Indexed message**
|
||||
- Text of inbound messages tied to a platform identity.
|
||||
- Used by cross-platform search.
|
||||
|
||||
## How contacts are created
|
||||
|
||||
Contacts are created automatically when **inbound messages** arrive:
|
||||
|
||||
- The plugin extracts sender identity details from the inbound message.
|
||||
- If the platform identity is new, a new canonical contact is created.
|
||||
- If it already exists, the identity metadata is refreshed.
|
||||
|
||||
There is **no backfill** step today; indexing starts when the plugin is enabled.
|
||||
|
||||
## Linking identities
|
||||
|
||||
You can link identities that belong to the same person:
|
||||
|
||||
- **Manual link**: merge two contacts into one canonical contact.
|
||||
- **Suggestions**: name/phone similarity hints (preview-only).
|
||||
- **Auto-link**: high-confidence matches (same phone number).
|
||||
|
||||
CLI reference: [Contacts CLI](/cli/contacts)
|
||||
|
||||
## Searching messages
|
||||
|
||||
Use the CLI or slash command:
|
||||
|
||||
- `clawdbot search "query"` (CLI)
|
||||
- `/search <query>` (chat)
|
||||
|
||||
Search uses SQLite FTS when available; otherwise it falls back to SQL `LIKE`.
|
||||
|
||||
CLI reference: [Search CLI](/cli/search)
|
||||
Slash commands: [Slash commands](/tools/slash-commands)
|
||||
|
||||
## Storage + privacy
|
||||
|
||||
- Stored locally on the Gateway host at `~/.clawdbot/contacts/contacts.sqlite`.
|
||||
- No cloud sync by default.
|
||||
- Treat this file as **sensitive** (names, handles, phone numbers).
|
||||
|
||||
To reset the graph, disable the plugin and move the SQLite file to Trash, then restart the Gateway.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **No results**: the plugin only indexes **new inbound messages**.
|
||||
- **Missing contacts**: ensure the plugin is enabled and the Gateway restarted.
|
||||
- **Search feels shallow**: FTS may be unavailable; check that SQLite FTS5 is supported on your runtime.
|
||||
+8
-8
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
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.
|
||||
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`).
|
||||
|
||||
## Message envelopes (local by default)
|
||||
|
||||
@@ -63,16 +63,16 @@ You can override this behavior:
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone or local time is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section:
|
||||
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:
|
||||
|
||||
```
|
||||
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
|
||||
Time format: 12-hour
|
||||
Time zone: America/Chicago
|
||||
```
|
||||
|
||||
If only the timezone is known, we still include the section and instruct the model
|
||||
to assume UTC for unknown time references.
|
||||
When the agent needs the current time, use the `session_status` tool; the status
|
||||
card includes a timestamp line.
|
||||
|
||||
## System event lines (local by default)
|
||||
|
||||
|
||||
+17
-1
@@ -349,6 +349,14 @@
|
||||
"source": "/cron-jobs",
|
||||
"destination": "/automation/cron-jobs"
|
||||
},
|
||||
{
|
||||
"source": "/cron-vs-heartbeat",
|
||||
"destination": "/automation/cron-vs-heartbeat"
|
||||
},
|
||||
{
|
||||
"source": "/cron-vs-heartbeat/",
|
||||
"destination": "/automation/cron-vs-heartbeat"
|
||||
},
|
||||
{
|
||||
"source": "/dashboard",
|
||||
"destination": "/web/dashboard"
|
||||
@@ -840,14 +848,16 @@
|
||||
"cli/skills",
|
||||
"cli/plugins",
|
||||
"cli/memory",
|
||||
"cli/contacts",
|
||||
"cli/search",
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/system",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
"cli/cron",
|
||||
"cli/dns",
|
||||
"cli/docs",
|
||||
@@ -875,6 +885,7 @@
|
||||
"concepts/session",
|
||||
"concepts/session-pruning",
|
||||
"concepts/sessions",
|
||||
"contact",
|
||||
"concepts/session-tool",
|
||||
"concepts/presence",
|
||||
"concepts/channel-routing",
|
||||
@@ -984,6 +995,7 @@
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
"automation/cron-jobs",
|
||||
"automation/cron-vs-heartbeat",
|
||||
"automation/poll"
|
||||
]
|
||||
},
|
||||
@@ -991,7 +1003,10 @@
|
||||
"group": "Tools & Skills",
|
||||
"pages": [
|
||||
"tools",
|
||||
"tools/lobster",
|
||||
"tools/llm-task",
|
||||
"plugin",
|
||||
"plugins/contacts-search",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"tools/exec",
|
||||
@@ -1034,6 +1049,7 @@
|
||||
"platforms/android",
|
||||
"platforms/windows",
|
||||
"platforms/linux",
|
||||
"platforms/fly",
|
||||
"platforms/hetzner",
|
||||
"platforms/exe-dev"
|
||||
]
|
||||
|
||||
@@ -1446,6 +1446,44 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
|
||||
`removeAckAfterReply` removes the bot’s 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.
|
||||
@@ -1970,6 +2008,7 @@ Example (provider/model-specific allowlist):
|
||||
```
|
||||
|
||||
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
|
||||
Matching is case-insensitive and supports `*` wildcards (`"*"` means all tools).
|
||||
This is applied even when the Docker sandbox is **off**.
|
||||
|
||||
Example (disable browser/canvas everywhere):
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
summary: "Heartbeat polling messages and notification rules"
|
||||
read_when:
|
||||
- Adjusting heartbeat cadence or messaging
|
||||
- Deciding between heartbeat and cron for scheduled tasks
|
||||
---
|
||||
# Heartbeat (Gateway)
|
||||
|
||||
> **Heartbeat vs Cron?** See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for guidance on when to use each.
|
||||
|
||||
Heartbeat runs **periodic agent turns** in the main session so the model can
|
||||
surface anything that needs attention without spamming you.
|
||||
|
||||
@@ -159,6 +162,10 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||
safe to include every 30 minutes.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
Example `HEARTBEAT.md`:
|
||||
@@ -192,7 +199,7 @@ Safety note: don’t put secrets (API keys, phone numbers, private tokens) into
|
||||
You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
|
||||
```bash
|
||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
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.
|
||||
@@ -184,7 +184,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway-daemon",
|
||||
"gateway",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND}",
|
||||
"--port",
|
||||
|
||||
@@ -23,6 +23,7 @@ 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)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||
- [Contacts + Search](/plugins/contacts-search) — bundled unified contacts + cross-platform search (disabled by default)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
@@ -62,6 +63,7 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -145,6 +147,16 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
Clawdbot can also merge **external channel catalogs** (for example, an MPM
|
||||
registry export). Drop a JSON file at one of:
|
||||
- `~/.clawdbot/mpm/plugins.json`
|
||||
- `~/.clawdbot/mpm/catalog.json`
|
||||
- `~/.clawdbot/plugins/catalog.json`
|
||||
|
||||
Or point `CLAWDBOT_PLUGIN_CATALOG_PATHS` (or `CLAWDBOT_MPM_CATALOG_PATHS`) at
|
||||
one or more JSON files (comma/semicolon/`PATH`-delimited). Each file should
|
||||
contain `{ "entries": [ { "name": "@scope/pkg", "clawdbot": { "channel": {...}, "install": {...} } } ] }`.
|
||||
|
||||
## Plugin IDs
|
||||
|
||||
Default plugin ids:
|
||||
@@ -484,6 +496,65 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
### Register auto-reply commands
|
||||
|
||||
Plugins can register custom slash commands that execute **without invoking the
|
||||
AI agent**. This is useful for toggle commands, status checks, or quick actions
|
||||
that don't need LLM processing.
|
||||
|
||||
```ts
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "mystatus",
|
||||
description: "Show plugin status",
|
||||
handler: (ctx) => ({
|
||||
text: `Plugin is running! Channel: ${ctx.channel}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Command handler context:
|
||||
|
||||
- `senderId`: The sender's ID (if available)
|
||||
- `channel`: The channel where the command was sent
|
||||
- `isAuthorizedSender`: Whether the sender is an authorized user
|
||||
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
|
||||
- `commandBody`: The full command text
|
||||
- `config`: The current Clawdbot config
|
||||
|
||||
Command options:
|
||||
|
||||
- `name`: Command name (without the leading `/`)
|
||||
- `description`: Help text shown in command lists
|
||||
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
|
||||
- `requireAuth`: Whether to require authorized sender (default: true)
|
||||
- `handler`: Function that returns `{ text: string }` (can be async)
|
||||
|
||||
Example with authorization and arguments:
|
||||
|
||||
```ts
|
||||
api.registerCommand({
|
||||
name: "setmode",
|
||||
description: "Set plugin mode",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const mode = ctx.args?.trim() || "default";
|
||||
await saveMode(mode);
|
||||
return { text: `Mode set to: ${mode}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Plugin commands are processed **before** built-in commands and the AI agent
|
||||
- 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
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
summary: "Contacts + Search plugin: unified contacts and cross-platform message search"
|
||||
read_when:
|
||||
- You want unified contacts or cross-platform message search
|
||||
- You are enabling the contacts-search plugin
|
||||
---
|
||||
|
||||
# Contacts + Search (plugin)
|
||||
|
||||
Unified contact graph + cross-platform message search.
|
||||
Indexes incoming messages, links platform identities, and exposes `/search` plus CLI tools.
|
||||
|
||||
## What it adds
|
||||
|
||||
- `clawdbot contacts ...` (link, list, search, stats)
|
||||
- `clawdbot search ...` (message search)
|
||||
- `/search ...` slash command (text surfaces)
|
||||
|
||||
## Where it runs
|
||||
|
||||
Runs inside the Gateway process. Enable it on the **Gateway host**, then restart the Gateway.
|
||||
|
||||
## Enable (bundled)
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable contacts-search
|
||||
```
|
||||
|
||||
Or in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"contacts-search": { enabled: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Data location
|
||||
|
||||
The contact store lives under the Clawdbot state directory:
|
||||
|
||||
- `~/.clawdbot/contacts/contacts.sqlite`
|
||||
|
||||
If you run with `--profile <name>` or `--dev`, the state root changes accordingly.
|
||||
|
||||
## Indexing notes
|
||||
|
||||
- Messages are indexed as they arrive (no backfill).
|
||||
- Search uses SQLite FTS when available; otherwise falls back to SQL `LIKE` queries.
|
||||
|
||||
## CLI quickstart
|
||||
|
||||
```bash
|
||||
clawdbot contacts list
|
||||
clawdbot contacts search "sarah"
|
||||
clawdbot contacts show <contact-id>
|
||||
clawdbot search "meeting notes" --from sarah --since 1w
|
||||
```
|
||||
|
||||
Related:
|
||||
- CLI: [contacts](/cli/contacts)
|
||||
- CLI: [search](/cli/search)
|
||||
- Concept: [Contact graph](/contact)
|
||||
- Slash commands: [Slash commands](/tools/slash-commands)
|
||||
- Plugins: [Plugins](/plugin)
|
||||
@@ -112,6 +112,23 @@ Default heartbeat prompt:
|
||||
|
||||
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
|
||||
|
||||
### Heartbeat vs Cron: When to Use Each
|
||||
|
||||
**Use heartbeat when:**
|
||||
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
|
||||
- You need conversational context from recent messages
|
||||
- Timing can drift slightly (every ~30 min is fine, not exact)
|
||||
- You want to reduce API calls by combining periodic checks
|
||||
|
||||
**Use cron when:**
|
||||
- Exact timing matters ("9:00 AM sharp every Monday")
|
||||
- Task needs isolation from main session history
|
||||
- You want a different model or thinking level for the task
|
||||
- One-shot reminders ("remind me in 20 minutes")
|
||||
- Output should deliver directly to a channel without main session involvement
|
||||
|
||||
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
|
||||
|
||||
**Things to check (rotate through these, 2-4 times per day):**
|
||||
- **Emails** - Any urgent unread messages?
|
||||
- **Calendar** - Upcoming events in next 24-48h?
|
||||
|
||||
@@ -5,4 +5,5 @@ read_when:
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
@@ -7,11 +7,16 @@ read_when:
|
||||
|
||||
*Fill this in during your first conversation. Make it yours.*
|
||||
|
||||
- **Name:** *(pick something you like)*
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:** *(your signature — pick one that feels right)*
|
||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||
- **Name:**
|
||||
*(pick something you like)*
|
||||
- **Creature:**
|
||||
*(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:**
|
||||
*(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:**
|
||||
*(your signature — pick one that feels right)*
|
||||
- **Avatar:**
|
||||
*(workspace-relative path, http(s) URL, or data URI)*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ 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.
|
||||
|
||||
@@ -182,6 +182,8 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
|
||||
@@ -971,6 +971,10 @@ Heartbeats run every **30m** by default. Tune or disable them:
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||
|
||||
### Do I need to add a “bot account” to a WhatsApp group?
|
||||
|
||||
@@ -102,6 +102,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Elevated mode](/tools/elevated)
|
||||
- [Cron jobs](/automation/cron-jobs)
|
||||
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat)
|
||||
- [Thinking + verbose](/tools/thinking)
|
||||
- [Models](/concepts/models)
|
||||
- [Sub-agents](/tools/subagents)
|
||||
|
||||
@@ -327,14 +327,8 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
<Card title="OpenRouter Transcription" icon="microphone" href="https://clawdhub.com/obviyus/openrouter-transcribe">
|
||||
**@obviyus** • `transcription` `multilingual` `skill`
|
||||
|
||||
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
|
||||
</Card>
|
||||
|
||||
<Card title="Google Docs Editor" icon="file-word">
|
||||
**Community** • `docs` `editing` `skill`
|
||||
|
||||
Rich-text Google Docs editing skill. Built rapidly with Claude Code.
|
||||
Multi-lingual audio transcription via OpenRouter (Gemini, etc). Available on ClawdHub.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
@@ -166,6 +166,19 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||
tokens instead of committing them to config files.
|
||||
|
||||
### Node browser proxy (zero-config default)
|
||||
|
||||
If you run a **node host** on the machine that has your browser, Clawdbot can
|
||||
auto-route browser tool calls to that node without any custom `controlUrl`
|
||||
setup. This is the default path for remote gateways.
|
||||
|
||||
Notes:
|
||||
- The node host exposes its local browser control server via a **proxy command**.
|
||||
- Profiles come from the node’s own `browser.profiles` config (same as local).
|
||||
- Disable if you don’t want it:
|
||||
- On the node: `nodeHost.browserProxy.enabled=false`
|
||||
- On the gateway: `gateway.nodes.browser.mode="off"`
|
||||
|
||||
### Browserless (hosted remote CDP)
|
||||
|
||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||
|
||||
@@ -12,6 +12,7 @@ Exec approvals are the **companion app / node host guardrail** for letting a san
|
||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
|
||||
Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used.
|
||||
|
||||
If the companion app UI is **not available**, any request that requires a prompt is
|
||||
resolved by the **ask fallback** (default: deny).
|
||||
|
||||
@@ -22,6 +22,11 @@ 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)
|
||||
|
||||
`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
|
||||
@@ -156,6 +161,7 @@ alongside tools (for example, the voice-call plugin).
|
||||
|
||||
Optional plugin tools:
|
||||
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
||||
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
|
||||
|
||||
## Tool inventory
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
summary: "JSON-only LLM tasks for workflows (optional plugin tool)"
|
||||
read_when:
|
||||
- You want a JSON-only LLM step inside workflows
|
||||
- You need schema-validated LLM output for automation
|
||||
---
|
||||
|
||||
# LLM Task
|
||||
|
||||
`llm-task` is an **optional plugin tool** that runs a JSON-only LLM task and
|
||||
returns structured output (optionally validated against JSON Schema).
|
||||
|
||||
This is ideal for workflow engines like Lobster: you can add a single LLM step
|
||||
without writing custom Clawdbot code for each workflow.
|
||||
|
||||
## Enable the plugin
|
||||
|
||||
1) Enable the plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"llm-task": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2) Allowlist the tool (it is registered with `optional: true`):
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": { "allow": ["llm-task"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Config (optional)
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"llm-task": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.2",
|
||||
"defaultAuthProfileId": "main",
|
||||
"allowedModels": ["openai-codex/gpt-5.2"],
|
||||
"maxTokens": 800,
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
|
||||
outside the list is rejected.
|
||||
|
||||
## Tool parameters
|
||||
|
||||
- `prompt` (string, required)
|
||||
- `input` (any, optional)
|
||||
- `schema` (object, optional JSON Schema)
|
||||
- `provider` (string, optional)
|
||||
- `model` (string, optional)
|
||||
- `authProfileId` (string, optional)
|
||||
- `temperature` (number, optional)
|
||||
- `maxTokens` (number, optional)
|
||||
- `timeoutMs` (number, optional)
|
||||
|
||||
## Output
|
||||
|
||||
Returns `details.json` containing the parsed JSON (and validates against
|
||||
`schema` when provided).
|
||||
|
||||
## Example: Lobster workflow step
|
||||
|
||||
```lobster
|
||||
clawd.invoke --tool llm-task --action json --args-json '{
|
||||
"prompt": "Given the input email, return intent and draft.",
|
||||
"input": {
|
||||
"subject": "Hello",
|
||||
"body": "Can you help?"
|
||||
},
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"intent": { "type": "string" },
|
||||
"draft": { "type": "string" }
|
||||
},
|
||||
"required": ["intent", "draft"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Safety notes
|
||||
|
||||
- The tool is **JSON-only** and instructs the model to output only JSON (no
|
||||
code fences, no commentary).
|
||||
- No tools are exposed to the model for this run.
|
||||
- Treat output as untrusted unless you validate with `schema`.
|
||||
- Put approvals before any side-effecting step (send, post, exec).
|
||||
@@ -65,6 +65,52 @@ gog.gmail.search --query 'newer_than:1d' \
|
||||
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
||||
```
|
||||
|
||||
## JSON-only LLM steps (llm-task)
|
||||
|
||||
For workflows that need a **structured LLM step**, enable the optional
|
||||
`llm-task` plugin tool and call it from Lobster. This keeps the workflow
|
||||
deterministic while still letting you classify/summarize/draft with a model.
|
||||
|
||||
Enable the tool:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"llm-task": { "enabled": true }
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": { "allow": ["llm-task"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use it in a pipeline:
|
||||
|
||||
```lobster
|
||||
clawd.invoke --tool llm-task --action json --args-json '{
|
||||
"prompt": "Given the input email, return intent and draft.",
|
||||
"input": { "subject": "Hello", "body": "Can you help?" },
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"intent": { "type": "string" },
|
||||
"draft": { "type": "string" }
|
||||
},
|
||||
"required": ["intent", "draft"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
See [LLM Task](/tools/llm-task) for details and configuration options.
|
||||
|
||||
## Workflow files (.lobster)
|
||||
|
||||
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
|
||||
|
||||
@@ -61,12 +61,20 @@ Text + native (when enabled):
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/search <query> [--from <contact>] [--platform <name>] [--since <time>]` (search messages across platforms; requires [Contacts + Search](/plugins/contacts-search))
|
||||
- `/context [list|detail|json]` (explain "context"; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
- `/tts_on` (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)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -62,6 +63,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -21,6 +21,7 @@ const bluebubblesActionSchema = z
|
||||
|
||||
const bluebubblesGroupConfigSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const bluebubblesAccountSchema = z.object({
|
||||
|
||||
@@ -4,6 +4,8 @@ export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this group. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
};
|
||||
|
||||
export type BlueBubblesAccountConfig = {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "contacts-search",
|
||||
"name": "Contacts + Search",
|
||||
"description": "Unified contact graph and cross-platform message search",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
configureContactStore,
|
||||
closeContactStore,
|
||||
} from "./src/contacts/index.js";
|
||||
import { registerContactsCli } from "./src/cli/contacts-cli.js";
|
||||
import { registerSearchCli } from "./src/cli/search-cli.js";
|
||||
import { runSearchCommand } from "./src/commands/search-command.js";
|
||||
import { indexInboundMessage } from "./src/hooks/message-indexer.js";
|
||||
|
||||
const contactsSearchPlugin = {
|
||||
id: "contacts-search",
|
||||
name: "Contacts + Search",
|
||||
description: "Unified contact graph with cross-platform message search",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
const stateDir = api.runtime.state.resolveStateDir();
|
||||
configureContactStore({ stateDir });
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
registerContactsCli(program);
|
||||
registerSearchCli(program);
|
||||
},
|
||||
{ commands: ["contacts", "search"] },
|
||||
);
|
||||
|
||||
api.registerCommand({
|
||||
name: "search",
|
||||
description: "Search messages across platforms.",
|
||||
acceptsArgs: true,
|
||||
handler: async (ctx) => ({ text: runSearchCommand(ctx.commandBody) }),
|
||||
});
|
||||
|
||||
api.on(
|
||||
"message_received",
|
||||
(event, ctx) => {
|
||||
indexInboundMessage({ event, ctx, logger: api.logger });
|
||||
},
|
||||
);
|
||||
|
||||
api.registerService({
|
||||
id: "contacts-search",
|
||||
start: () => {},
|
||||
stop: () => {
|
||||
closeContactStore();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default contactsSearchPlugin;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@clawdbot/contacts-search",
|
||||
"version": "2026.1.21",
|
||||
"type": "module",
|
||||
"description": "Clawdbot unified contacts and cross-platform search plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
findLinkSuggestions,
|
||||
getContactStore,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { cli, formatDanger, formatSuccess, renderTable, theme } from "./formatting.js";
|
||||
|
||||
function formatPlatformList(platforms: string[]): string {
|
||||
return platforms.join(", ");
|
||||
}
|
||||
|
||||
function formatContactRow(contact: {
|
||||
canonicalId: string;
|
||||
displayName: string;
|
||||
aliases: string[];
|
||||
identities: Array<{ platform: string; platformId: string; username?: string | null }>;
|
||||
}) {
|
||||
const platforms = [...new Set(contact.identities.map((i) => i.platform))];
|
||||
return {
|
||||
ID: contact.canonicalId,
|
||||
Name: contact.displayName,
|
||||
Platforms: formatPlatformList(platforms),
|
||||
Identities: String(contact.identities.length),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerContactsCli(program: Command) {
|
||||
const contacts = program
|
||||
.command("contacts")
|
||||
.description("Unified contact graph - cross-platform identity management")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cli/contacts",
|
||||
"docs.clawd.bot/cli/contacts",
|
||||
)}\n`,
|
||||
)
|
||||
.action(() => {
|
||||
contacts.help({ error: true });
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts list
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("list")
|
||||
.description("List all contacts in the unified graph")
|
||||
.option("--query <text>", "Search by name or alias")
|
||||
.option("--platform <name>", "Filter by platform (channel id)")
|
||||
.option("--limit <n>", "Limit results", "50")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 50;
|
||||
|
||||
const platform = opts.platform
|
||||
? ((opts.platform as string).toLowerCase() as Platform)
|
||||
: undefined;
|
||||
const contactsList = store.listContacts({
|
||||
query: opts.query as string | undefined,
|
||||
platform,
|
||||
limit,
|
||||
});
|
||||
|
||||
const contactsWithIdentities = contactsList
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(contactsWithIdentities, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (contactsWithIdentities.length === 0) {
|
||||
cli.log(theme.muted("No contacts found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
|
||||
);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: contactsWithIdentities.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts show
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("show")
|
||||
.description("Show details for a specific contact")
|
||||
.argument("<id>", "Contact canonical ID or search query")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (id: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
// Try exact match first
|
||||
let contact = store.getContactWithIdentities(id);
|
||||
|
||||
// If not found, search
|
||||
if (!contact) {
|
||||
const matches = store.searchContacts(id, 1);
|
||||
contact = matches[0] ?? null;
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
cli.error(formatDanger(`Contact not found: ${id}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(contact, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(`${theme.heading("Contact")}`);
|
||||
cli.log(` ID: ${contact.canonicalId}`);
|
||||
cli.log(` Name: ${contact.displayName}`);
|
||||
if (contact.aliases.length > 0) {
|
||||
cli.log(` Aliases: ${contact.aliases.join(", ")}`);
|
||||
}
|
||||
|
||||
cli.log("");
|
||||
cli.log(
|
||||
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Platform", header: "Platform", minWidth: 10 },
|
||||
{ key: "ID", header: "Platform ID", minWidth: 20, flex: true },
|
||||
{ key: "Username", header: "Username", minWidth: 12, flex: true },
|
||||
{ key: "Phone", header: "Phone", minWidth: 14 },
|
||||
],
|
||||
rows: contact.identities.map((i) => ({
|
||||
Platform: i.platform,
|
||||
ID: i.platformId,
|
||||
Username: i.username ?? "",
|
||||
Phone: i.phone ?? "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts search
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("search")
|
||||
.description("Search contacts by name, alias, or username")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--limit <n>", "Limit results", "10")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 10;
|
||||
const results = store.searchContacts(query, limit);
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
cli.log(theme.muted(`No contacts found matching "${query}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 20, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 16, flex: true },
|
||||
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
|
||||
{ key: "Identities", header: "#", minWidth: 4 },
|
||||
],
|
||||
rows: results.map(formatContactRow),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("link")
|
||||
.description("Link two contacts (merge into one)")
|
||||
.argument("<primary>", "Primary contact ID (will keep this one)")
|
||||
.argument("<secondary>", "Secondary contact ID (will be merged and deleted)")
|
||||
.action(async (primary: string, secondary: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = linkContacts(store, primary, secondary);
|
||||
|
||||
if (!result.success) {
|
||||
cli.error(formatDanger(result.error ?? "Failed to link contacts"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(formatSuccess(`Linked: ${secondary} merged into ${primary}`));
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts unlink
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("unlink")
|
||||
.description("Unlink a platform identity from its contact (creates a new contact)")
|
||||
.argument("<platform>", "Platform (channel id)")
|
||||
.argument("<platformId>", "Platform-specific user ID")
|
||||
.action(async (platform: string, platformId: string) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const result = unlinkIdentity(store, platform, platformId);
|
||||
|
||||
if (!result.success) {
|
||||
cli.error(formatDanger(result.error ?? "Failed to unlink identity"));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts suggestions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("suggestions")
|
||||
.description("Show link suggestions (contacts that may be the same person)")
|
||||
.option("--min-score <n>", "Minimum name similarity score (0-1)", "0.85")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const minScore = parseFloat(opts.minScore as string) || 0.85;
|
||||
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(suggestions, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
cli.log(theme.muted("No link suggestions found."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
|
||||
);
|
||||
|
||||
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
|
||||
cli.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Source", header: "Source", minWidth: 20, flex: true },
|
||||
{ key: "Target", header: "Target", minWidth: 20, flex: true },
|
||||
{ key: "Reason", header: "Reason", minWidth: 14 },
|
||||
{ key: "Confidence", header: "Confidence", minWidth: 10 },
|
||||
{ key: "Score", header: "Score", minWidth: 6 },
|
||||
],
|
||||
rows: suggestions.map((s) => ({
|
||||
Source: `${s.sourceIdentity.platform}:${s.sourceIdentity.displayName || s.sourceIdentity.platformId}`,
|
||||
Target: `${s.targetIdentity.platform}:${s.targetIdentity.displayName || s.targetIdentity.platformId}`,
|
||||
Reason: s.reason,
|
||||
Confidence: s.confidence,
|
||||
Score: s.score.toFixed(2),
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
cli.log("");
|
||||
cli.log(
|
||||
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts auto-link
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("auto-link")
|
||||
.description("Automatically link high-confidence matches (e.g., same phone number)")
|
||||
.option("--dry-run", "Show what would be linked without making changes", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
if (opts.dryRun) {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
if (highConfidence.length === 0) {
|
||||
cli.log(theme.muted("No high-confidence matches found."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
|
||||
);
|
||||
for (const s of highConfidence) {
|
||||
cli.log(
|
||||
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
if (result.linked === 0) {
|
||||
cli.log(theme.muted("No high-confidence matches found to auto-link."));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(formatSuccess(`Auto-linked ${result.linked} contact(s)`));
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts stats
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("stats")
|
||||
.description("Show contact store statistics")
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const stats = store.getStats();
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(stats, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(`${theme.heading("Contact Store Statistics")}`);
|
||||
cli.log(` Contacts: ${stats.contacts}`);
|
||||
cli.log(` Identities: ${stats.identities}`);
|
||||
cli.log(` Indexed Messages: ${stats.messages}`);
|
||||
cli.log("");
|
||||
cli.log(`${theme.heading("Identities by Platform")}`);
|
||||
for (const [platform, count] of Object.entries(stats.platforms)) {
|
||||
cli.log(` ${platform}: ${count}`);
|
||||
}
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// contacts alias
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
contacts
|
||||
.command("alias")
|
||||
.description("Add or remove an alias for a contact")
|
||||
.argument("<contactId>", "Contact ID")
|
||||
.argument("<alias>", "Alias to add")
|
||||
.option("--remove", "Remove the alias instead of adding", false)
|
||||
.action(async (contactId: string, alias: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const contact = store.getContact(contactId);
|
||||
|
||||
if (!contact) {
|
||||
cli.error(formatDanger(`Contact not found: ${contactId}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentAliases = contact.aliases;
|
||||
let newAliases: string[];
|
||||
|
||||
if (opts.remove) {
|
||||
newAliases = currentAliases.filter((a) => a !== alias);
|
||||
if (newAliases.length === currentAliases.length) {
|
||||
cli.log(theme.muted(`Alias "${alias}" not found on this contact.`));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (currentAliases.includes(alias)) {
|
||||
cli.log(theme.muted(`Alias "${alias}" already exists on this contact.`));
|
||||
return;
|
||||
}
|
||||
newAliases = [...currentAliases, alias];
|
||||
}
|
||||
|
||||
store.updateContact(contactId, { aliases: newAliases });
|
||||
cli.log(
|
||||
formatSuccess(
|
||||
opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
type TableColumn = {
|
||||
key: string;
|
||||
header: string;
|
||||
minWidth?: number;
|
||||
flex?: boolean;
|
||||
};
|
||||
|
||||
type TableRow = Record<string, string | number | null | undefined>;
|
||||
|
||||
type TableOptions = {
|
||||
columns: TableColumn[];
|
||||
rows: TableRow[];
|
||||
width?: number;
|
||||
};
|
||||
|
||||
const pad = (value: string, width: number) => value.padEnd(width);
|
||||
|
||||
const truncate = (value: string, width: number) => {
|
||||
if (value.length <= width) return pad(value, width);
|
||||
if (width <= 3) return value.slice(0, width);
|
||||
return value.slice(0, width - 3) + "...";
|
||||
};
|
||||
|
||||
export const theme = {
|
||||
heading: (value: string) => value,
|
||||
muted: (value: string) => value,
|
||||
accent: (value: string) => value,
|
||||
accentBright: (value: string) => value,
|
||||
};
|
||||
|
||||
export const cli = {
|
||||
log: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
},
|
||||
error: (message: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
},
|
||||
exit: (code: number) => {
|
||||
process.exit(code);
|
||||
},
|
||||
};
|
||||
|
||||
export const formatSuccess = (message: string) => message;
|
||||
export const formatDanger = (message: string) => message;
|
||||
|
||||
export function renderTable({ columns, rows, width }: TableOptions): string {
|
||||
const widths = columns.map((column) => {
|
||||
const headerWidth = column.header.length;
|
||||
const minWidth = column.minWidth ?? 0;
|
||||
const maxRowWidth = rows.reduce((max, row) => {
|
||||
const value = String(row[column.key] ?? "");
|
||||
return Math.max(max, value.length);
|
||||
}, 0);
|
||||
return Math.max(minWidth, headerWidth, maxRowWidth);
|
||||
});
|
||||
|
||||
if (width) {
|
||||
const baseWidth = widths.reduce((sum, colWidth) => sum + colWidth, 0);
|
||||
const totalWidth = baseWidth + (columns.length - 1) * 2;
|
||||
if (totalWidth > width) {
|
||||
const flexColumns = columns
|
||||
.map((column, index) => (column.flex ? index : -1))
|
||||
.filter((index) => index >= 0);
|
||||
if (flexColumns.length > 0) {
|
||||
const excess = totalWidth - width;
|
||||
const shrinkEach = Math.ceil(excess / flexColumns.length);
|
||||
for (const index of flexColumns) {
|
||||
const minWidth = columns[index]!.minWidth ?? 4;
|
||||
widths[index] = Math.max(minWidth, widths[index]! - shrinkEach);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const header = columns
|
||||
.map((column, index) => truncate(column.header, widths[index]!))
|
||||
.join(" ");
|
||||
const separator = columns
|
||||
.map((_, index) => "-".repeat(widths[index]!))
|
||||
.join(" ");
|
||||
|
||||
const lines = [header, separator];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
columns
|
||||
.map((column, index) =>
|
||||
truncate(String(row[column.key] ?? ""), widths[index]!),
|
||||
)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { formatDocsLink } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
import { cli, formatDanger, theme } from "./formatting.js";
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string): number | null {
|
||||
if (!value) return null;
|
||||
|
||||
// Handle relative times like "1h", "2d", "1w"
|
||||
const relativeMatch = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (relativeMatch) {
|
||||
const amount = parseInt(relativeMatch[1]!, 10);
|
||||
const unit = relativeMatch[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ISO date strings
|
||||
const parsed = Date.parse(value);
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerSearchCli(program: Command) {
|
||||
program
|
||||
.command("search")
|
||||
.description("Search messages across all messaging platforms")
|
||||
.argument("<query>", "Search query")
|
||||
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
|
||||
.option(
|
||||
"--platform <name>",
|
||||
"Filter by platform (channel id)",
|
||||
)
|
||||
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
|
||||
.option("--until <time>", "Filter messages before this time")
|
||||
.option("--limit <n>", "Limit results", "20")
|
||||
.option("--json", "Output JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Examples:")}\n` +
|
||||
` clawdbot search "meeting tomorrow"\n` +
|
||||
` clawdbot search "deadline" --from alice\n` +
|
||||
` clawdbot search "project" --platform slack --since 1w\n` +
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/search", "docs.clawd.bot/cli/search")}\n`,
|
||||
)
|
||||
.action(async (query: string, opts) => {
|
||||
try {
|
||||
const store = getContactStore();
|
||||
const limit = parseInt(opts.limit as string, 10) || 20;
|
||||
|
||||
// Parse platforms
|
||||
let platforms: Platform[] | undefined;
|
||||
if (opts.platform) {
|
||||
const platform = (opts.platform as string).toLowerCase() as Platform;
|
||||
platforms = [platform];
|
||||
}
|
||||
|
||||
// Parse timestamps
|
||||
const since = opts.since ? parseTimestamp(opts.since as string) : undefined;
|
||||
const until = opts.until ? parseTimestamp(opts.until as string) : undefined;
|
||||
|
||||
if (opts.since && since === null) {
|
||||
cli.error(formatDanger(`Invalid --since value: ${opts.since}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
if (opts.until && until === null) {
|
||||
cli.error(formatDanger(`Invalid --until value: ${opts.until}`));
|
||||
cli.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = store.searchMessages({
|
||||
query,
|
||||
from: opts.from as string | undefined,
|
||||
platforms,
|
||||
since: since ?? undefined,
|
||||
until: until ?? undefined,
|
||||
limit,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
cli.log(JSON.stringify(results, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
cli.log(theme.muted(`No messages found matching "${query}".`));
|
||||
|
||||
// Helpful hints
|
||||
if (opts.from) {
|
||||
const contactMatches = store.searchContacts(opts.from as string, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
cli.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
cli.log(
|
||||
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
|
||||
);
|
||||
cli.log("");
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
|
||||
cli.log(
|
||||
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
|
||||
);
|
||||
cli.log(` ${snippet}`);
|
||||
cli.log("");
|
||||
}
|
||||
|
||||
if (results.length === limit) {
|
||||
cli.log(
|
||||
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
cli.error(formatDanger(String(err)));
|
||||
cli.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
/**
|
||||
* Parse relative time strings like "1h", "2d", "1w"
|
||||
*/
|
||||
function parseRelativeTime(value: string): number | null {
|
||||
const match = value.match(/^(\d+)([hdwm])$/i);
|
||||
if (!match) return null;
|
||||
|
||||
const amount = parseInt(match[1]!, 10);
|
||||
const unit = match[2]!.toLowerCase();
|
||||
const now = Date.now();
|
||||
|
||||
switch (unit) {
|
||||
case "h":
|
||||
return now - amount * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return now - amount * 24 * 60 * 60 * 1000;
|
||||
case "w":
|
||||
return now - amount * 7 * 24 * 60 * 60 * 1000;
|
||||
case "m":
|
||||
return now - amount * 30 * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search command arguments.
|
||||
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
|
||||
*/
|
||||
function tokenizeArgs(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g;
|
||||
for (const match of input.matchAll(pattern)) {
|
||||
const raw = match[1] ?? match[2] ?? match[0];
|
||||
tokens.push(raw.replace(/\\(["'\\])/g, "$1"));
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parseSearchArgs(commandBody: string): {
|
||||
query: string;
|
||||
from?: string;
|
||||
platform?: Platform;
|
||||
since?: number;
|
||||
error?: string;
|
||||
} {
|
||||
const argsStr = commandBody.replace(/^\/search\s*/i, "").trim();
|
||||
if (!argsStr) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
let query = "";
|
||||
let from: string | undefined;
|
||||
let platform: Platform | undefined;
|
||||
let since: number | undefined;
|
||||
|
||||
const parts = tokenizeArgs(argsStr);
|
||||
const queryParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]!;
|
||||
|
||||
if (part === "--from" && i + 1 < parts.length) {
|
||||
const fromParts: string[] = [];
|
||||
while (i + 1 < parts.length && !parts[i + 1]!.startsWith("--")) {
|
||||
fromParts.push(parts[++i]!);
|
||||
}
|
||||
if (fromParts.length === 0) {
|
||||
return { query: "", error: "Missing value for --from" };
|
||||
}
|
||||
from = fromParts.join(" ");
|
||||
} else if (part === "--platform" && i + 1 < parts.length) {
|
||||
platform = parts[++i]!.toLowerCase() as Platform;
|
||||
} else if (part === "--since" && i + 1 < parts.length) {
|
||||
const timeStr = parts[++i]!;
|
||||
const parsed = parseRelativeTime(timeStr);
|
||||
if (parsed === null) {
|
||||
return {
|
||||
query: "",
|
||||
error: `Invalid --since value: ${timeStr}. Use format like 1h, 2d, 1w, 1m`,
|
||||
};
|
||||
}
|
||||
since = parsed;
|
||||
} else if (part.startsWith("--")) {
|
||||
return { query: "", error: `Unknown option: ${part}` };
|
||||
} else {
|
||||
queryParts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
query = queryParts.join(" ");
|
||||
if (!query) {
|
||||
return {
|
||||
query: "",
|
||||
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
|
||||
};
|
||||
}
|
||||
|
||||
return { query, from, platform, since };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
describe("parseSearchArgs", () => {
|
||||
it("handles multi-word --from without quotes", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
|
||||
it("handles quoted multi-word --from", () => {
|
||||
const parsed = parseSearchArgs('/search budget --from "Sarah Smith" --since 1w');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("budget");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
expect(parsed.since).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
it("keeps multi-word query alongside --from", () => {
|
||||
const parsed = parseSearchArgs('/search quarterly report --from Sarah Smith');
|
||||
expect(parsed.error).toBeUndefined();
|
||||
expect(parsed.query).toBe("quarterly report");
|
||||
expect(parsed.from).toBe("Sarah Smith");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { getContactStore } from "../contacts/index.js";
|
||||
import { parseSearchArgs } from "./search-args.js";
|
||||
|
||||
/**
|
||||
* Format a timestamp for display
|
||||
*/
|
||||
function formatTimestamp(ts: number): string {
|
||||
const date = new Date(ts);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString([], { weekday: "short" });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function runSearchCommand(commandBody: string): string {
|
||||
const parsed = parseSearchArgs(commandBody);
|
||||
if (parsed.error) {
|
||||
return `❌ ${parsed.error}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: parsed.query,
|
||||
from: parsed.from,
|
||||
platforms: parsed.platform ? [parsed.platform] : undefined,
|
||||
since: parsed.since,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
let msg = `🔍 No messages found matching "${parsed.query}"`;
|
||||
if (parsed.from) {
|
||||
const contactMatches = store.searchContacts(parsed.from, 5);
|
||||
if (contactMatches.length === 0) {
|
||||
msg += `\n\n⚠️ Note: No contacts found matching "${parsed.from}"`;
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
const lines = [`🔍 Search Results (${results.length})\n`];
|
||||
|
||||
for (const result of results) {
|
||||
const { message, contact, snippet } = result;
|
||||
const senderName = contact?.displayName ?? message.senderId;
|
||||
const time = formatTimestamp(message.timestamp);
|
||||
const platformLabel = message.platform.toUpperCase();
|
||||
|
||||
lines.push(`[${platformLabel}] ${senderName} - ${time}`);
|
||||
lines.push(` ${snippet}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (results.length === 10) {
|
||||
lines.push('Use the CLI for more results: clawdbot search "' + parsed.query + '" --limit 50');
|
||||
}
|
||||
|
||||
return lines.join("\n").trim();
|
||||
} catch (err) {
|
||||
return `❌ Search error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
extractDiscordContact,
|
||||
extractIMessageContact,
|
||||
extractSignalContact,
|
||||
extractSlackContact,
|
||||
extractTelegramContact,
|
||||
extractWhatsAppContact,
|
||||
importContactFromMessage,
|
||||
importDiscordGuildMembers,
|
||||
importSlackUsers,
|
||||
importWhatsAppGroupParticipants,
|
||||
isJidGroup,
|
||||
jidToE164,
|
||||
} from "./importer.js";
|
||||
|
||||
describe("jidToE164", () => {
|
||||
it("extracts phone from standard JID", () => {
|
||||
expect(jidToE164("14155551234@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("extracts phone from JID with device suffix", () => {
|
||||
expect(jidToE164("14155551234:0@s.whatsapp.net")).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for invalid JID", () => {
|
||||
expect(jidToE164("")).toBeNull();
|
||||
expect(jidToE164("invalid")).toBeNull();
|
||||
expect(jidToE164("abc@s.whatsapp.net")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
// Group JIDs don't have phone numbers
|
||||
expect(jidToE164("123456789-1234567890@g.us")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isJidGroup", () => {
|
||||
it("returns true for group JIDs", () => {
|
||||
expect(isJidGroup("123456789-1234567890@g.us")).toBe(true);
|
||||
expect(isJidGroup("status@broadcast")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for user JIDs", () => {
|
||||
expect(isJidGroup("14155551234@s.whatsapp.net")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractWhatsAppContact", () => {
|
||||
it("extracts contact data from sender JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
pushName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for group JID", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "123-456@g.us",
|
||||
pushName: "Group Name",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it("handles missing push name", () => {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: "14155551234@s.whatsapp.net",
|
||||
});
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTelegramContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
username: "johndoe",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles missing fields", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
});
|
||||
expect(data?.username).toBeNull();
|
||||
expect(data?.displayName).toBeNull();
|
||||
});
|
||||
|
||||
it("handles first name only", () => {
|
||||
const data = extractTelegramContact({
|
||||
userId: 123456789,
|
||||
firstName: "John",
|
||||
});
|
||||
expect(data?.displayName).toBe("John");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractDiscordContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
nick: "Johnny",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "discord",
|
||||
platformId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "Johnny", // Nick takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to globalName when no nick", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
globalName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("falls back to username when no globalName", () => {
|
||||
const data = extractDiscordContact({
|
||||
userId: "123456789012345678",
|
||||
username: "johndoe",
|
||||
});
|
||||
expect(data?.displayName).toBe("johndoe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSlackContact", () => {
|
||||
it("extracts contact data from user info", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
displayName: "John Doe",
|
||||
realName: "John Michael Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "slack",
|
||||
platformId: "U12345678",
|
||||
username: "john.doe",
|
||||
phone: null,
|
||||
displayName: "John Doe", // displayName takes precedence
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to realName when no displayName", () => {
|
||||
const data = extractSlackContact({
|
||||
userId: "U12345678",
|
||||
username: "john.doe",
|
||||
realName: "John Doe",
|
||||
});
|
||||
expect(data?.displayName).toBe("John Doe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignalContact", () => {
|
||||
it("extracts contact data from signal envelope", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceUuid: "uuid-123-456",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "signal",
|
||||
platformId: "uuid-123-456", // UUID preferred
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses phone as platformId when no UUID", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceNumber: "+14155551234",
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data?.platformId).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null when no identifier", () => {
|
||||
const data = extractSignalContact({
|
||||
sourceName: "John Doe",
|
||||
});
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIMessageContact", () => {
|
||||
it("extracts contact from phone number", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "+14155551234",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "+14155551234",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts contact from email", () => {
|
||||
const data = extractIMessageContact({
|
||||
senderId: "john@example.com",
|
||||
senderName: "John Doe",
|
||||
});
|
||||
expect(data).toEqual({
|
||||
platform: "imessage",
|
||||
platformId: "john@example.com",
|
||||
username: null,
|
||||
phone: null, // Email is not a phone
|
||||
displayName: "John Doe",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("importContactFromMessage", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("creates new contact for unknown sender", () => {
|
||||
const { contactId, isNew } = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
expect(isNew).toBe(true);
|
||||
expect(contactId).toMatch(/^john-doe-[a-f0-9]{8}$/);
|
||||
|
||||
const contact = store.getContactWithIdentities(contactId);
|
||||
expect(contact?.displayName).toBe("John Doe");
|
||||
expect(contact?.identities.length).toBe(1);
|
||||
expect(contact?.identities[0]?.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("returns existing contact for known sender", () => {
|
||||
// First import
|
||||
const first = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(first.isNew).toBe(true);
|
||||
|
||||
// Second import of same sender
|
||||
const second = importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
expect(second.isNew).toBe(false);
|
||||
expect(second.contactId).toBe(first.contactId);
|
||||
});
|
||||
|
||||
it("updates identity metadata for known sender", () => {
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johndoe",
|
||||
displayName: "John Doe",
|
||||
phone: null,
|
||||
});
|
||||
|
||||
importContactFromMessage(store, {
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "johnny",
|
||||
displayName: "John D",
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("telegram", "123456789");
|
||||
expect(identity?.username).toBe("johnny");
|
||||
expect(identity?.displayName).toBe("John D");
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("uses platformId as displayName fallback", () => {
|
||||
const { contactId } = importContactFromMessage(store, {
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
displayName: null,
|
||||
phone: "+14155551234",
|
||||
});
|
||||
|
||||
const contact = store.getContact(contactId);
|
||||
expect(contact?.displayName).toBe("14155551234@s.whatsapp.net");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulk importers", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("importSlackUsers", () => {
|
||||
it("imports users from Slack API response", async () => {
|
||||
const mockUsers = [
|
||||
{ id: "U1", name: "alice", displayName: "Alice", isBot: false },
|
||||
{ id: "U2", name: "bob", realName: "Bob Smith", isBot: false },
|
||||
{ id: "U3", name: "bot", displayName: "Bot", isBot: true }, // Should be skipped
|
||||
{ id: "U4", name: "deleted", displayName: "Deleted", deleted: true }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importSlackUsers(store, async () => mockUsers);
|
||||
|
||||
expect(result.platform).toBe("slack");
|
||||
expect(result.imported).toBe(2); // Only Alice and Bob
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("handles API errors gracefully", async () => {
|
||||
const result = await importSlackUsers(store, async () => {
|
||||
throw new Error("API error");
|
||||
});
|
||||
|
||||
expect(result.imported).toBe(0);
|
||||
expect(result.errors.length).toBe(1);
|
||||
expect(result.errors[0]).toContain("Failed to list Slack users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("importDiscordGuildMembers", () => {
|
||||
it("imports members from Discord API response", async () => {
|
||||
const mockMembers = [
|
||||
{ user: { id: "1", username: "alice", global_name: "Alice" }, nick: null },
|
||||
{ user: { id: "2", username: "bob", global_name: "Bob" }, nick: "Bobby" },
|
||||
{ user: { id: "3", username: "botuser", bot: true } }, // Should be skipped
|
||||
];
|
||||
|
||||
const result = await importDiscordGuildMembers(store, async () => mockMembers);
|
||||
|
||||
expect(result.platform).toBe("discord");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importWhatsAppGroupParticipants", () => {
|
||||
it("imports participants from group metadata", async () => {
|
||||
const mockGetMetadata = async (_jid: string) => ({
|
||||
subject: "Test Group",
|
||||
participants: [
|
||||
{ id: "14155551111@s.whatsapp.net" },
|
||||
{ id: "14155552222@s.whatsapp.net" },
|
||||
{ id: "123-456@g.us" }, // Group JID should be skipped
|
||||
],
|
||||
});
|
||||
|
||||
const result = await importWhatsAppGroupParticipants(store, mockGetMetadata, "123-456@g.us");
|
||||
|
||||
expect(result.platform).toBe("whatsapp");
|
||||
expect(result.imported).toBe(2);
|
||||
expect(result.errors.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Contact importers for each messaging platform.
|
||||
*
|
||||
* Since many platforms don't have bulk contact APIs, importers use a combination of:
|
||||
* 1. Direct API calls where available (Slack users.list, Discord members)
|
||||
* 2. Message-based discovery (observing incoming messages)
|
||||
* 3. Group metadata extraction
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { ImportResult, Platform, PlatformIdentityInput } from "./types.js";
|
||||
|
||||
/**
|
||||
* Base interface for platform-specific contact importers.
|
||||
*/
|
||||
export type ContactImporter = {
|
||||
/** Platform this importer handles */
|
||||
platform: Platform;
|
||||
|
||||
/** Import contacts from this platform */
|
||||
import(store: ContactStore): Promise<ImportResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract E.164 phone number from WhatsApp JID.
|
||||
* JID format: "1234567890@s.whatsapp.net" or "1234567890:0@s.whatsapp.net"
|
||||
*/
|
||||
export function jidToE164(jid: string): string | null {
|
||||
if (!jid) return null;
|
||||
// Remove suffix
|
||||
const numberPart = jid.split("@")[0];
|
||||
if (!numberPart) return null;
|
||||
// Handle device suffix (e.g., "1234567890:0")
|
||||
const phone = numberPart.split(":")[0];
|
||||
if (!phone || !/^\d{7,15}$/.test(phone)) return null;
|
||||
return `+${phone}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a JID is a group.
|
||||
*/
|
||||
export function isJidGroup(jid: string): boolean {
|
||||
return jid.includes("@g.us") || jid.includes("@broadcast");
|
||||
}
|
||||
|
||||
/**
|
||||
* Data extracted from an incoming message for contact discovery.
|
||||
*/
|
||||
export type MessageContactData = {
|
||||
platform: Platform;
|
||||
platformId: string;
|
||||
username?: string | null;
|
||||
phone?: string | null;
|
||||
displayName?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Import a contact from message data.
|
||||
* Creates a new contact if the platform identity doesn't exist,
|
||||
* or updates the existing contact's metadata.
|
||||
*/
|
||||
export function importContactFromMessage(
|
||||
store: ContactStore,
|
||||
data: MessageContactData,
|
||||
): { contactId: string; isNew: boolean } {
|
||||
// Check if identity already exists
|
||||
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
|
||||
if (existing) {
|
||||
const updated = {
|
||||
contactId: existing.contactId,
|
||||
platform: existing.platform,
|
||||
platformId: existing.platformId,
|
||||
username: data.username ?? existing.username,
|
||||
phone: data.phone ?? existing.phone,
|
||||
displayName: data.displayName ?? existing.displayName,
|
||||
lastSeenAt: Date.now(),
|
||||
} satisfies PlatformIdentityInput;
|
||||
store.addIdentity(updated);
|
||||
return { contactId: existing.contactId, isNew: false };
|
||||
}
|
||||
|
||||
// Create new contact and identity
|
||||
const displayName = data.displayName || data.username || data.platformId;
|
||||
const contact = store.createContact(displayName);
|
||||
|
||||
const input: PlatformIdentityInput = {
|
||||
contactId: contact.canonicalId,
|
||||
platform: data.platform,
|
||||
platformId: data.platformId,
|
||||
username: data.username ?? null,
|
||||
phone: data.phone ?? null,
|
||||
displayName: data.displayName ?? null,
|
||||
lastSeenAt: Date.now(),
|
||||
};
|
||||
store.addIdentity(input);
|
||||
|
||||
return { contactId: contact.canonicalId, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp contact data extraction from message.
|
||||
*/
|
||||
export function extractWhatsAppContact(params: {
|
||||
senderJid: string;
|
||||
pushName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderJid, pushName } = params;
|
||||
if (!senderJid || isJidGroup(senderJid)) return null;
|
||||
|
||||
const phone = jidToE164(senderJid);
|
||||
|
||||
return {
|
||||
platform: "whatsapp",
|
||||
platformId: senderJid,
|
||||
username: null,
|
||||
phone,
|
||||
displayName: pushName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram contact data extraction from message.
|
||||
*/
|
||||
export function extractTelegramContact(params: {
|
||||
userId: number | string;
|
||||
username?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, firstName, lastName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
const displayName = [firstName, lastName].filter(Boolean).join(" ") || null;
|
||||
|
||||
return {
|
||||
platform: "telegram",
|
||||
platformId: String(userId),
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord contact data extraction from message.
|
||||
*/
|
||||
export function extractDiscordContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
globalName?: string | null;
|
||||
nick?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, globalName, nick } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
// Prefer display names: nick > globalName > username
|
||||
const displayName = nick || globalName || username || null;
|
||||
|
||||
return {
|
||||
platform: "discord",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack contact data extraction from message.
|
||||
*/
|
||||
export function extractSlackContact(params: {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
displayName?: string | null;
|
||||
realName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { userId, username, displayName, realName } = params;
|
||||
if (!userId) return null;
|
||||
|
||||
return {
|
||||
platform: "slack",
|
||||
platformId: userId,
|
||||
username: username ?? null,
|
||||
phone: null,
|
||||
displayName: displayName || realName || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal contact data extraction from message.
|
||||
*/
|
||||
export function extractSignalContact(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
sourceName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { sourceNumber, sourceUuid, sourceName } = params;
|
||||
|
||||
// Prefer UUID as platformId, fall back to phone
|
||||
const platformId = sourceUuid || sourceNumber;
|
||||
if (!platformId) return null;
|
||||
|
||||
return {
|
||||
platform: "signal",
|
||||
platformId,
|
||||
username: null,
|
||||
phone: sourceNumber ?? null,
|
||||
displayName: sourceName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* iMessage contact data extraction from message.
|
||||
*/
|
||||
export function extractIMessageContact(params: {
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
}): MessageContactData | null {
|
||||
const { senderId, senderName } = params;
|
||||
if (!senderId) return null;
|
||||
|
||||
// iMessage senderId can be phone or email
|
||||
const isPhone = /^\+?\d{10,}$/.test(senderId.replace(/\D/g, ""));
|
||||
|
||||
return {
|
||||
platform: "imessage",
|
||||
platformId: senderId,
|
||||
username: null,
|
||||
phone: isPhone ? senderId : null,
|
||||
displayName: senderName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BULK IMPORTERS (for platforms with bulk APIs)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slack bulk importer using users.list API.
|
||||
*/
|
||||
export async function importSlackUsers(
|
||||
store: ContactStore,
|
||||
listUsers: () => Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
realName?: string;
|
||||
email?: string;
|
||||
isBot?: boolean;
|
||||
deleted?: boolean;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "slack",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const users = await listUsers();
|
||||
|
||||
for (const user of users) {
|
||||
// Skip bots and deleted users
|
||||
if (user.isBot || user.deleted) continue;
|
||||
if (!user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractSlackContact({
|
||||
userId: user.id,
|
||||
username: user.name,
|
||||
displayName: user.displayName,
|
||||
realName: user.realName,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import user ${user.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Slack users: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord bulk importer using guild member search.
|
||||
*/
|
||||
export async function importDiscordGuildMembers(
|
||||
store: ContactStore,
|
||||
listMembers: () => Promise<
|
||||
Array<{
|
||||
user: {
|
||||
id: string;
|
||||
username?: string;
|
||||
global_name?: string;
|
||||
bot?: boolean;
|
||||
};
|
||||
nick?: string | null;
|
||||
}>
|
||||
>,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "discord",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const members = await listMembers();
|
||||
|
||||
for (const member of members) {
|
||||
// Skip bots
|
||||
if (member.user.bot) continue;
|
||||
if (!member.user.id) continue;
|
||||
|
||||
try {
|
||||
const data = extractDiscordContact({
|
||||
userId: member.user.id,
|
||||
username: member.user.username,
|
||||
globalName: member.user.global_name,
|
||||
nick: member.nick,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import member ${member.user.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to list Discord members: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp group participants importer.
|
||||
*/
|
||||
export async function importWhatsAppGroupParticipants(
|
||||
store: ContactStore,
|
||||
getGroupMetadata: (groupJid: string) => Promise<{
|
||||
subject?: string;
|
||||
participants?: Array<{ id: string }>;
|
||||
}>,
|
||||
groupJid: string,
|
||||
): Promise<ImportResult> {
|
||||
const result: ImportResult = {
|
||||
platform: "whatsapp",
|
||||
imported: 0,
|
||||
linked: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const meta = await getGroupMetadata(groupJid);
|
||||
const participants = meta.participants ?? [];
|
||||
|
||||
for (const participant of participants) {
|
||||
if (!participant.id) continue;
|
||||
if (isJidGroup(participant.id)) continue;
|
||||
|
||||
try {
|
||||
const data = extractWhatsAppContact({
|
||||
senderJid: participant.id,
|
||||
pushName: null, // Group metadata doesn't include push names
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { isNew } = importContactFromMessage(store, data);
|
||||
if (isNew) result.imported++;
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to import participant ${participant.id}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to get group metadata for ${groupJid}: ${String(err)}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Contacts module - Unified contact graph for cross-platform identity resolution.
|
||||
*
|
||||
* This module provides:
|
||||
* - Canonical contact management (create, link, search)
|
||||
* - Platform identity linking (WhatsApp, Telegram, Discord, Slack, Signal, etc.)
|
||||
* - Message indexing for cross-platform search
|
||||
* - Auto-linking heuristics based on phone/email/name matching
|
||||
*/
|
||||
|
||||
export { ContactStore, configureContactStore, getContactStore, closeContactStore } from "./store.js";
|
||||
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
|
||||
export {
|
||||
importContactFromMessage,
|
||||
extractWhatsAppContact,
|
||||
extractTelegramContact,
|
||||
extractDiscordContact,
|
||||
extractSlackContact,
|
||||
extractSignalContact,
|
||||
extractIMessageContact,
|
||||
importSlackUsers,
|
||||
importDiscordGuildMembers,
|
||||
importWhatsAppGroupParticipants,
|
||||
jidToE164,
|
||||
isJidGroup,
|
||||
} from "./importer.js";
|
||||
export type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
ImportResult,
|
||||
IndexedMessage,
|
||||
LinkConfidence,
|
||||
LinkSuggestion,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
export type { ContactImporter, MessageContactData } from "./importer.js";
|
||||
export {
|
||||
findPhoneMatches,
|
||||
findNameMatches,
|
||||
findLinkSuggestions,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
autoLinkHighConfidence,
|
||||
} from "./linker.js";
|
||||
@@ -0,0 +1,600 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import {
|
||||
autoLinkHighConfidence,
|
||||
findLinkSuggestions,
|
||||
findNameMatches,
|
||||
findPhoneMatches,
|
||||
linkContacts,
|
||||
unlinkIdentity,
|
||||
} from "./linker.js";
|
||||
|
||||
describe("linker", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("findPhoneMatches", () => {
|
||||
it("finds contacts with same phone number", () => {
|
||||
// Create two contacts with same phone on different platforms
|
||||
const contact1 = store.createContact("John on WhatsApp");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Signal");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "uuid-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(1);
|
||||
expect(suggestions[0]?.reason).toBe("phone_match");
|
||||
expect(suggestions[0]?.confidence).toBe("high");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("does not suggest already-linked contacts", () => {
|
||||
const contact = store.createContact("John");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-john",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "John Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty for no phone matches", () => {
|
||||
const contact1 = store.createContact("Alice");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-alice",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "Alice",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-bob",
|
||||
username: null,
|
||||
phone: "+14155552222",
|
||||
displayName: "Bob",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findPhoneMatches(store);
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNameMatches", () => {
|
||||
it("finds contacts with similar names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Doe"); // Same name
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findNameMatches(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0]?.reason).toBe("name_similarity");
|
||||
expect(suggestions[0]?.score).toBe(1.0);
|
||||
});
|
||||
|
||||
it("finds contacts with slightly different names", () => {
|
||||
const contact1 = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Doe",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John D"); // Shorter version
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John D",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// With default threshold of 0.85, these may or may not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.6 });
|
||||
// At least should find something with low threshold
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects minimum score threshold", () => {
|
||||
const contact1 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Bob Jones");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-bob",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Bob Jones",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Completely different names should not match
|
||||
const suggestions = findNameMatches(store, { minScore: 0.85 });
|
||||
expect(suggestions.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findLinkSuggestions", () => {
|
||||
it("combines phone and name matches", () => {
|
||||
// Phone match
|
||||
const contact1 = store.createContact("Phone User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Phone User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Phone User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
// Name match (different person)
|
||||
const contact3 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact3.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact4 = store.createContact("Alice Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact4.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-alice",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "Alice Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Phone matches should come first (high confidence)
|
||||
const phoneMatch = suggestions.find((s) => s.reason === "phone_match");
|
||||
expect(phoneMatch).toBeDefined();
|
||||
expect(phoneMatch?.confidence).toBe("high");
|
||||
});
|
||||
|
||||
it("sorts by confidence then score", () => {
|
||||
// Create multiple potential matches with different confidence levels
|
||||
const contact1 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Test User A");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-a",
|
||||
username: null,
|
||||
phone: "+14155559999",
|
||||
displayName: "Test User A",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify sorted by confidence
|
||||
for (let i = 1; i < suggestions.length; i++) {
|
||||
const prev = suggestions[i - 1]!;
|
||||
const curr = suggestions[i]!;
|
||||
const confidenceOrder = { high: 3, medium: 2, low: 1 };
|
||||
const prevConf = confidenceOrder[prev.confidence];
|
||||
const currConf = confidenceOrder[curr.confidence];
|
||||
expect(prevConf).toBeGreaterThanOrEqual(currConf);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("linkContacts", () => {
|
||||
it("merges two contacts", () => {
|
||||
const contact1 = store.createContact("John on Telegram");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe_tg",
|
||||
phone: null,
|
||||
displayName: "John TG",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John on Discord");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: "johndoe_dc",
|
||||
phone: null,
|
||||
displayName: "John DC",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact1.canonicalId, contact2.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Primary contact should have both identities
|
||||
const merged = store.getContactWithIdentities(contact1.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(merged?.identities.map((i) => i.platform)).toContain("discord");
|
||||
|
||||
// Secondary contact should be deleted
|
||||
expect(store.getContact(contact2.canonicalId)).toBeNull();
|
||||
|
||||
// Aliases should include secondary contact's name
|
||||
expect(merged?.aliases).toContain("John on Discord");
|
||||
});
|
||||
|
||||
it("reassigns message history when merging contacts", () => {
|
||||
const primary = store.createContact("Merge Primary");
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
const secondary = store.createContact("Merge Secondary");
|
||||
store.addIdentity({
|
||||
contactId: secondary.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-merge",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Merge",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-merge-1",
|
||||
content: "merge history one",
|
||||
platform: "telegram",
|
||||
senderId: "tg-merge",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-merge-2",
|
||||
content: "merge history two",
|
||||
platform: "discord",
|
||||
senderId: "dc-merge",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = linkContacts(store, primary.canonicalId, secondary.canonicalId);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const results = store.searchMessages({ query: "merge history", from: primary.canonicalId });
|
||||
expect(results.length).toBe(2);
|
||||
const ids = results.map((entry) => entry.message.id);
|
||||
expect(ids).toContain("msg-merge-1");
|
||||
expect(ids).toContain("msg-merge-2");
|
||||
expect(results.every((entry) => entry.message.contactId === primary.canonicalId)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns error for non-existent primary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, "fake-id", contact.canonicalId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Primary contact not found");
|
||||
});
|
||||
|
||||
it("returns error for non-existent secondary contact", () => {
|
||||
const contact = store.createContact("Test");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-test",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = linkContacts(store, contact.canonicalId, "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Secondary contact not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinkIdentity", () => {
|
||||
it("creates new contact for unlinked identity", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-user",
|
||||
username: "user_tg",
|
||||
phone: null,
|
||||
displayName: "TG User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-user",
|
||||
username: "user_dc",
|
||||
phone: null,
|
||||
displayName: "DC User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-user");
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.newContactId).toBeDefined();
|
||||
|
||||
// Original contact should only have telegram identity
|
||||
const original = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(original?.identities.length).toBe(1);
|
||||
expect(original?.identities[0]?.platform).toBe("telegram");
|
||||
|
||||
// New contact should have discord identity
|
||||
const newContact = store.getContactWithIdentities(result.newContactId!);
|
||||
expect(newContact?.identities.length).toBe(1);
|
||||
expect(newContact?.identities[0]?.platform).toBe("discord");
|
||||
});
|
||||
|
||||
it("moves message history to new contact when unlinking", () => {
|
||||
const contact = store.createContact("Unlink User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "TG Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-unlink",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "DC Unlink",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-unlink",
|
||||
content: "unlink history message",
|
||||
platform: "discord",
|
||||
senderId: "dc-unlink",
|
||||
channelId: "c3",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "discord", "dc-unlink");
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const newContactId = result.newContactId!;
|
||||
const newResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: newContactId,
|
||||
});
|
||||
expect(newResults.length).toBe(1);
|
||||
expect(newResults[0]?.message.contactId).toBe(newContactId);
|
||||
|
||||
const oldResults = store.searchMessages({
|
||||
query: "unlink history",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(oldResults.length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns error for non-existent identity", () => {
|
||||
const result = unlinkIdentity(store, "telegram", "fake-id");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Identity not found");
|
||||
});
|
||||
|
||||
it("returns error when trying to unlink only identity", () => {
|
||||
const contact = store.createContact("Single Identity User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-single",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = unlinkIdentity(store, "telegram", "tg-single");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Cannot unlink the only identity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoLinkHighConfidence", () => {
|
||||
it("automatically links high confidence matches", () => {
|
||||
// Create contacts with same phone (high confidence)
|
||||
const contact1 = store.createContact("Auto Link User 1");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User WA",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("Auto Link User 2");
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-auto",
|
||||
username: null,
|
||||
phone: "+14155557777",
|
||||
displayName: "Auto User Signal",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const initialCount = store.listContacts().length;
|
||||
expect(initialCount).toBe(2);
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
expect(result.linked).toBe(1);
|
||||
|
||||
// Should now have only one contact
|
||||
const finalCount = store.listContacts().length;
|
||||
expect(finalCount).toBe(1);
|
||||
|
||||
// The remaining contact should have both identities
|
||||
const contacts = store.listContacts();
|
||||
const merged = store.getContactWithIdentities(contacts[0]!.canonicalId);
|
||||
expect(merged?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("does not link medium/low confidence matches", () => {
|
||||
// Create contacts with similar but not exact names (medium confidence)
|
||||
const contact1 = store.createContact("John Smith");
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smith",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const contact2 = store.createContact("John Smyth"); // Similar but not same
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-john",
|
||||
username: null,
|
||||
phone: null,
|
||||
displayName: "John Smyth",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = autoLinkHighConfidence(store);
|
||||
|
||||
// Name similarity below threshold should not auto-link
|
||||
// They may or may not be linked depending on exact similarity
|
||||
// But we verify auto-link only processes high confidence
|
||||
expect(result.suggestions.every((s) => s.confidence === "high")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Auto-linking heuristics for the unified contact graph.
|
||||
*
|
||||
* This module provides algorithms to automatically detect and suggest
|
||||
* links between platform identities that likely belong to the same person.
|
||||
*
|
||||
* Linking heuristics (in priority order):
|
||||
* 1. Phone match: Same E.164 phone across platforms (high confidence)
|
||||
* 2. Name similarity: Fuzzy name matching with high threshold (medium confidence)
|
||||
*/
|
||||
|
||||
import type { ContactStore } from "./store.js";
|
||||
import type { LinkConfidence, LinkSuggestion, PlatformIdentity } from "./types.js";
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings.
|
||||
*/
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
if (a.length === 0) return b.length;
|
||||
if (b.length === 0) return a.length;
|
||||
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize matrix
|
||||
for (let i = 0; i <= b.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= a.length; j++) {
|
||||
matrix[0]![j] = j;
|
||||
}
|
||||
|
||||
// Fill in the rest of the matrix
|
||||
for (let i = 1; i <= b.length; i++) {
|
||||
for (let j = 1; j <= a.length; j++) {
|
||||
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j - 1]! + cost, // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[b.length]![a.length]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity score between two strings (0-1).
|
||||
* 1 = identical, 0 = completely different.
|
||||
*/
|
||||
function calculateSimilarity(a: string, b: string): number {
|
||||
if (!a || !b) return 0;
|
||||
|
||||
// Normalize: lowercase, trim, remove extra whitespace
|
||||
const normalizedA = a.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
const normalizedB = b.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
|
||||
if (normalizedA === normalizedB) return 1;
|
||||
if (normalizedA.length === 0 || normalizedB.length === 0) return 0;
|
||||
|
||||
const maxLength = Math.max(normalizedA.length, normalizedB.length);
|
||||
const distance = levenshteinDistance(normalizedA, normalizedB);
|
||||
|
||||
return 1 - distance / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number for comparison.
|
||||
* Strips non-digit characters except leading +.
|
||||
*/
|
||||
function normalizePhone(phone: string | null): string | null {
|
||||
if (!phone) return null;
|
||||
// Keep only digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if 10 digits without country code
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized.length >= 10 ? normalized : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on phone number matching.
|
||||
* This is the highest confidence match since phone numbers are unique identifiers.
|
||||
*/
|
||||
export function findPhoneMatches(store: ContactStore): LinkSuggestion[] {
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
|
||||
// Build phone → identities map
|
||||
const phoneToIdentities = new Map<string, PlatformIdentity[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const withIdentities = store.getContactWithIdentities(contact.canonicalId);
|
||||
if (!withIdentities) continue;
|
||||
|
||||
for (const identity of withIdentities.identities) {
|
||||
const phone = normalizePhone(identity.phone);
|
||||
if (!phone) continue;
|
||||
|
||||
const existing = phoneToIdentities.get(phone) ?? [];
|
||||
existing.push(identity);
|
||||
phoneToIdentities.set(phone, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Find identities with same phone but different contacts
|
||||
for (const [_phone, identities] of phoneToIdentities) {
|
||||
if (identities.length < 2) continue;
|
||||
|
||||
// Group by contact ID
|
||||
const byContact = new Map<string, PlatformIdentity[]>();
|
||||
for (const identity of identities) {
|
||||
const existing = byContact.get(identity.contactId) ?? [];
|
||||
existing.push(identity);
|
||||
byContact.set(identity.contactId, existing);
|
||||
}
|
||||
|
||||
// If all belong to same contact, already linked
|
||||
if (byContact.size < 2) continue;
|
||||
|
||||
// Create suggestions for each pair of contacts
|
||||
const contactIds = Array.from(byContact.keys());
|
||||
for (let i = 0; i < contactIds.length; i++) {
|
||||
for (let j = i + 1; j < contactIds.length; j++) {
|
||||
const sourceIdentity = byContact.get(contactIds[i]!)![0]!;
|
||||
const targetIdentity = byContact.get(contactIds[j]!)![0]!;
|
||||
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "phone_match",
|
||||
confidence: "high",
|
||||
score: 1.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find link suggestions based on name similarity.
|
||||
* Uses fuzzy matching with a configurable threshold.
|
||||
*/
|
||||
export function findNameMatches(
|
||||
store: ContactStore,
|
||||
options: { minScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const { minScore = 0.85 } = options;
|
||||
const suggestions: LinkSuggestion[] = [];
|
||||
|
||||
// Get all contacts with their identities
|
||||
const contacts = store.listContacts();
|
||||
const contactsWithIdentities = contacts
|
||||
.map((c) => store.getContactWithIdentities(c.canonicalId))
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
|
||||
// Compare each pair of contacts
|
||||
for (let i = 0; i < contactsWithIdentities.length; i++) {
|
||||
for (let j = i + 1; j < contactsWithIdentities.length; j++) {
|
||||
const contactA = contactsWithIdentities[i]!;
|
||||
const contactB = contactsWithIdentities[j]!;
|
||||
|
||||
// Skip if already same contact
|
||||
if (contactA.canonicalId === contactB.canonicalId) continue;
|
||||
|
||||
// Compare display names
|
||||
const similarity = calculateSimilarity(contactA.displayName, contactB.displayName);
|
||||
|
||||
if (similarity >= minScore) {
|
||||
// Get representative identities for the suggestion
|
||||
const sourceIdentity = contactA.identities[0];
|
||||
const targetIdentity = contactB.identities[0];
|
||||
|
||||
if (sourceIdentity && targetIdentity) {
|
||||
suggestions.push({
|
||||
sourceIdentity,
|
||||
targetIdentity,
|
||||
reason: "name_similarity",
|
||||
confidence: similarity >= 0.95 ? "high" : "medium",
|
||||
score: similarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also compare identity display names
|
||||
for (const identityA of contactA.identities) {
|
||||
for (const identityB of contactB.identities) {
|
||||
if (!identityA.displayName || !identityB.displayName) continue;
|
||||
|
||||
const identitySimilarity = calculateSimilarity(
|
||||
identityA.displayName,
|
||||
identityB.displayName,
|
||||
);
|
||||
|
||||
if (identitySimilarity >= minScore) {
|
||||
// Avoid duplicate suggestions
|
||||
const alreadySuggested = suggestions.some(
|
||||
(s) =>
|
||||
(s.sourceIdentity.id === identityA.id && s.targetIdentity.id === identityB.id) ||
|
||||
(s.sourceIdentity.id === identityB.id && s.targetIdentity.id === identityA.id),
|
||||
);
|
||||
|
||||
if (!alreadySuggested) {
|
||||
suggestions.push({
|
||||
sourceIdentity: identityA,
|
||||
targetIdentity: identityB,
|
||||
reason: "name_similarity",
|
||||
confidence: identitySimilarity >= 0.95 ? "high" : "medium",
|
||||
score: identitySimilarity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all link suggestions across all heuristics.
|
||||
* Returns suggestions sorted by confidence and score.
|
||||
*/
|
||||
export function findLinkSuggestions(
|
||||
store: ContactStore,
|
||||
options: { minNameScore?: number } = {},
|
||||
): LinkSuggestion[] {
|
||||
const phoneSuggestions = findPhoneMatches(store);
|
||||
const nameSuggestions = findNameMatches(store, { minScore: options.minNameScore });
|
||||
|
||||
// Combine and sort by confidence (high first) then score
|
||||
const all = [...phoneSuggestions, ...nameSuggestions];
|
||||
|
||||
const confidenceOrder: Record<LinkConfidence, number> = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
|
||||
return all.sort((a, b) => {
|
||||
const confDiff = confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
|
||||
if (confDiff !== 0) return confDiff;
|
||||
return b.score - a.score;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two contacts by merging all identities into the primary contact.
|
||||
* The secondary contact is deleted.
|
||||
*/
|
||||
export function linkContacts(
|
||||
store: ContactStore,
|
||||
primaryContactId: string,
|
||||
secondaryContactId: string,
|
||||
): { success: boolean; error?: string } {
|
||||
const primary = store.getContactWithIdentities(primaryContactId);
|
||||
const secondary = store.getContactWithIdentities(secondaryContactId);
|
||||
|
||||
if (!primary) {
|
||||
return { success: false, error: `Primary contact not found: ${primaryContactId}` };
|
||||
}
|
||||
if (!secondary) {
|
||||
return { success: false, error: `Secondary contact not found: ${secondaryContactId}` };
|
||||
}
|
||||
|
||||
// Move all identities from secondary to primary
|
||||
for (const identity of secondary.identities) {
|
||||
store.addIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: primary.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge aliases
|
||||
const newAliases = [...primary.aliases];
|
||||
if (!newAliases.includes(secondary.displayName)) {
|
||||
newAliases.push(secondary.displayName);
|
||||
}
|
||||
for (const alias of secondary.aliases) {
|
||||
if (!newAliases.includes(alias)) {
|
||||
newAliases.push(alias);
|
||||
}
|
||||
}
|
||||
store.updateContact(primary.canonicalId, { aliases: newAliases });
|
||||
|
||||
// Delete secondary contact
|
||||
store.deleteContact(secondaryContactId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a platform identity from its current contact.
|
||||
* Creates a new contact for the identity.
|
||||
*/
|
||||
export function unlinkIdentity(
|
||||
store: ContactStore,
|
||||
platform: string,
|
||||
platformId: string,
|
||||
): { success: boolean; newContactId?: string; error?: string } {
|
||||
const identity = store.getIdentityByPlatformId(platform, platformId);
|
||||
if (!identity) {
|
||||
return { success: false, error: `Identity not found: ${platform}:${platformId}` };
|
||||
}
|
||||
|
||||
const currentContact = store.getContactWithIdentities(identity.contactId);
|
||||
if (!currentContact) {
|
||||
return { success: false, error: `Contact not found: ${identity.contactId}` };
|
||||
}
|
||||
|
||||
// If this is the only identity, nothing to unlink
|
||||
if (currentContact.identities.length === 1) {
|
||||
return { success: false, error: "Cannot unlink the only identity from a contact" };
|
||||
}
|
||||
|
||||
// Create new contact for this identity
|
||||
const displayName = identity.displayName || identity.username || identity.platformId;
|
||||
const newContact = store.createContact(displayName);
|
||||
|
||||
// Move the identity to the new contact
|
||||
store.addIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
platformId: identity.platformId,
|
||||
username: identity.username,
|
||||
phone: identity.phone,
|
||||
displayName: identity.displayName,
|
||||
lastSeenAt: identity.lastSeenAt,
|
||||
});
|
||||
store.updateMessageContactForIdentity({
|
||||
contactId: newContact.canonicalId,
|
||||
platform: identity.platform,
|
||||
senderId: identity.platformId,
|
||||
});
|
||||
|
||||
return { success: true, newContactId: newContact.canonicalId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-apply high-confidence link suggestions.
|
||||
* Returns the number of links applied.
|
||||
*/
|
||||
export function autoLinkHighConfidence(store: ContactStore): {
|
||||
linked: number;
|
||||
suggestions: LinkSuggestion[];
|
||||
} {
|
||||
const suggestions = findLinkSuggestions(store);
|
||||
const highConfidence = suggestions.filter((s) => s.confidence === "high");
|
||||
|
||||
let linked = 0;
|
||||
const processedContacts = new Set<string>();
|
||||
|
||||
for (const suggestion of highConfidence) {
|
||||
const sourceContactId = suggestion.sourceIdentity.contactId;
|
||||
const targetContactId = suggestion.targetIdentity.contactId;
|
||||
|
||||
// Skip if either contact was already processed (merged into another)
|
||||
if (processedContacts.has(sourceContactId) || processedContacts.has(targetContactId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if same contact (already linked)
|
||||
if (sourceContactId === targetContactId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = linkContacts(store, sourceContactId, targetContactId);
|
||||
if (result.success) {
|
||||
linked++;
|
||||
processedContacts.add(targetContactId);
|
||||
}
|
||||
}
|
||||
|
||||
return { linked, suggestions: highConfidence };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
/**
|
||||
* Ensures the contact store schema is created in the SQLite database.
|
||||
* Creates tables for contacts, platform identities, and indexed messages.
|
||||
*/
|
||||
export function ensureContactStoreSchema(db: DatabaseSync): {
|
||||
ftsAvailable: boolean;
|
||||
ftsError?: string;
|
||||
} {
|
||||
// Unified contacts table - canonical contact records
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
canonical_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
aliases TEXT NOT NULL DEFAULT '[]',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Platform identities table - links platform-specific IDs to canonical contacts
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS platform_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_id TEXT NOT NULL REFERENCES contacts(canonical_id) ON DELETE CASCADE,
|
||||
platform TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
username TEXT,
|
||||
phone TEXT,
|
||||
display_name TEXT,
|
||||
last_seen_at INTEGER,
|
||||
UNIQUE(platform, platform_id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexed messages table - for cross-platform message search
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS indexed_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
contact_id TEXT REFERENCES contacts(canonical_id) ON DELETE SET NULL,
|
||||
platform TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
embedding TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexes for efficient queries
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_contact_id ON platform_identities(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_platform ON platform_identities(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_phone ON platform_identities(phone);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_platform_identities_username ON platform_identities(username);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_contact_id ON indexed_messages(contact_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_platform ON indexed_messages(platform);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_sender_id ON indexed_messages(sender_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_channel_id ON indexed_messages(channel_id);`,
|
||||
);
|
||||
db.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_timestamp ON indexed_messages(timestamp);`,
|
||||
);
|
||||
|
||||
// Full-text search virtual table for message content
|
||||
let ftsAvailable = false;
|
||||
let ftsError: string | undefined;
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
||||
content,
|
||||
id UNINDEXED,
|
||||
contact_id UNINDEXED,
|
||||
platform UNINDEXED,
|
||||
sender_id UNINDEXED,
|
||||
channel_id UNINDEXED,
|
||||
timestamp UNINDEXED
|
||||
);
|
||||
`);
|
||||
ftsAvailable = true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
ftsAvailable = false;
|
||||
ftsError = message;
|
||||
}
|
||||
|
||||
// Migration helper - add columns if they don't exist
|
||||
ensureColumn(db, "contacts", "aliases", "TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a column exists on a table, adding it if missing.
|
||||
*/
|
||||
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (rows.some((row) => row.name === column)) return;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all contact store tables (for testing/reset).
|
||||
*/
|
||||
export function dropContactStoreTables(db: DatabaseSync): void {
|
||||
db.exec(`DROP TABLE IF EXISTS messages_fts;`);
|
||||
db.exec(`DROP TABLE IF EXISTS indexed_messages;`);
|
||||
db.exec(`DROP TABLE IF EXISTS platform_identities;`);
|
||||
db.exec(`DROP TABLE IF EXISTS contacts;`);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ContactStore } from "./store.js";
|
||||
import type { Platform } from "./types.js";
|
||||
|
||||
describe("ContactStore", () => {
|
||||
let store: ContactStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = ContactStore.openInMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
describe("contacts", () => {
|
||||
it("creates a contact with generated canonical ID", () => {
|
||||
const contact = store.createContact("Sarah Jones");
|
||||
expect(contact.canonicalId).toMatch(/^sarah-jones-[a-f0-9]{8}$/);
|
||||
expect(contact.displayName).toBe("Sarah Jones");
|
||||
expect(contact.aliases).toEqual([]);
|
||||
expect(contact.createdAt).toBeGreaterThan(0);
|
||||
expect(contact.updatedAt).toBe(contact.createdAt);
|
||||
});
|
||||
|
||||
it("creates a contact with aliases", () => {
|
||||
const contact = store.createContact("Bob Smith", ["Bobby", "Bob S"]);
|
||||
expect(contact.aliases).toEqual(["Bobby", "Bob S"]);
|
||||
});
|
||||
|
||||
it("retrieves a contact by canonical ID", () => {
|
||||
const created = store.createContact("Alice Doe");
|
||||
const retrieved = store.getContact(created.canonicalId);
|
||||
expect(retrieved).toEqual(created);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const retrieved = store.getContact("non-existent-id");
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it("updates contact display name", () => {
|
||||
const contact = store.createContact("Old Name");
|
||||
const success = store.updateContact(contact.canonicalId, { displayName: "New Name" });
|
||||
expect(success).toBe(true);
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.displayName).toBe("New Name");
|
||||
// updatedAt should be >= createdAt (may be same millisecond in fast tests)
|
||||
expect(updated?.updatedAt).toBeGreaterThanOrEqual(contact.updatedAt);
|
||||
});
|
||||
|
||||
it("updates contact aliases", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
store.updateContact(contact.canonicalId, { aliases: ["Tester", "TU"] });
|
||||
|
||||
const updated = store.getContact(contact.canonicalId);
|
||||
expect(updated?.aliases).toEqual(["Tester", "TU"]);
|
||||
});
|
||||
|
||||
it("returns false when updating non-existent contact", () => {
|
||||
const success = store.updateContact("fake-id", { displayName: "Test" });
|
||||
expect(success).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a contact", () => {
|
||||
const contact = store.createContact("To Delete");
|
||||
expect(store.getContact(contact.canonicalId)).not.toBeNull();
|
||||
|
||||
const deleted = store.deleteContact(contact.canonicalId);
|
||||
expect(deleted).toBe(true);
|
||||
expect(store.getContact(contact.canonicalId)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns false when deleting non-existent contact", () => {
|
||||
const deleted = store.deleteContact("fake-id");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("lists all contacts", () => {
|
||||
store.createContact("Alpha User");
|
||||
store.createContact("Beta User");
|
||||
store.createContact("Gamma User");
|
||||
|
||||
const contacts = store.listContacts();
|
||||
expect(contacts.length).toBe(3);
|
||||
});
|
||||
|
||||
it("lists contacts with query filter", () => {
|
||||
store.createContact("John Doe");
|
||||
store.createContact("Jane Doe", ["Janey"]);
|
||||
store.createContact("Bob Smith");
|
||||
|
||||
const contacts = store.listContacts({ query: "doe" });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("lists contacts with limit", () => {
|
||||
store.createContact("User 1");
|
||||
store.createContact("User 2");
|
||||
store.createContact("User 3");
|
||||
|
||||
const contacts = store.listContacts({ limit: 2 });
|
||||
expect(contacts.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("platform identities", () => {
|
||||
it("adds a platform identity to a contact", () => {
|
||||
const contact = store.createContact("Test User");
|
||||
const identity = store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "123456789",
|
||||
username: "testuser",
|
||||
phone: null,
|
||||
displayName: "Test User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
expect(identity.id).toBeGreaterThan(0);
|
||||
expect(identity.contactId).toBe(contact.canonicalId);
|
||||
expect(identity.platform).toBe("telegram");
|
||||
expect(identity.platformId).toBe("123456789");
|
||||
});
|
||||
|
||||
it("retrieves identities by contact ID", () => {
|
||||
const contact = store.createContact("Multi Platform User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-123",
|
||||
username: "teleuser",
|
||||
phone: null,
|
||||
displayName: "Tele User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-456",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.getIdentitiesByContact(contact.canonicalId);
|
||||
expect(identities.length).toBe(2);
|
||||
expect(identities.map((i) => i.platform)).toContain("telegram");
|
||||
expect(identities.map((i) => i.platform)).toContain("discord");
|
||||
});
|
||||
|
||||
it("retrieves identity by platform and platform ID", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "+14155551234@s.whatsapp.net",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identity = store.getIdentityByPlatformId("whatsapp", "+14155551234@s.whatsapp.net");
|
||||
expect(identity).not.toBeNull();
|
||||
expect(identity?.contactId).toBe(contact.canonicalId);
|
||||
expect(identity?.phone).toBe("+14155551234");
|
||||
});
|
||||
|
||||
it("returns null for non-existent identity", () => {
|
||||
const identity = store.getIdentityByPlatformId("telegram", "fake-id");
|
||||
expect(identity).toBeNull();
|
||||
});
|
||||
|
||||
it("finds identities by phone number", () => {
|
||||
const contact = store.createContact("Phone User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-jid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "WA User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "signal",
|
||||
platformId: "signal-uuid-1",
|
||||
username: null,
|
||||
phone: "+14155551234",
|
||||
displayName: "Signal User",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const identities = store.findIdentitiesByPhone("+14155551234");
|
||||
expect(identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("updates last seen timestamp", () => {
|
||||
const contact = store.createContact("User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-id",
|
||||
username: "user",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.updateIdentityLastSeen("telegram", "tg-id");
|
||||
const identity = store.getIdentityByPlatformId("telegram", "tg-id");
|
||||
expect(identity?.lastSeenAt).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolves platform sender to contact ID", () => {
|
||||
const contact = store.createContact("Resolvable User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "discord-user-id",
|
||||
username: "discorduser",
|
||||
phone: null,
|
||||
displayName: "Discord Display",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const resolved = store.resolveContact("discord", "discord-user-id");
|
||||
expect(resolved).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("returns null when resolving unknown sender", () => {
|
||||
const resolved = store.resolveContact("telegram", "unknown-id");
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("contact search", () => {
|
||||
it("searches contacts by display name", () => {
|
||||
store.createContact("Alice Wonderland");
|
||||
store.createContact("Bob Builder");
|
||||
store.createContact("Alice Cooper");
|
||||
|
||||
const results = store.searchContacts("alice");
|
||||
expect(results.length).toBe(2);
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Wonderland");
|
||||
expect(results.map((r) => r.displayName)).toContain("Alice Cooper");
|
||||
});
|
||||
|
||||
it("searches contacts by username", () => {
|
||||
const contact = store.createContact("John Doe");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-john",
|
||||
username: "johndoe",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("johndoe");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.displayName).toBe("John Doe");
|
||||
});
|
||||
|
||||
it("returns contact with all identities", () => {
|
||||
const contact = store.createContact("Multi User");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-multi",
|
||||
username: "multi_tg",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "slack",
|
||||
platformId: "slack-multi",
|
||||
username: "multi_slack",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const results = store.searchContacts("multi");
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.identities.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message indexing", () => {
|
||||
it("indexes a message", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-1",
|
||||
content: "Hello, this is a test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-123",
|
||||
channelId: "channel-456",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.messages).toBe(1);
|
||||
});
|
||||
|
||||
it("links message to contact when sender is resolved", () => {
|
||||
const contact = store.createContact("Known Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-known",
|
||||
username: "known",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-linked",
|
||||
content: "Message from known sender",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-known",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Search should find the message
|
||||
const results = store.searchMessages({ query: "known sender" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("filters messages by canonical contact id", () => {
|
||||
const contact = store.createContact("Filter Sender");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "sender-filter",
|
||||
username: "filter",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "msg-filter",
|
||||
content: "Message for canonical filter",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "sender-filter",
|
||||
channelId: "chat-1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "canonical filter",
|
||||
from: contact.canonicalId,
|
||||
});
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
|
||||
});
|
||||
|
||||
it("searches messages by content", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-search-1",
|
||||
content: "The quick brown fox jumps over the lazy dog",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-search-2",
|
||||
content: "A slow red turtle crawls under the fence",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "quick fox" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.message.id).toBe("msg-search-1");
|
||||
});
|
||||
|
||||
it("filters messages by platform", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-tg",
|
||||
content: "Telegram message about deadlines",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-dc",
|
||||
content: "Discord message about deadlines",
|
||||
platform: "discord" as Platform,
|
||||
senderId: "s2",
|
||||
channelId: "c2",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "deadlines",
|
||||
platforms: ["telegram"],
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.platform).toBe("telegram");
|
||||
});
|
||||
|
||||
it("filters messages by timestamp range", () => {
|
||||
const now = Date.now();
|
||||
store.indexMessage({
|
||||
id: "msg-old",
|
||||
content: "Old message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now - 7 * 24 * 60 * 60 * 1000, // 7 days ago
|
||||
});
|
||||
store.indexMessage({
|
||||
id: "msg-new",
|
||||
content: "New message about projects",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
const results = store.searchMessages({
|
||||
query: "projects",
|
||||
since: now - 24 * 60 * 60 * 1000, // Last 24 hours
|
||||
});
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]?.message.id).toBe("msg-new");
|
||||
});
|
||||
|
||||
it("creates snippet with context", () => {
|
||||
store.indexMessage({
|
||||
id: "msg-snippet",
|
||||
content:
|
||||
"This is a very long message that contains the word deadline somewhere in the middle and continues with more text after that point to test the snippet creation functionality.",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "s1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const results = store.searchMessages({ query: "deadline" });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0]?.snippet).toContain("deadline");
|
||||
expect(results[0]?.snippet.length).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactWithIdentities", () => {
|
||||
it("returns contact with all platform identities", () => {
|
||||
const contact = store.createContact("Full Contact");
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-full",
|
||||
username: "full_tg",
|
||||
phone: "+14155551111",
|
||||
displayName: "TG Full",
|
||||
lastSeenAt: Date.now(),
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact.canonicalId,
|
||||
platform: "whatsapp",
|
||||
platformId: "wa-full",
|
||||
username: null,
|
||||
phone: "+14155551111",
|
||||
displayName: "WA Full",
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
const result = store.getContactWithIdentities(contact.canonicalId);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.displayName).toBe("Full Contact");
|
||||
expect(result?.identities.length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null for non-existent contact", () => {
|
||||
const result = store.getContactWithIdentities("fake-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statistics", () => {
|
||||
it("returns accurate stats", () => {
|
||||
const contact1 = store.createContact("Stats User 1");
|
||||
const contact2 = store.createContact("Stats User 2");
|
||||
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-1",
|
||||
username: "stats1",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact1.canonicalId,
|
||||
platform: "discord",
|
||||
platformId: "dc-stats-1",
|
||||
username: "stats1dc",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
store.addIdentity({
|
||||
contactId: contact2.canonicalId,
|
||||
platform: "telegram",
|
||||
platformId: "tg-stats-2",
|
||||
username: "stats2",
|
||||
phone: null,
|
||||
displayName: null,
|
||||
lastSeenAt: null,
|
||||
});
|
||||
|
||||
store.indexMessage({
|
||||
id: "stats-msg-1",
|
||||
content: "Stats test message",
|
||||
platform: "telegram" as Platform,
|
||||
senderId: "tg-stats-1",
|
||||
channelId: "c1",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const stats = store.getStats();
|
||||
expect(stats.contacts).toBe(2);
|
||||
expect(stats.identities).toBe(3);
|
||||
expect(stats.messages).toBe(1);
|
||||
expect(stats.platforms.telegram).toBe(2);
|
||||
expect(stats.platforms.discord).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,838 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
||||
|
||||
import { requireNodeSqlite } from "../sqlite.js";
|
||||
import { ensureContactStoreSchema } from "./schema.js";
|
||||
import type {
|
||||
Contact,
|
||||
ContactSearchOptions,
|
||||
ContactWithIdentities,
|
||||
IndexedMessage,
|
||||
MessageSearchOptions,
|
||||
MessageSearchResult,
|
||||
Platform,
|
||||
PlatformIdentity,
|
||||
PlatformIdentityInput,
|
||||
} from "./types.js";
|
||||
|
||||
const CONTACTS_DB_FILENAME = "contacts.sqlite";
|
||||
|
||||
/**
|
||||
* ContactStore manages the unified contact graph and message index.
|
||||
*
|
||||
* Key capabilities:
|
||||
* - Store and retrieve canonical contacts
|
||||
* - Link platform-specific identities to canonical contacts
|
||||
* - Index messages for cross-platform search
|
||||
* - Resolve sender identities to canonical contacts
|
||||
*/
|
||||
export class ContactStore {
|
||||
private db: DatabaseSync;
|
||||
private ftsAvailable: boolean;
|
||||
|
||||
// Prepared statements for performance
|
||||
private stmtInsertContact: StatementSync;
|
||||
private stmtUpdateContact: StatementSync;
|
||||
private stmtGetContact: StatementSync;
|
||||
private stmtDeleteContact: StatementSync;
|
||||
private stmtInsertIdentity: StatementSync;
|
||||
private stmtGetIdentitiesByContact: StatementSync;
|
||||
private stmtGetIdentityByPlatformId: StatementSync;
|
||||
private stmtUpdateIdentityLastSeen: StatementSync;
|
||||
private stmtInsertMessage: StatementSync;
|
||||
private stmtInsertMessageFts: StatementSync | null;
|
||||
private stmtUpdateMessageContactBySender: StatementSync;
|
||||
private stmtUpdateMessageFtsContactBySender: StatementSync | null;
|
||||
|
||||
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
|
||||
this.db = db;
|
||||
this.ftsAvailable = ftsAvailable;
|
||||
|
||||
// Prepare statements
|
||||
this.stmtInsertContact = db.prepare(`
|
||||
INSERT INTO contacts (canonical_id, display_name, aliases, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtUpdateContact = db.prepare(`
|
||||
UPDATE contacts SET display_name = ?, aliases = ?, updated_at = ? WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtGetContact = db.prepare(`
|
||||
SELECT canonical_id, display_name, aliases, created_at, updated_at
|
||||
FROM contacts WHERE canonical_id = ?
|
||||
`);
|
||||
this.stmtDeleteContact = db.prepare(`DELETE FROM contacts WHERE canonical_id = ?`);
|
||||
|
||||
this.stmtInsertIdentity = db.prepare(`
|
||||
INSERT OR REPLACE INTO platform_identities
|
||||
(contact_id, platform, platform_id, username, phone, display_name, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
this.stmtGetIdentitiesByContact = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE contact_id = ?
|
||||
`);
|
||||
this.stmtGetIdentityByPlatformId = db.prepare(`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
this.stmtUpdateIdentityLastSeen = db.prepare(`
|
||||
UPDATE platform_identities SET last_seen_at = ? WHERE platform = ? AND platform_id = ?
|
||||
`);
|
||||
|
||||
this.stmtInsertMessage = db.prepare(`
|
||||
INSERT OR REPLACE INTO indexed_messages
|
||||
(id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.stmtInsertMessageFts = ftsAvailable
|
||||
? db.prepare(`
|
||||
INSERT OR REPLACE INTO messages_fts
|
||||
(content, id, contact_id, platform, sender_id, channel_id, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
: null;
|
||||
|
||||
this.stmtUpdateMessageContactBySender = db.prepare(`
|
||||
UPDATE indexed_messages SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`);
|
||||
this.stmtUpdateMessageFtsContactBySender = ftsAvailable
|
||||
? db.prepare(`
|
||||
UPDATE messages_fts SET contact_id = ? WHERE platform = ? AND sender_id = ?
|
||||
`)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open or create a contact store database.
|
||||
*/
|
||||
static open(params: { dbPath?: string; stateDir?: string } = {}): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const resolvedPath =
|
||||
params.dbPath ??
|
||||
(params.stateDir ? path.join(params.stateDir, "contacts", CONTACTS_DB_FILENAME) : undefined);
|
||||
if (!resolvedPath) {
|
||||
throw new Error("ContactStore.open requires dbPath or stateDir");
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(resolvedPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const db = new nodeSqlite.DatabaseSync(resolvedPath);
|
||||
|
||||
// Enable foreign keys
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
// Set up schema
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new in-memory store (for testing).
|
||||
*/
|
||||
static openInMemory(): ContactStore {
|
||||
const nodeSqlite = requireNodeSqlite();
|
||||
const db = new nodeSqlite.DatabaseSync(":memory:");
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
const { ftsAvailable } = ensureContactStoreSchema(db);
|
||||
return new ContactStore(db, ftsAvailable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONTACT OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a canonical ID from a display name.
|
||||
*/
|
||||
private generateCanonicalId(displayName: string): string {
|
||||
const slug = displayName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 30);
|
||||
const suffix = randomUUID().slice(0, 8);
|
||||
return `${slug || "contact"}-${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new canonical contact.
|
||||
*/
|
||||
createContact(displayName: string, aliases: string[] = []): Contact {
|
||||
const now = Date.now();
|
||||
const canonicalId = this.generateCanonicalId(displayName);
|
||||
this.stmtInsertContact.run(canonicalId, displayName, JSON.stringify(aliases), now, now);
|
||||
return {
|
||||
canonicalId,
|
||||
displayName,
|
||||
aliases,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact by canonical ID.
|
||||
*/
|
||||
getContact(canonicalId: string): Contact | null {
|
||||
const row = this.stmtGetContact.get(canonicalId) as
|
||||
| {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
return {
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact's display name and/or aliases.
|
||||
*/
|
||||
updateContact(
|
||||
canonicalId: string,
|
||||
updates: { displayName?: string; aliases?: string[] },
|
||||
): boolean {
|
||||
const existing = this.getContact(canonicalId);
|
||||
if (!existing) return false;
|
||||
|
||||
const displayName = updates.displayName ?? existing.displayName;
|
||||
const aliases = updates.aliases ?? existing.aliases;
|
||||
const now = Date.now();
|
||||
|
||||
this.stmtUpdateContact.run(displayName, JSON.stringify(aliases), now, canonicalId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact and all its platform identities.
|
||||
*/
|
||||
deleteContact(canonicalId: string): boolean {
|
||||
const result = this.stmtDeleteContact.run(canonicalId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all contacts with optional filtering.
|
||||
*/
|
||||
listContacts(options: ContactSearchOptions = {}): Contact[] {
|
||||
let sql = `SELECT canonical_id, display_name, aliases, created_at, updated_at FROM contacts`;
|
||||
const params: (string | number)[] = [];
|
||||
const conditions: string[] = [];
|
||||
|
||||
if (options.query) {
|
||||
conditions.push(`(display_name LIKE ? OR aliases LIKE ?)`);
|
||||
const pattern = `%${options.query}%`;
|
||||
params.push(pattern, pattern);
|
||||
}
|
||||
|
||||
if (options.platform) {
|
||||
conditions.push(
|
||||
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
|
||||
);
|
||||
params.push(this.normalizePlatform(options.platform));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += ` WHERE ${conditions.join(" AND ")}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY updated_at DESC`;
|
||||
|
||||
if (options.limit) {
|
||||
sql += ` LIMIT ?`;
|
||||
params.push(options.limit);
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(sql);
|
||||
const rows = stmt.all(...params) as Array<{
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
aliases: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
canonicalId: row.canonical_id,
|
||||
displayName: row.display_name,
|
||||
aliases: JSON.parse(row.aliases) as string[],
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a contact with all its platform identities.
|
||||
*/
|
||||
getContactWithIdentities(canonicalId: string): ContactWithIdentities | null {
|
||||
const contact = this.getContact(canonicalId);
|
||||
if (!contact) return null;
|
||||
|
||||
const identities = this.getIdentitiesByContact(canonicalId);
|
||||
return { ...contact, identities };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name, alias, or username.
|
||||
*/
|
||||
searchContacts(query: string, limit = 10): ContactWithIdentities[] {
|
||||
const pattern = `%${query}%`;
|
||||
|
||||
// Search in contacts table
|
||||
const contactRows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT c.canonical_id
|
||||
FROM contacts c
|
||||
LEFT JOIN platform_identities pi ON c.canonical_id = pi.contact_id
|
||||
WHERE c.display_name LIKE ?
|
||||
OR c.aliases LIKE ?
|
||||
OR pi.username LIKE ?
|
||||
OR pi.display_name LIKE ?
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(pattern, pattern, pattern, pattern, limit) as Array<{ canonical_id: string }>;
|
||||
|
||||
return contactRows
|
||||
.map((row) => this.getContactWithIdentities(row.canonical_id))
|
||||
.filter((c): c is ContactWithIdentities => c !== null);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PLATFORM IDENTITY OPERATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a platform identity to a contact.
|
||||
*/
|
||||
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
|
||||
const platform = this.normalizePlatform(input.platform);
|
||||
const phone = input.phone ? this.normalizePhone(input.phone) : null;
|
||||
this.stmtInsertIdentity.run(
|
||||
input.contactId,
|
||||
platform,
|
||||
input.platformId,
|
||||
input.username,
|
||||
phone,
|
||||
input.displayName,
|
||||
input.lastSeenAt,
|
||||
);
|
||||
|
||||
// Get the inserted row to return
|
||||
const identity = this.getIdentityByPlatformId(platform, input.platformId);
|
||||
if (!identity) {
|
||||
throw new Error(
|
||||
`Failed to retrieve inserted identity: ${platform}:${input.platformId}`,
|
||||
);
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform identities for a contact.
|
||||
*/
|
||||
getIdentitiesByContact(contactId: string): PlatformIdentity[] {
|
||||
const rows = this.stmtGetIdentitiesByContact.all(contactId) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a platform identity by platform and platform-specific ID.
|
||||
*/
|
||||
getIdentityByPlatformId(platform: string, platformId: string): PlatformIdentity | null {
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
const row = this.stmtGetIdentityByPlatformId.get(normalizedPlatform, platformId) as
|
||||
| {
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find identities by phone number across all platforms.
|
||||
*/
|
||||
findIdentitiesByPhone(phone: string): PlatformIdentity[] {
|
||||
const normalized = this.normalizePhone(phone);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
|
||||
FROM platform_identities WHERE phone = ?
|
||||
`,
|
||||
)
|
||||
.all(normalized) as Array<{
|
||||
id: number;
|
||||
contact_id: string;
|
||||
platform: string;
|
||||
platform_id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
display_name: string | null;
|
||||
last_seen_at: number | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
platformId: row.platform_id,
|
||||
username: row.username,
|
||||
phone: row.phone,
|
||||
displayName: row.display_name,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update last seen timestamp for a platform identity.
|
||||
*/
|
||||
updateIdentityLastSeen(platform: string, platformId: string): void {
|
||||
const normalizedPlatform = this.normalizePlatform(platform);
|
||||
this.stmtUpdateIdentityLastSeen.run(Date.now(), normalizedPlatform, platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a platform sender to a canonical contact ID.
|
||||
* Returns null if the sender is not in the contact graph.
|
||||
*/
|
||||
resolveContact(platform: string, platformId: string): string | null {
|
||||
const identity = this.getIdentityByPlatformId(platform, platformId);
|
||||
return identity?.contactId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign indexed messages for a platform identity to a contact.
|
||||
*/
|
||||
updateMessageContactForIdentity(params: {
|
||||
contactId: string | null;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
}): void {
|
||||
const platform = this.normalizePlatform(params.platform);
|
||||
this.stmtUpdateMessageContactBySender.run(params.contactId, platform, params.senderId);
|
||||
this.stmtUpdateMessageFtsContactBySender?.run(params.contactId, platform, params.senderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a phone number to E.164 format.
|
||||
*/
|
||||
private normalizePhone(phone: string): string {
|
||||
// Strip everything except digits and leading +
|
||||
let normalized = phone.replace(/[^+\d]/g, "");
|
||||
// Ensure it starts with +
|
||||
if (!normalized.startsWith("+") && normalized.length >= 10) {
|
||||
// Assume US if no country code and 10 digits
|
||||
if (normalized.length === 10) {
|
||||
normalized = `+1${normalized}`;
|
||||
} else {
|
||||
normalized = `+${normalized}`;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizePlatform(platform: string): string {
|
||||
return platform.trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MESSAGE INDEXING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Index a message for cross-platform search.
|
||||
*/
|
||||
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
|
||||
const platform = this.normalizePlatform(message.platform);
|
||||
// Try to resolve the sender to a canonical contact
|
||||
const contactId = this.resolveContact(platform, message.senderId);
|
||||
|
||||
this.stmtInsertMessage.run(
|
||||
message.id,
|
||||
message.content,
|
||||
contactId,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
message.embedding ?? null,
|
||||
);
|
||||
|
||||
// Also insert into FTS table
|
||||
if (this.stmtInsertMessageFts && message.content) {
|
||||
this.stmtInsertMessageFts.run(
|
||||
message.content,
|
||||
message.id,
|
||||
contactId,
|
||||
platform,
|
||||
message.senderId,
|
||||
message.channelId,
|
||||
message.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
// Update last seen timestamp for the sender
|
||||
if (contactId) {
|
||||
this.updateIdentityLastSeen(platform, message.senderId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search indexed messages.
|
||||
*/
|
||||
searchMessages(options: MessageSearchOptions): MessageSearchResult[] {
|
||||
const results: MessageSearchResult[] = [];
|
||||
|
||||
if (!options.query) return results;
|
||||
|
||||
const normalizedPlatforms = options.platforms?.map((platform) =>
|
||||
this.normalizePlatform(platform),
|
||||
);
|
||||
|
||||
// Resolve "from" filter to contact IDs
|
||||
let contactIds: string[] | null = null;
|
||||
if (options.from) {
|
||||
const normalized = options.from.trim();
|
||||
const exact = normalized ? this.getContact(normalized) : null;
|
||||
const matches = this.searchContacts(options.from, 10);
|
||||
const ids = new Set<string>(matches.map((m) => m.canonicalId));
|
||||
if (exact) ids.add(exact.canonicalId);
|
||||
if (ids.size === 0) {
|
||||
return results;
|
||||
}
|
||||
contactIds = [...ids];
|
||||
}
|
||||
|
||||
// Build query based on FTS availability
|
||||
const normalizedOptions = {
|
||||
...options,
|
||||
platforms: normalizedPlatforms,
|
||||
};
|
||||
|
||||
if (this.ftsAvailable) {
|
||||
return this.searchMessagesFts(normalizedOptions, contactIds);
|
||||
}
|
||||
return this.searchMessagesLike(normalizedOptions, contactIds);
|
||||
}
|
||||
|
||||
private searchMessagesFts(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT m.id, m.content, m.contact_id, m.platform, m.sender_id, m.channel_id, m.timestamp, m.embedding,
|
||||
bm25(messages_fts) as score
|
||||
FROM messages_fts fts
|
||||
JOIN indexed_messages m ON fts.id = m.id
|
||||
WHERE messages_fts MATCH ?
|
||||
`;
|
||||
const params: (string | number)[] = [options.query];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND m.contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND m.platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND m.channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND m.timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND m.timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY score LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
score: number;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: Math.abs(row.score), // BM25 returns negative scores
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private searchMessagesLike(
|
||||
options: MessageSearchOptions,
|
||||
contactIds: string[] | null,
|
||||
): MessageSearchResult[] {
|
||||
let sql = `
|
||||
SELECT id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding
|
||||
FROM indexed_messages
|
||||
WHERE content LIKE ?
|
||||
`;
|
||||
const params: (string | number)[] = [`%${options.query}%`];
|
||||
|
||||
if (contactIds && contactIds.length > 0) {
|
||||
const placeholders = contactIds.map(() => "?").join(",");
|
||||
sql += ` AND contact_id IN (${placeholders})`;
|
||||
params.push(...contactIds);
|
||||
}
|
||||
|
||||
if (options.platforms && options.platforms.length > 0) {
|
||||
const placeholders = options.platforms.map(() => "?").join(",");
|
||||
sql += ` AND platform IN (${placeholders})`;
|
||||
params.push(...options.platforms);
|
||||
}
|
||||
|
||||
if (options.channelId) {
|
||||
sql += ` AND channel_id = ?`;
|
||||
params.push(options.channelId);
|
||||
}
|
||||
|
||||
if (options.since) {
|
||||
sql += ` AND timestamp >= ?`;
|
||||
params.push(options.since);
|
||||
}
|
||||
|
||||
if (options.until) {
|
||||
sql += ` AND timestamp <= ?`;
|
||||
params.push(options.until);
|
||||
}
|
||||
|
||||
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
||||
params.push(options.limit ?? 50);
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
contact_id: string | null;
|
||||
platform: string;
|
||||
sender_id: string;
|
||||
channel_id: string;
|
||||
timestamp: number;
|
||||
embedding: string | null;
|
||||
}>;
|
||||
|
||||
return rows.map((row) => {
|
||||
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
|
||||
return {
|
||||
message: {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
contactId: row.contact_id,
|
||||
platform: row.platform as Platform,
|
||||
senderId: row.sender_id,
|
||||
channelId: row.channel_id,
|
||||
timestamp: row.timestamp,
|
||||
embedding: row.embedding,
|
||||
},
|
||||
contact,
|
||||
score: 1.0, // Simple LIKE doesn't provide scoring
|
||||
snippet: this.createSnippet(row.content, options.query),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snippet with the query highlighted.
|
||||
*/
|
||||
private createSnippet(content: string, query: string, maxLength = 200): string {
|
||||
const lowerContent = content.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerContent.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) {
|
||||
return content.slice(0, maxLength) + (content.length > maxLength ? "..." : "");
|
||||
}
|
||||
|
||||
// Center the snippet around the match
|
||||
const contextBefore = 50;
|
||||
const contextAfter = 100;
|
||||
let start = Math.max(0, index - contextBefore);
|
||||
let end = Math.min(content.length, index + query.length + contextAfter);
|
||||
|
||||
// Adjust to word boundaries if possible
|
||||
if (start > 0) {
|
||||
const spaceIndex = content.lastIndexOf(" ", start + 10);
|
||||
if (spaceIndex > start - 20) start = spaceIndex + 1;
|
||||
}
|
||||
if (end < content.length) {
|
||||
const spaceIndex = content.indexOf(" ", end - 10);
|
||||
if (spaceIndex !== -1 && spaceIndex < end + 20) end = spaceIndex;
|
||||
}
|
||||
|
||||
let snippet = content.slice(start, end);
|
||||
if (start > 0) snippet = "..." + snippet;
|
||||
if (end < content.length) snippet = snippet + "...";
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// STATISTICS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get statistics about the contact store.
|
||||
*/
|
||||
getStats(): {
|
||||
contacts: number;
|
||||
identities: number;
|
||||
messages: number;
|
||||
platforms: Record<string, number>;
|
||||
} {
|
||||
const contactCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM contacts`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const identityCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM platform_identities`).get() as {
|
||||
count: number;
|
||||
}
|
||||
).count;
|
||||
|
||||
const messageCount = (
|
||||
this.db.prepare(`SELECT COUNT(*) as count FROM indexed_messages`).get() as { count: number }
|
||||
).count;
|
||||
|
||||
const platformRows = this.db
|
||||
.prepare(`SELECT platform, COUNT(*) as count FROM platform_identities GROUP BY platform`)
|
||||
.all() as Array<{ platform: string; count: number }>;
|
||||
|
||||
const platforms: Record<string, number> = {};
|
||||
for (const row of platformRows) {
|
||||
platforms[row.platform] = row.count;
|
||||
}
|
||||
|
||||
return {
|
||||
contacts: contactCount,
|
||||
identities: identityCount,
|
||||
messages: messageCount,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _store: ContactStore | null = null;
|
||||
let _storeConfig: { dbPath?: string; stateDir?: string } = {};
|
||||
|
||||
export function configureContactStore(params: { dbPath?: string; stateDir?: string }): void {
|
||||
_storeConfig = params;
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global contact store instance.
|
||||
*/
|
||||
export function getContactStore(): ContactStore {
|
||||
if (!_store) {
|
||||
_store = ContactStore.open(_storeConfig);
|
||||
}
|
||||
return _store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the global contact store instance.
|
||||
*/
|
||||
export function closeContactStore(): void {
|
||||
if (_store) {
|
||||
_store.close();
|
||||
_store = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ChannelId } from "clawdbot/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Types for the unified contact graph.
|
||||
*
|
||||
* The contact graph allows cross-platform identity resolution:
|
||||
* - Multiple platform identities can be linked to a single canonical contact
|
||||
* - Enables unified message search across all messaging channels
|
||||
*/
|
||||
|
||||
/**
|
||||
* A canonical contact in the unified contact graph.
|
||||
* Represents a single person who may have multiple platform identities.
|
||||
*/
|
||||
export type Contact = {
|
||||
/** Unique canonical identifier (e.g., "sarah-jones-abc123") */
|
||||
canonicalId: string;
|
||||
/** Primary display name for this contact */
|
||||
displayName: string;
|
||||
/** Alternative names/aliases for this contact */
|
||||
aliases: string[];
|
||||
/** When this contact was first created */
|
||||
createdAt: number;
|
||||
/** When this contact was last updated */
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supported messaging platforms.
|
||||
*/
|
||||
export type Platform = ChannelId;
|
||||
|
||||
/**
|
||||
* A platform-specific identity linked to a canonical contact.
|
||||
* Each person may have one or more of these across different platforms.
|
||||
*/
|
||||
export type PlatformIdentity = {
|
||||
/** Database row ID */
|
||||
id: number;
|
||||
/** Reference to the canonical contact */
|
||||
contactId: string;
|
||||
/** Which platform this identity belongs to */
|
||||
platform: Platform;
|
||||
/** Platform-specific user identifier (JID, user ID, etc.) */
|
||||
platformId: string;
|
||||
/** Platform-specific username (@handle) if available */
|
||||
username: string | null;
|
||||
/** E.164 phone number if available */
|
||||
phone: string | null;
|
||||
/** Platform-specific display name */
|
||||
displayName: string | null;
|
||||
/** When this identity was last seen in a message */
|
||||
lastSeenAt: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Input for creating a new platform identity.
|
||||
*/
|
||||
export type PlatformIdentityInput = Omit<PlatformIdentity, "id">;
|
||||
|
||||
/**
|
||||
* Result of a contact search/lookup.
|
||||
*/
|
||||
export type ContactWithIdentities = Contact & {
|
||||
/** All platform identities associated with this contact */
|
||||
identities: PlatformIdentity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-link match confidence levels.
|
||||
*/
|
||||
export type LinkConfidence = "high" | "medium" | "low";
|
||||
|
||||
/**
|
||||
* A suggested link between platform identities.
|
||||
*/
|
||||
export type LinkSuggestion = {
|
||||
/** The source identity that was analyzed */
|
||||
sourceIdentity: PlatformIdentity;
|
||||
/** The target identity to potentially link with */
|
||||
targetIdentity: PlatformIdentity;
|
||||
/** Why this link is suggested */
|
||||
reason: "phone_match" | "email_match" | "name_similarity";
|
||||
/** How confident we are in this match */
|
||||
confidence: LinkConfidence;
|
||||
/** Score for ranking (0-1) */
|
||||
score: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Contact import result from a platform.
|
||||
*/
|
||||
export type ImportResult = {
|
||||
platform: Platform;
|
||||
imported: number;
|
||||
linked: number;
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for contact search.
|
||||
*/
|
||||
export type ContactSearchOptions = {
|
||||
/** Search query (matches name, aliases, username) */
|
||||
query?: string;
|
||||
/** Filter by platform */
|
||||
platform?: Platform;
|
||||
/** Maximum results to return */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A message indexed for cross-platform search.
|
||||
*/
|
||||
export type IndexedMessage = {
|
||||
/** Unique message ID */
|
||||
id: string;
|
||||
/** Message content (may be empty for media) */
|
||||
content: string;
|
||||
/** Reference to canonical contact ID of sender */
|
||||
contactId: string | null;
|
||||
/** Platform this message came from */
|
||||
platform: Platform;
|
||||
/** Platform-specific sender ID */
|
||||
senderId: string;
|
||||
/** Platform-specific channel/chat ID */
|
||||
channelId: string;
|
||||
/** When the message was sent */
|
||||
timestamp: number;
|
||||
/** Optional: pre-computed embedding for semantic search */
|
||||
embedding: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for message search.
|
||||
*/
|
||||
export type MessageSearchOptions = {
|
||||
/** Text query to search for */
|
||||
query: string;
|
||||
/** Filter by sender (canonical contact ID or platform identity) */
|
||||
from?: string;
|
||||
/** Filter by platform */
|
||||
platforms?: Platform[];
|
||||
/** Filter by channel/chat ID */
|
||||
channelId?: string;
|
||||
/** Filter messages after this timestamp */
|
||||
since?: number;
|
||||
/** Filter messages before this timestamp */
|
||||
until?: number;
|
||||
/** Maximum results */
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message search result.
|
||||
*/
|
||||
export type MessageSearchResult = {
|
||||
message: IndexedMessage;
|
||||
/** The contact who sent this message (if resolved) */
|
||||
contact: Contact | null;
|
||||
/** Search relevance score */
|
||||
score: number;
|
||||
/** Snippet with highlighted match */
|
||||
snippet: string;
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { importContactFromMessage, getContactStore } from "../contacts/index.js";
|
||||
import type { Platform } from "../contacts/types.js";
|
||||
|
||||
function normalizePlatform(value: string): Platform {
|
||||
return value.trim().toLowerCase() as Platform;
|
||||
}
|
||||
|
||||
function resolveMessageId(params: {
|
||||
messageId?: string;
|
||||
platform: string;
|
||||
senderId: string;
|
||||
timestamp?: number;
|
||||
content: string;
|
||||
}): string {
|
||||
if (params.messageId) {
|
||||
return `${params.platform}:${params.messageId}`;
|
||||
}
|
||||
if (!params.timestamp) return randomUUID();
|
||||
const hash = createHash("sha1");
|
||||
hash.update(params.platform);
|
||||
hash.update("|");
|
||||
hash.update(params.senderId);
|
||||
hash.update("|");
|
||||
hash.update(String(params.timestamp));
|
||||
hash.update("|");
|
||||
hash.update(params.content);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
export function indexInboundMessage(params: {
|
||||
event: {
|
||||
from: string;
|
||||
content: string;
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
ctx: {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
conversationId?: string;
|
||||
};
|
||||
logger?: { warn?: (message: string) => void };
|
||||
}): void {
|
||||
const { event, ctx, logger } = params;
|
||||
const channelId = (ctx.channelId ?? "").trim();
|
||||
if (!channelId) return;
|
||||
|
||||
const metadata = event.metadata ?? {};
|
||||
const meta = metadata as {
|
||||
senderId?: string;
|
||||
messageId?: string;
|
||||
senderUsername?: string;
|
||||
senderE164?: string;
|
||||
senderName?: string;
|
||||
};
|
||||
const senderId = String(meta.senderId ?? event.from ?? "").trim();
|
||||
if (!senderId) return;
|
||||
|
||||
const content = typeof event.content === "string" ? event.content.trim() : "";
|
||||
const platform = normalizePlatform(channelId);
|
||||
const timestamp =
|
||||
typeof event.timestamp === "number" && Number.isFinite(event.timestamp)
|
||||
? event.timestamp
|
||||
: Date.now();
|
||||
const metadataMessageId = meta.messageId;
|
||||
const messageId = resolveMessageId({
|
||||
messageId: typeof metadataMessageId === "string" ? metadataMessageId : undefined,
|
||||
platform,
|
||||
senderId,
|
||||
timestamp,
|
||||
content,
|
||||
});
|
||||
const conversationId = (ctx.conversationId ?? "").trim() || senderId;
|
||||
|
||||
try {
|
||||
const store = getContactStore();
|
||||
importContactFromMessage(store, {
|
||||
platform,
|
||||
platformId: senderId,
|
||||
username: typeof meta.senderUsername === "string" ? meta.senderUsername : null,
|
||||
phone: typeof meta.senderE164 === "string" ? meta.senderE164 : null,
|
||||
displayName: typeof meta.senderName === "string" ? meta.senderName : null,
|
||||
});
|
||||
|
||||
if (!content) return;
|
||||
|
||||
store.indexMessage({
|
||||
id: messageId,
|
||||
content,
|
||||
contactId: null,
|
||||
platform,
|
||||
senderId,
|
||||
channelId: conversationId,
|
||||
timestamp,
|
||||
embedding: null,
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.(
|
||||
`[contacts-search] failed indexing message: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function requireNodeSqlite(): typeof import("node:sqlite") {
|
||||
const onWarning = (warning: Error & { name?: string; message?: string }) => {
|
||||
if (
|
||||
warning.name === "ExperimentalWarning" &&
|
||||
warning.message?.includes("SQLite is an experimental feature")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
|
||||
};
|
||||
|
||||
process.on("warning", onWarning);
|
||||
try {
|
||||
return require("node:sqlite") as typeof import("node:sqlite");
|
||||
} finally {
|
||||
process.off("warning", onWarning);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@@ -144,6 +145,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
@@ -106,6 +107,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# LLM Task (plugin)
|
||||
|
||||
Adds an **optional** agent tool `llm-task` for running **JSON-only** LLM tasks
|
||||
(drafting, summarizing, classifying) with optional JSON Schema validation.
|
||||
|
||||
Designed to be called from workflow engines (for example, Lobster via
|
||||
`clawd.invoke --each`) without adding new Clawdbot code per workflow.
|
||||
|
||||
## Enable
|
||||
|
||||
1) Enable the plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"llm-task": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2) Allowlist the tool (it is registered with `optional: true`):
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"list": [
|
||||
{
|
||||
"id": "main",
|
||||
"tools": { "allow": ["llm-task"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Config (optional)
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"llm-task": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"defaultProvider": "openai-codex",
|
||||
"defaultModel": "gpt-5.2",
|
||||
"defaultAuthProfileId": "main",
|
||||
"allowedModels": ["openai-codex/gpt-5.2"],
|
||||
"maxTokens": 800,
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`allowedModels` is an allowlist of `provider/model` strings. If set, any request
|
||||
outside the list is rejected.
|
||||
|
||||
## Tool API
|
||||
|
||||
### Parameters
|
||||
|
||||
- `prompt` (string, required)
|
||||
- `input` (any, optional)
|
||||
- `schema` (object, optional JSON Schema)
|
||||
- `provider` (string, optional)
|
||||
- `model` (string, optional)
|
||||
- `authProfileId` (string, optional)
|
||||
- `temperature` (number, optional)
|
||||
- `maxTokens` (number, optional)
|
||||
- `timeoutMs` (number, optional)
|
||||
|
||||
### Output
|
||||
|
||||
Returns `details.json` containing the parsed JSON (and validates against
|
||||
`schema` when provided).
|
||||
|
||||
## Notes
|
||||
|
||||
- The tool is **JSON-only** and instructs the model to output only JSON
|
||||
(no code fences, no commentary).
|
||||
- No tools are exposed to the model for this run.
|
||||
- Side effects should be handled outside this tool (for example, approvals in
|
||||
Lobster) before calling tools that send messages/emails.
|
||||
|
||||
## Bundled extension note
|
||||
|
||||
This extension depends on Clawdbot internal modules (the embedded agent runner).
|
||||
It is intended to ship as a **bundled** Clawdbot extension (like `lobster`) and
|
||||
be enabled via `plugins.entries` + tool allowlists.
|
||||
|
||||
It is **not** currently designed to be copied into
|
||||
`~/.clawdbot/extensions` as a standalone plugin directory.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "llm-task",
|
||||
"name": "LLM Task",
|
||||
"description": "Generic JSON-only LLM tool for structured tasks callable from workflows.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultProvider": { "type": "string" },
|
||||
"defaultModel": { "type": "string" },
|
||||
"defaultAuthProfileId": { "type": "string" },
|
||||
"allowedModels": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Allowlist of provider/model keys like openai-codex/gpt-5.2."
|
||||
},
|
||||
"maxTokens": { "type": "number" },
|
||||
"timeoutMs": { "type": "number" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { createLlmTaskTool } from "./src/llm-task-tool.js";
|
||||
|
||||
export default function register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(createLlmTaskTool(api), { optional: true });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@clawdbot/llm-task",
|
||||
"version": "2026.1.23",
|
||||
"type": "module",
|
||||
"description": "Clawdbot JSON-only LLM task plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../../../src/agents/pi-embedded-runner.js", () => {
|
||||
return {
|
||||
runEmbeddedPiAgent: vi.fn(async () => ({
|
||||
meta: { startedAt: Date.now() },
|
||||
payloads: [{ text: "{}" }],
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js";
|
||||
import { createLlmTaskTool } from "./llm-task-tool.js";
|
||||
|
||||
function fakeApi(overrides: any = {}) {
|
||||
return {
|
||||
id: "llm-task",
|
||||
name: "llm-task",
|
||||
source: "test",
|
||||
config: { agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } } },
|
||||
pluginConfig: {},
|
||||
runtime: { version: "test" },
|
||||
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
registerTool() {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("llm-task tool (json-only)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns parsed json", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
const res = await tool.execute("id", { prompt: "return foo" });
|
||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("strips fenced json", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: "```json\n{\"ok\":true}\n```" }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
const res = await tool.execute("id", { prompt: "return ok" });
|
||||
expect((res as any).details.json).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("validates schema", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { foo: { type: "string" } },
|
||||
required: ["foo"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const res = await tool.execute("id", { prompt: "return foo", schema });
|
||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("throws on invalid json", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({ meta: {}, payloads: [{ text: "not-json" }] });
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
|
||||
});
|
||||
|
||||
it("throws on schema mismatch", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
|
||||
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
|
||||
});
|
||||
|
||||
it("passes provider/model overrides to embedded runner", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
expect(call.provider).toBe("anthropic");
|
||||
expect(call.model).toBe("claude-4-sonnet");
|
||||
});
|
||||
|
||||
it("enforces allowedModels", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any);
|
||||
await expect(tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" })).rejects.toThrow(
|
||||
/not allowed/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("disables tools for embedded run", async () => {
|
||||
(runEmbeddedPiAgent as any).mockResolvedValueOnce({
|
||||
meta: {},
|
||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||
});
|
||||
const tool = createLlmTaskTool(fakeApi() as any);
|
||||
await tool.execute("id", { prompt: "x" });
|
||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||
expect(call.disableTools).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import Ajv from "ajv";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
// NOTE: This extension is intended to be bundled with Clawdbot.
|
||||
// When running from source (tests/dev), Clawdbot internals live under src/.
|
||||
// When running from a built install, internals live under dist/ (no src/ tree).
|
||||
// So we resolve internal imports dynamically with src-first, dist-fallback.
|
||||
|
||||
import type { ClawdbotPluginApi } from "../../../src/plugins/types.js";
|
||||
|
||||
type RunEmbeddedPiAgentFn = (params: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
||||
// Source checkout (tests/dev)
|
||||
try {
|
||||
const mod = await import("../../../src/agents/pi-embedded-runner.js");
|
||||
if (typeof (mod as any).runEmbeddedPiAgent === "function") return (mod as any).runEmbeddedPiAgent;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Bundled install (built)
|
||||
const mod = await import("../../../agents/pi-embedded-runner.js");
|
||||
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
|
||||
throw new Error("Internal error: runEmbeddedPiAgent not available");
|
||||
}
|
||||
return (mod as any).runEmbeddedPiAgent;
|
||||
}
|
||||
|
||||
function stripCodeFences(s: string): string {
|
||||
const trimmed = s.trim();
|
||||
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (m) return (m[1] ?? "").trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string {
|
||||
const texts = (payloads ?? [])
|
||||
.filter((p) => !p.isError && typeof p.text === "string")
|
||||
.map((p) => p.text ?? "");
|
||||
return texts.join("\n").trim();
|
||||
}
|
||||
|
||||
function toModelKey(provider?: string, model?: string): string | undefined {
|
||||
const p = provider?.trim();
|
||||
const m = model?.trim();
|
||||
if (!p || !m) return undefined;
|
||||
return `${p}/${m}`;
|
||||
}
|
||||
|
||||
type PluginCfg = {
|
||||
defaultProvider?: string;
|
||||
defaultModel?: string;
|
||||
defaultAuthProfileId?: string;
|
||||
allowedModels?: string[];
|
||||
maxTokens?: number;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export function createLlmTaskTool(api: ClawdbotPluginApi) {
|
||||
return {
|
||||
name: "llm-task",
|
||||
description:
|
||||
"Run a generic JSON-only LLM task and return schema-validated JSON. Designed for orchestration from Lobster workflows via clawd.invoke.",
|
||||
parameters: Type.Object({
|
||||
prompt: Type.String({ description: "Task instruction for the LLM." }),
|
||||
input: Type.Optional(Type.Unknown({ description: "Optional input payload for the task." })),
|
||||
schema: Type.Optional(Type.Unknown({ description: "Optional JSON Schema to validate the returned JSON." })),
|
||||
provider: Type.Optional(Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." })),
|
||||
model: Type.Optional(Type.String({ description: "Model id override." })),
|
||||
authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })),
|
||||
temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })),
|
||||
maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })),
|
||||
timeoutMs: Type.Optional(Type.Number({ description: "Timeout for the LLM run." })),
|
||||
}),
|
||||
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const prompt = String(params.prompt ?? "");
|
||||
if (!prompt.trim()) throw new Error("prompt required");
|
||||
|
||||
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
|
||||
|
||||
const primary = api.config?.agents?.defaults?.model?.primary;
|
||||
const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined;
|
||||
const primaryModel = typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined;
|
||||
|
||||
const provider =
|
||||
(typeof params.provider === "string" && params.provider.trim()) ||
|
||||
(typeof pluginCfg.defaultProvider === "string" && pluginCfg.defaultProvider.trim()) ||
|
||||
primaryProvider ||
|
||||
undefined;
|
||||
|
||||
const model =
|
||||
(typeof params.model === "string" && params.model.trim()) ||
|
||||
(typeof pluginCfg.defaultModel === "string" && pluginCfg.defaultModel.trim()) ||
|
||||
primaryModel ||
|
||||
undefined;
|
||||
|
||||
const authProfileId =
|
||||
(typeof (params as any).authProfileId === "string" && (params as any).authProfileId.trim()) ||
|
||||
(typeof pluginCfg.defaultAuthProfileId === "string" && pluginCfg.defaultAuthProfileId.trim()) ||
|
||||
undefined;
|
||||
|
||||
const modelKey = toModelKey(provider, model);
|
||||
if (!provider || !model || !modelKey) {
|
||||
throw new Error(
|
||||
`provider/model could not be resolved (provider=${String(provider ?? "")}, model=${String(model ?? "")})`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = Array.isArray(pluginCfg.allowedModels) ? pluginCfg.allowedModels : undefined;
|
||||
if (allowed && allowed.length > 0 && !allowed.includes(modelKey)) {
|
||||
throw new Error(
|
||||
`Model not allowed by llm-task plugin config: ${modelKey}. Allowed models: ${allowed.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const timeoutMs =
|
||||
(typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : undefined) ||
|
||||
(typeof pluginCfg.timeoutMs === "number" && pluginCfg.timeoutMs > 0 ? pluginCfg.timeoutMs : undefined) ||
|
||||
30_000;
|
||||
|
||||
const streamParams = {
|
||||
temperature: typeof params.temperature === "number" ? params.temperature : undefined,
|
||||
maxTokens:
|
||||
typeof params.maxTokens === "number"
|
||||
? params.maxTokens
|
||||
: typeof pluginCfg.maxTokens === "number"
|
||||
? pluginCfg.maxTokens
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const input = (params as any).input as unknown;
|
||||
let inputJson: string;
|
||||
try {
|
||||
inputJson = JSON.stringify(input ?? null, null, 2);
|
||||
} catch {
|
||||
throw new Error("input must be JSON-serializable");
|
||||
}
|
||||
|
||||
const system = [
|
||||
"You are a JSON-only function.",
|
||||
"Return ONLY a valid JSON value.",
|
||||
"Do not wrap in markdown fences.",
|
||||
"Do not include commentary.",
|
||||
"Do not call tools.",
|
||||
].join(" ");
|
||||
|
||||
const fullPrompt = `${system}\n\nTASK:\n${prompt}\n\nINPUT_JSON:\n${inputJson}\n`;
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
try {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-llm-task-"));
|
||||
const sessionId = `llm-task-${Date.now()}`;
|
||||
const sessionFile = path.join(tmpDir, "session.json");
|
||||
|
||||
const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent();
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(),
|
||||
config: api.config,
|
||||
prompt: fullPrompt,
|
||||
timeoutMs,
|
||||
runId: `llm-task-${Date.now()}`,
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
authProfileIdSource: authProfileId ? "user" : "auto",
|
||||
streamParams,
|
||||
disableTools: true,
|
||||
});
|
||||
|
||||
const text = collectText((result as any).payloads);
|
||||
if (!text) throw new Error("LLM returned empty output");
|
||||
|
||||
const raw = stripCodeFences(text);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("LLM returned invalid JSON");
|
||||
}
|
||||
|
||||
const schema = (params as any).schema as unknown;
|
||||
if (schema && typeof schema === "object" && !Array.isArray(schema)) {
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
const validate = ajv.compile(schema as any);
|
||||
const ok = validate(parsed);
|
||||
if (!ok) {
|
||||
const msg =
|
||||
validate.errors?.map((e) => `${e.instancePath || "<root>"} ${e.message || "invalid"}`).join("; ") ??
|
||||
"invalid";
|
||||
throw new Error(`LLM JSON did not match schema: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
|
||||
details: { json: parsed, provider, model },
|
||||
};
|
||||
} finally {
|
||||
if (tmpDir) {
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -25,9 +25,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
||||
"clawdbot": "workspace:*",
|
||||
"markdown-it": "14.1.0",
|
||||
"matrix-bot-sdk": "0.8.0",
|
||||
"music-metadata": "^11.10.6"
|
||||
"music-metadata": "^11.10.6",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
@@ -167,6 +167,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -26,6 +26,7 @@ const matrixRoomSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
@@ -32,3 +32,30 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
return resolved?.tools;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type MatrixRoomConfig = {
|
||||
allow?: boolean;
|
||||
/** Require mentioning the bot to trigger replies. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If true, reply without mention requirements. */
|
||||
autoReply?: boolean;
|
||||
/** Optional allowlist for room senders (user IDs or localparts). */
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import {
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
normalizeMSTeamsUserInput,
|
||||
@@ -77,6 +78,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
@@ -86,6 +88,50 @@ export function resolveMSTeamsRouteConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMSTeamsGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.msteams;
|
||||
if (!cfg) return undefined;
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const groupSpace = params.groupSpace?.trim();
|
||||
|
||||
const resolved = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: groupSpace,
|
||||
teamName: groupSpace,
|
||||
conversationId: groupId,
|
||||
channelName: groupChannel,
|
||||
});
|
||||
|
||||
if (resolved.channelConfig) {
|
||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
||||
}
|
||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
||||
|
||||
if (!groupId) return undefined;
|
||||
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
groupId,
|
||||
groupChannel,
|
||||
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
|
||||
);
|
||||
for (const teamConfig of Object.values(cfg.teams ?? {})) {
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: teamConfig?.channels ?? {},
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
if (match.entry) {
|
||||
return match.entry.tools ?? teamConfig?.tools;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type MSTeamsReplyPolicy = {
|
||||
requireMention: boolean;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
|
||||
@@ -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/.default");
|
||||
await tokenProvider.getAccessToken("https://api.botframework.com");
|
||||
let graph:
|
||||
| {
|
||||
ok: boolean;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
||||
|
||||
const meta = {
|
||||
id: "nextcloud-talk",
|
||||
@@ -159,6 +160,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
|
||||
return true;
|
||||
},
|
||||
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
@@ -11,6 +12,7 @@ import { z } from "zod";
|
||||
export const NextcloudTalkRoomSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
@@ -86,6 +86,21 @@ export function resolveNextcloudTalkRoomMatch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } } };
|
||||
const roomToken = params.groupId?.trim();
|
||||
if (!roomToken) return undefined;
|
||||
const roomName = params.groupChannel?.trim() || undefined;
|
||||
const match = resolveNextcloudTalkRoomMatch({
|
||||
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkRequireMention(params: {
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this room. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
@@ -161,6 +162,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
@@ -154,6 +155,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Tlon (Clawdbot plugin)
|
||||
|
||||
Tlon/Urbit channel plugin for Clawdbot. Supports DMs, group mentions, and thread replies.
|
||||
|
||||
Docs: https://docs.clawd.bot/channels/tlon
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "tlon",
|
||||
"channels": [
|
||||
"tlon"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { tlonPlugin } from "./src/channel.js";
|
||||
import { setTlonRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "tlon",
|
||||
name: "Tlon",
|
||||
description: "Tlon/Urbit channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setTlonRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: tlonPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@clawdbot/tlon",
|
||||
"version": "2026.1.22",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Tlon/Urbit channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"id": "tlon",
|
||||
"label": "Tlon",
|
||||
"selectionLabel": "Tlon (Urbit)",
|
||||
"docsPath": "/channels/tlon",
|
||||
"docsLabel": "tlon",
|
||||
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
|
||||
"order": 90,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@clawdbot/tlon",
|
||||
"localPath": "extensions/tlon",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@urbit/aura": "^2.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelSetupInput,
|
||||
ClawdbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
|
||||
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
|
||||
import { ensureUrbitConnectPatched, Urbit } from "./urbit/http-api.js";
|
||||
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
|
||||
import { monitorTlonProvider } from "./monitor/index.js";
|
||||
import { tlonChannelConfigSchema } from "./config-schema.js";
|
||||
import { tlonOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
const TLON_CHANNEL_ID = "tlon" as const;
|
||||
|
||||
type TlonSetupInput = ChannelSetupInput & {
|
||||
ship?: string;
|
||||
url?: string;
|
||||
code?: string;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
};
|
||||
|
||||
function applyTlonSetupConfig(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
input: TlonSetupInput;
|
||||
}): ClawdbotConfig {
|
||||
const { cfg, accountId, input } = params;
|
||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "tlon",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const base = namedConfig.channels?.tlon ?? {};
|
||||
|
||||
const payload = {
|
||||
...(input.ship ? { ship: input.ship } : {}),
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
...(input.code ? { code: input.code } : {}),
|
||||
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
|
||||
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (useDefault) {
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: true,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...namedConfig,
|
||||
channels: {
|
||||
...namedConfig.channels,
|
||||
tlon: {
|
||||
...base,
|
||||
enabled: base.enabled ?? true,
|
||||
accounts: {
|
||||
...(base as { accounts?: Record<string, unknown> }).accounts,
|
||||
[accountId]: {
|
||||
...((base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
||||
accountId
|
||||
] ?? {}),
|
||||
enabled: true,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tlonOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
textChunkLimit: 10000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const parsed = parseTlonTarget(to ?? "");
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
|
||||
};
|
||||
}
|
||||
if (parsed.kind === "dm") {
|
||||
return { ok: true, to: parsed.ship };
|
||||
}
|
||||
return { ok: true, to: parsed.nest };
|
||||
},
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
ensureUrbitConnectPatched();
|
||||
const api = await Urbit.authenticate({
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
url: account.url,
|
||||
code: account.code,
|
||||
verbose: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
if (parsed.kind === "dm") {
|
||||
return await sendDm({
|
||||
api,
|
||||
fromShip,
|
||||
toShip: parsed.ship,
|
||||
text,
|
||||
});
|
||||
}
|
||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
return await sendGroupMessage({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text,
|
||||
replyToId: replyId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
const mergedText = buildMediaText(text, mediaUrl);
|
||||
return await tlonOutbound.sendText({
|
||||
cfg,
|
||||
to,
|
||||
text: mergedText,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const tlonPlugin: ChannelPlugin = {
|
||||
id: TLON_CHANNEL_ID,
|
||||
meta: {
|
||||
id: TLON_CHANNEL_ID,
|
||||
label: "Tlon",
|
||||
selectionLabel: "Tlon (Urbit)",
|
||||
docsPath: "/channels/tlon",
|
||||
docsLabel: "tlon",
|
||||
blurb: "Decentralized messaging on Urbit",
|
||||
aliases: ["urbit"],
|
||||
order: 90,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
media: false,
|
||||
reply: true,
|
||||
threads: true,
|
||||
},
|
||||
onboarding: tlonOnboardingAdapter,
|
||||
reload: { configPrefixes: ["channels.tlon"] },
|
||||
configSchema: tlonChannelConfigSchema,
|
||||
config: {
|
||||
listAccountIds: (cfg) => listTlonAccountIds(cfg as ClawdbotConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined),
|
||||
defaultAccountId: () => "default",
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
if (useDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
accounts: {
|
||||
...(cfg.channels?.tlon?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.tlon?.accounts?.[accountId] ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
if (useDefault) {
|
||||
const { ship, code, url, name, ...rest } = cfg.channels?.tlon ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: rest,
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
const { [accountId]: removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon ?? {}),
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
},
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
}),
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
channelKey: "tlon",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ cfg, accountId, input }) => {
|
||||
const setupInput = input as TlonSetupInput;
|
||||
const resolved = resolveTlonAccount(cfg as ClawdbotConfig, accountId ?? undefined);
|
||||
const ship = setupInput.ship?.trim() || resolved.ship;
|
||||
const url = setupInput.url?.trim() || resolved.url;
|
||||
const code = setupInput.code?.trim() || resolved.code;
|
||||
if (!ship) return "Tlon requires --ship.";
|
||||
if (!url) return "Tlon requires --url.";
|
||||
if (!code) return "Tlon requires --code.";
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) =>
|
||||
applyTlonSetupConfig({
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId,
|
||||
input: input as TlonSetupInput,
|
||||
}),
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
const parsed = parseTlonTarget(target);
|
||||
if (!parsed) return target.trim();
|
||||
if (parsed.kind === "dm") return parsed.ship;
|
||||
return parsed.nest;
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (target) => Boolean(parseTlonTarget(target)),
|
||||
hint: formatTargetHint(),
|
||||
},
|
||||
},
|
||||
outbound: tlonOutbound,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: "default",
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts) => {
|
||||
return accounts.flatMap((account) => {
|
||||
if (!account.configured) {
|
||||
return [
|
||||
{
|
||||
channel: TLON_CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
kind: "config",
|
||||
message: "Account not configured (missing ship, code, or url)",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
ship: snapshot.ship ?? null,
|
||||
url: snapshot.url ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => {
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
return { ok: false, error: "Not configured" };
|
||||
}
|
||||
try {
|
||||
ensureUrbitConnectPatched();
|
||||
const api = await Urbit.authenticate({
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
url: account.url,
|
||||
code: account.code,
|
||||
verbose: false,
|
||||
});
|
||||
try {
|
||||
await api.getOurName();
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await api.delete();
|
||||
}
|
||||
} catch (error: any) {
|
||||
return { ok: false, error: error?.message ?? String(error) };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
|
||||
return monitorTlonProvider({
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
const ShipSchema = z.string().min(1);
|
||||
const ChannelNestSchema = z.string().min(1);
|
||||
|
||||
export const TlonChannelRuleSchema = z.object({
|
||||
mode: z.enum(["restricted", "open"]).optional(),
|
||||
allowedShips: z.array(ShipSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAuthorizationSchema = z.object({
|
||||
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const TlonConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
ship: ShipSchema.optional(),
|
||||
url: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
groupChannels: z.array(ChannelNestSchema).optional(),
|
||||
dmAllowlist: z.array(ShipSchema).optional(),
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
authorization: TlonAuthorizationSchema.optional(),
|
||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||
accounts: z.record(TlonAccountSchema).optional(),
|
||||
});
|
||||
|
||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { formatChangesDate } from "./utils.js";
|
||||
|
||||
export async function fetchGroupChanges(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
daysAgo = 5,
|
||||
) {
|
||||
try {
|
||||
const changeDate = formatChangesDate(daysAgo);
|
||||
runtime.log?.(`[tlon] Fetching group changes since ${daysAgo} days ago (${changeDate})...`);
|
||||
const changes = await api.scry(`/groups-ui/v5/changes/${changeDate}.json`);
|
||||
if (changes) {
|
||||
runtime.log?.("[tlon] Successfully fetched changes data");
|
||||
return changes;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
runtime.log?.(`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllChannels(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
|
||||
const changes = await fetchGroupChanges(api, runtime, 5);
|
||||
|
||||
let initData: any;
|
||||
if (changes) {
|
||||
runtime.log?.("[tlon] Changes data received, using full init for channel extraction");
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
} else {
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
}
|
||||
|
||||
const channels: string[] = [];
|
||||
if (initData && initData.groups) {
|
||||
for (const groupData of Object.values(initData.groups as Record<string, any>)) {
|
||||
if (groupData && typeof groupData === "object" && groupData.channels) {
|
||||
for (const channelNest of Object.keys(groupData.channels)) {
|
||||
if (channelNest.startsWith("chat/")) {
|
||||
channels.push(channelNest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channels.length > 0) {
|
||||
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
||||
runtime.log?.(
|
||||
`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`,
|
||||
);
|
||||
} else {
|
||||
runtime.log?.("[tlon] No chat channels found via auto-discovery");
|
||||
runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels");
|
||||
}
|
||||
|
||||
return channels;
|
||||
} catch (error: any) {
|
||||
runtime.log?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
||||
runtime.log?.("[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels");
|
||||
runtime.log?.("[tlon] Example: [\"chat/~host-ship/channel-name\"]");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { extractMessageText } from "./utils.js";
|
||||
|
||||
export type TlonHistoryEntry = {
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const messageCache = new Map<string, TlonHistoryEntry[]>();
|
||||
const MAX_CACHED_MESSAGES = 100;
|
||||
|
||||
export function cacheMessage(channelNest: string, message: TlonHistoryEntry) {
|
||||
if (!messageCache.has(channelNest)) {
|
||||
messageCache.set(channelNest, []);
|
||||
}
|
||||
const cache = messageCache.get(channelNest);
|
||||
if (!cache) return;
|
||||
cache.unshift(message);
|
||||
if (cache.length > MAX_CACHED_MESSAGES) {
|
||||
cache.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelHistory(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
channelNest: string,
|
||||
count = 50,
|
||||
runtime?: RuntimeEnv,
|
||||
): Promise<TlonHistoryEntry[]> {
|
||||
try {
|
||||
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
||||
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
||||
|
||||
const data: any = await api.scry(scryPath);
|
||||
if (!data) return [];
|
||||
|
||||
let posts: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
posts = data;
|
||||
} else if (data.posts && typeof data.posts === "object") {
|
||||
posts = Object.values(data.posts);
|
||||
} else if (typeof data === "object") {
|
||||
posts = Object.values(data);
|
||||
}
|
||||
|
||||
const messages = posts
|
||||
.map((item) => {
|
||||
const essay = item.essay || item["r-post"]?.set?.essay;
|
||||
const seal = item.seal || item["r-post"]?.set?.seal;
|
||||
|
||||
return {
|
||||
author: essay?.author || "unknown",
|
||||
content: extractMessageText(essay?.content || []),
|
||||
timestamp: essay?.sent || Date.now(),
|
||||
id: seal?.id,
|
||||
} as TlonHistoryEntry;
|
||||
})
|
||||
.filter((msg) => msg.content);
|
||||
|
||||
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
||||
return messages;
|
||||
} catch (error: any) {
|
||||
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChannelHistory(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
channelNest: string,
|
||||
count = 50,
|
||||
runtime?: RuntimeEnv,
|
||||
): Promise<TlonHistoryEntry[]> {
|
||||
const cache = messageCache.get(channelNest) ?? [];
|
||||
if (cache.length >= count) {
|
||||
runtime?.log?.(`[tlon] Using cached messages (${cache.length} available)`);
|
||||
return cache.slice(0, count);
|
||||
}
|
||||
|
||||
runtime?.log?.(
|
||||
`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`,
|
||||
);
|
||||
return await fetchChannelHistory(api, channelNest, count, runtime);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import { format } from "node:util";
|
||||
|
||||
import type { RuntimeEnv, ReplyPayload, ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { getTlonRuntime } from "../runtime.js";
|
||||
import { resolveTlonAccount } from "../types.js";
|
||||
import { normalizeShip, parseChannelNest } from "../targets.js";
|
||||
import { authenticate } from "../urbit/auth.js";
|
||||
import { UrbitSSEClient } from "../urbit/sse-client.js";
|
||||
import { sendDm, sendGroupMessage } from "../urbit/send.js";
|
||||
import { cacheMessage, getChannelHistory } from "./history.js";
|
||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
||||
import {
|
||||
extractMessageText,
|
||||
formatModelName,
|
||||
isBotMentioned,
|
||||
isDmAllowed,
|
||||
isSummarizationRequest,
|
||||
} from "./utils.js";
|
||||
import { fetchAllChannels } from "./discovery.js";
|
||||
|
||||
export type MonitorTlonOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
type ChannelAuthorization = {
|
||||
mode?: "restricted" | "open";
|
||||
allowedShips?: string[];
|
||||
};
|
||||
|
||||
function resolveChannelAuthorization(
|
||||
cfg: ClawdbotConfig,
|
||||
channelNest: string,
|
||||
): { mode: "restricted" | "open"; allowedShips: string[] } {
|
||||
const tlonConfig = cfg.channels?.tlon as
|
||||
| {
|
||||
authorization?: { channelRules?: Record<string, ChannelAuthorization> };
|
||||
defaultAuthorizedShips?: string[];
|
||||
}
|
||||
| undefined;
|
||||
const rules = tlonConfig?.authorization?.channelRules ?? {};
|
||||
const rule = rules[channelNest];
|
||||
const allowedShips = rule?.allowedShips ?? tlonConfig?.defaultAuthorizedShips ?? [];
|
||||
const mode = rule?.mode ?? "restricted";
|
||||
return { mode, allowedShips };
|
||||
}
|
||||
|
||||
export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<void> {
|
||||
const core = getTlonRuntime();
|
||||
const cfg = core.config.loadConfig() as ClawdbotConfig;
|
||||
if (cfg.channels?.tlon?.enabled === false) return;
|
||||
|
||||
const logger = core.logging.getChildLogger({ module: "tlon-auto-reply" });
|
||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: (...args) => {
|
||||
logger.info(formatRuntimeMessage(...args));
|
||||
},
|
||||
error: (...args) => {
|
||||
logger.error(formatRuntimeMessage(...args));
|
||||
},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveTlonAccount(cfg, opts.accountId ?? undefined);
|
||||
if (!account.enabled) return;
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured (ship/url/code required)");
|
||||
}
|
||||
|
||||
const botShipName = normalizeShip(account.ship);
|
||||
runtime.log?.(`[tlon] Starting monitor for ${botShipName}`);
|
||||
|
||||
let api: UrbitSSEClient | null = null;
|
||||
try {
|
||||
runtime.log?.(`[tlon] Attempting authentication to ${account.url}...`);
|
||||
const cookie = await authenticate(account.url, account.code);
|
||||
api = new UrbitSSEClient(account.url, cookie, {
|
||||
ship: botShipName,
|
||||
logger: {
|
||||
log: (message) => runtime.log?.(message),
|
||||
error: (message) => runtime.error?.(message),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to authenticate: ${error?.message ?? String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const processedTracker = createProcessedMessageTracker(2000);
|
||||
let groupChannels: string[] = [];
|
||||
|
||||
if (account.autoDiscoverChannels !== false) {
|
||||
try {
|
||||
const discoveredChannels = await fetchAllChannels(api, runtime);
|
||||
if (discoveredChannels.length > 0) {
|
||||
groupChannels = discoveredChannels;
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Auto-discovery failed: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupChannels.length === 0 && account.groupChannels.length > 0) {
|
||||
groupChannels = account.groupChannels;
|
||||
runtime.log?.(`[tlon] Using manual groupChannels config: ${groupChannels.join(", ")}`);
|
||||
}
|
||||
|
||||
if (groupChannels.length > 0) {
|
||||
runtime.log?.(
|
||||
`[tlon] Monitoring ${groupChannels.length} group channel(s): ${groupChannels.join(", ")}`,
|
||||
);
|
||||
} else {
|
||||
runtime.log?.("[tlon] No group channels to monitor (DMs only)");
|
||||
}
|
||||
|
||||
const handleIncomingDM = async (update: any) => {
|
||||
try {
|
||||
const memo = update?.response?.add?.memo;
|
||||
if (!memo) return;
|
||||
|
||||
const messageId = update.id as string | undefined;
|
||||
if (!processedTracker.mark(messageId)) return;
|
||||
|
||||
const senderShip = normalizeShip(memo.author ?? "");
|
||||
if (!senderShip || senderShip === botShipName) return;
|
||||
|
||||
const messageText = extractMessageText(memo.content);
|
||||
if (!messageText) return;
|
||||
|
||||
if (!isDmAllowed(senderShip, account.dmAllowlist)) {
|
||||
runtime.log?.(`[tlon] Blocked DM from ${senderShip}: not in allowlist`);
|
||||
return;
|
||||
}
|
||||
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText,
|
||||
isGroup: false,
|
||||
timestamp: memo.sent || Date.now(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Error handling DM: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncomingGroupMessage = (channelNest: string) => async (update: any) => {
|
||||
try {
|
||||
const parsed = parseChannelNest(channelNest);
|
||||
if (!parsed) return;
|
||||
|
||||
const essay = update?.response?.post?.["r-post"]?.set?.essay;
|
||||
const memo = update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.memo;
|
||||
if (!essay && !memo) return;
|
||||
|
||||
const content = memo || essay;
|
||||
const isThreadReply = Boolean(memo);
|
||||
const messageId = isThreadReply
|
||||
? update?.response?.post?.["r-post"]?.reply?.id
|
||||
: update?.response?.post?.id;
|
||||
|
||||
if (!processedTracker.mark(messageId)) return;
|
||||
|
||||
const senderShip = normalizeShip(content.author ?? "");
|
||||
if (!senderShip || senderShip === botShipName) return;
|
||||
|
||||
const messageText = extractMessageText(content.content);
|
||||
if (!messageText) return;
|
||||
|
||||
cacheMessage(channelNest, {
|
||||
author: senderShip,
|
||||
content: messageText,
|
||||
timestamp: content.sent || Date.now(),
|
||||
id: messageId,
|
||||
});
|
||||
|
||||
const mentioned = isBotMentioned(messageText, botShipName);
|
||||
if (!mentioned) return;
|
||||
|
||||
const { mode, allowedShips } = resolveChannelAuthorization(cfg, channelNest);
|
||||
if (mode === "restricted") {
|
||||
if (allowedShips.length === 0) {
|
||||
runtime.log?.(`[tlon] Access denied: ${senderShip} in ${channelNest} (no allowlist)`);
|
||||
return;
|
||||
}
|
||||
const normalizedAllowed = allowedShips.map(normalizeShip);
|
||||
if (!normalizedAllowed.includes(senderShip)) {
|
||||
runtime.log?.(
|
||||
`[tlon] Access denied: ${senderShip} in ${channelNest} (allowed: ${allowedShips.join(", ")})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const seal = isThreadReply
|
||||
? update?.response?.post?.["r-post"]?.reply?.["r-reply"]?.set?.seal
|
||||
: update?.response?.post?.["r-post"]?.set?.seal;
|
||||
|
||||
const parentId = seal?.["parent-id"] || seal?.parent || null;
|
||||
|
||||
await processMessage({
|
||||
messageId: messageId ?? "",
|
||||
senderShip,
|
||||
messageText,
|
||||
isGroup: true,
|
||||
groupChannel: channelNest,
|
||||
groupName: `${parsed.hostShip}/${parsed.channelName}`,
|
||||
timestamp: content.sent || Date.now(),
|
||||
parentId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Error handling group message: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const processMessage = async (params: {
|
||||
messageId: string;
|
||||
senderShip: string;
|
||||
messageText: string;
|
||||
isGroup: boolean;
|
||||
groupChannel?: string;
|
||||
groupName?: string;
|
||||
timestamp: number;
|
||||
parentId?: string | null;
|
||||
}) => {
|
||||
const { messageId, senderShip, isGroup, groupChannel, groupName, timestamp, parentId } = params;
|
||||
let messageText = params.messageText;
|
||||
|
||||
if (isGroup && groupChannel && isSummarizationRequest(messageText)) {
|
||||
try {
|
||||
const history = await getChannelHistory(api!, groupChannel, 50, runtime);
|
||||
if (history.length === 0) {
|
||||
const noHistoryMsg =
|
||||
"I couldn't fetch any messages for this channel. It might be empty or there might be a permissions issue.";
|
||||
if (isGroup) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (parsed) {
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: noHistoryMsg,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: noHistoryMsg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const historyText = history
|
||||
.map((msg) => `[${new Date(msg.timestamp).toLocaleString()}] ${msg.author}: ${msg.content}`)
|
||||
.join("\n");
|
||||
|
||||
messageText =
|
||||
`Please summarize this channel conversation (${history.length} recent messages):\n\n${historyText}\n\n` +
|
||||
"Provide a concise summary highlighting:\n" +
|
||||
"1. Main topics discussed\n" +
|
||||
"2. Key decisions or conclusions\n" +
|
||||
"3. Action items if any\n" +
|
||||
"4. Notable participants";
|
||||
} catch (error: any) {
|
||||
const errorMsg = `Sorry, I encountered an error while fetching the channel history: ${error?.message ?? String(error)}`;
|
||||
if (isGroup && groupChannel) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (parsed) {
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: errorMsg,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: errorMsg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "tlon",
|
||||
accountId: opts.accountId ?? undefined,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? groupChannel ?? senderShip : senderShip,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `${senderShip} in ${groupName}` : senderShip;
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Tlon",
|
||||
from: fromLabel,
|
||||
timestamp,
|
||||
body: messageText,
|
||||
});
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: messageText,
|
||||
CommandBody: messageText,
|
||||
From: isGroup ? `tlon:group:${groupChannel}` : `tlon:${senderShip}`,
|
||||
To: `tlon:${botShipName}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderShip,
|
||||
SenderId: senderShip,
|
||||
Provider: "tlon",
|
||||
Surface: "tlon",
|
||||
MessageSid: messageId,
|
||||
OriginatingChannel: "tlon",
|
||||
OriginatingTo: `tlon:${isGroup ? groupChannel : botShipName}`,
|
||||
});
|
||||
|
||||
const dispatchStartTime = Date.now();
|
||||
|
||||
const responsePrefix = core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||
.responsePrefix;
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
let replyText = payload.text;
|
||||
if (!replyText) return;
|
||||
|
||||
const showSignature = account.showModelSignature ?? cfg.channels?.tlon?.showModelSignature ?? false;
|
||||
if (showSignature) {
|
||||
const modelInfo =
|
||||
payload.metadata?.model || payload.model || route.model || cfg.agents?.defaults?.model?.primary;
|
||||
replyText = `${replyText}\n\n_[Generated by ${formatModelName(modelInfo)}]_`;
|
||||
}
|
||||
|
||||
if (isGroup && groupChannel) {
|
||||
const parsed = parseChannelNest(groupChannel);
|
||||
if (!parsed) return;
|
||||
await sendGroupMessage({
|
||||
api: api!,
|
||||
fromShip: botShipName,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
text: replyText,
|
||||
replyToId: parentId ?? undefined,
|
||||
});
|
||||
} else {
|
||||
await sendDm({ api: api!, fromShip: botShipName, toShip: senderShip, text: replyText });
|
||||
}
|
||||
},
|
||||
onError: (err, info) => {
|
||||
const dispatchDuration = Date.now() - dispatchStartTime;
|
||||
runtime.error?.(
|
||||
`[tlon] ${info.kind} reply failed after ${dispatchDuration}ms: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const subscribedChannels = new Set<string>();
|
||||
const subscribedDMs = new Set<string>();
|
||||
|
||||
async function subscribeToChannel(channelNest: string) {
|
||||
if (subscribedChannels.has(channelNest)) return;
|
||||
const parsed = parseChannelNest(channelNest);
|
||||
if (!parsed) {
|
||||
runtime.error?.(`[tlon] Invalid channel format: ${channelNest}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api!.subscribe({
|
||||
app: "channels",
|
||||
path: `/${channelNest}`,
|
||||
event: handleIncomingGroupMessage(channelNest),
|
||||
err: (error) => {
|
||||
runtime.error?.(`[tlon] Group subscription error for ${channelNest}: ${String(error)}`);
|
||||
},
|
||||
quit: () => {
|
||||
runtime.log?.(`[tlon] Group subscription ended for ${channelNest}`);
|
||||
subscribedChannels.delete(channelNest);
|
||||
},
|
||||
});
|
||||
subscribedChannels.add(channelNest);
|
||||
runtime.log?.(`[tlon] Subscribed to group channel: ${channelNest}`);
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to subscribe to ${channelNest}: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToDM(dmShip: string) {
|
||||
if (subscribedDMs.has(dmShip)) return;
|
||||
try {
|
||||
await api!.subscribe({
|
||||
app: "chat",
|
||||
path: `/dm/${dmShip}`,
|
||||
event: handleIncomingDM,
|
||||
err: (error) => {
|
||||
runtime.error?.(`[tlon] DM subscription error for ${dmShip}: ${String(error)}`);
|
||||
},
|
||||
quit: () => {
|
||||
runtime.log?.(`[tlon] DM subscription ended for ${dmShip}`);
|
||||
subscribedDMs.delete(dmShip);
|
||||
},
|
||||
});
|
||||
subscribedDMs.add(dmShip);
|
||||
runtime.log?.(`[tlon] Subscribed to DM with ${dmShip}`);
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to subscribe to DM with ${dmShip}: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshChannelSubscriptions() {
|
||||
try {
|
||||
const dmShips = await api!.scry("/chat/dm.json");
|
||||
if (Array.isArray(dmShips)) {
|
||||
for (const dmShip of dmShips) {
|
||||
await subscribeToDM(dmShip);
|
||||
}
|
||||
}
|
||||
|
||||
if (account.autoDiscoverChannels !== false) {
|
||||
const discoveredChannels = await fetchAllChannels(api!, runtime);
|
||||
for (const channelNest of discoveredChannels) {
|
||||
await subscribeToChannel(channelNest);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Channel refresh failed: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runtime.log?.("[tlon] Subscribing to updates...");
|
||||
|
||||
let dmShips: string[] = [];
|
||||
try {
|
||||
const dmList = await api!.scry("/chat/dm.json");
|
||||
if (Array.isArray(dmList)) {
|
||||
dmShips = dmList;
|
||||
runtime.log?.(`[tlon] Found ${dmShips.length} DM conversation(s)`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Failed to fetch DM list: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
|
||||
for (const dmShip of dmShips) {
|
||||
await subscribeToDM(dmShip);
|
||||
}
|
||||
|
||||
for (const channelNest of groupChannels) {
|
||||
await subscribeToChannel(channelNest);
|
||||
}
|
||||
|
||||
runtime.log?.("[tlon] All subscriptions registered, connecting to SSE stream...");
|
||||
await api!.connect();
|
||||
runtime.log?.("[tlon] Connected! All subscriptions active");
|
||||
|
||||
const pollInterval = setInterval(() => {
|
||||
if (!opts.abortSignal?.aborted) {
|
||||
refreshChannelSubscriptions().catch((error) => {
|
||||
runtime.error?.(`[tlon] Channel refresh error: ${error?.message ?? String(error)}`);
|
||||
});
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
if (opts.abortSignal) {
|
||||
await new Promise((resolve) => {
|
||||
opts.abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearInterval(pollInterval);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} else {
|
||||
await new Promise(() => {});
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await api?.close();
|
||||
} catch (error: any) {
|
||||
runtime.error?.(`[tlon] Cleanup error: ${error?.message ?? String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createProcessedMessageTracker } from "./processed-messages.js";
|
||||
|
||||
describe("createProcessedMessageTracker", () => {
|
||||
it("dedupes and evicts oldest entries", () => {
|
||||
const tracker = createProcessedMessageTracker(3);
|
||||
|
||||
expect(tracker.mark("a")).toBe(true);
|
||||
expect(tracker.mark("a")).toBe(false);
|
||||
expect(tracker.has("a")).toBe(true);
|
||||
|
||||
tracker.mark("b");
|
||||
tracker.mark("c");
|
||||
expect(tracker.size()).toBe(3);
|
||||
|
||||
tracker.mark("d");
|
||||
expect(tracker.size()).toBe(3);
|
||||
expect(tracker.has("a")).toBe(false);
|
||||
expect(tracker.has("b")).toBe(true);
|
||||
expect(tracker.has("c")).toBe(true);
|
||||
expect(tracker.has("d")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ProcessedMessageTracker = {
|
||||
mark: (id?: string | null) => boolean;
|
||||
has: (id?: string | null) => boolean;
|
||||
size: () => number;
|
||||
};
|
||||
|
||||
export function createProcessedMessageTracker(limit = 2000): ProcessedMessageTracker {
|
||||
const seen = new Set<string>();
|
||||
const order: string[] = [];
|
||||
|
||||
const mark = (id?: string | null) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return true;
|
||||
if (seen.has(trimmed)) return false;
|
||||
seen.add(trimmed);
|
||||
order.push(trimmed);
|
||||
if (order.length > limit) {
|
||||
const overflow = order.length - limit;
|
||||
for (let i = 0; i < overflow; i += 1) {
|
||||
const oldest = order.shift();
|
||||
if (oldest) seen.delete(oldest);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const has = (id?: string | null) => {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) return false;
|
||||
return seen.has(trimmed);
|
||||
};
|
||||
|
||||
return {
|
||||
mark,
|
||||
has,
|
||||
size: () => seen.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { normalizeShip } from "../targets.js";
|
||||
|
||||
export function formatModelName(modelString?: string | null): string {
|
||||
if (!modelString) return "AI";
|
||||
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
||||
const modelMappings: Record<string, string> = {
|
||||
"claude-opus-4-5": "Claude Opus 4.5",
|
||||
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
||||
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
||||
"gpt-4o": "GPT-4o",
|
||||
"gpt-4-turbo": "GPT-4 Turbo",
|
||||
"gpt-4": "GPT-4",
|
||||
"gemini-2.0-flash": "Gemini 2.0 Flash",
|
||||
"gemini-pro": "Gemini Pro",
|
||||
};
|
||||
|
||||
if (modelMappings[modelName]) return modelMappings[modelName];
|
||||
return modelName
|
||||
.replace(/-/g, " ")
|
||||
.split(" ")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function isBotMentioned(messageText: string, botShipName: string): boolean {
|
||||
if (!messageText || !botShipName) return false;
|
||||
const normalizedBotShip = normalizeShip(botShipName);
|
||||
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
||||
return mentionPattern.test(messageText);
|
||||
}
|
||||
|
||||
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
|
||||
if (!allowlist || allowlist.length === 0) return true;
|
||||
const normalizedSender = normalizeShip(senderShip);
|
||||
return allowlist
|
||||
.map((ship) => normalizeShip(ship))
|
||||
.some((ship) => ship === normalizedSender);
|
||||
}
|
||||
|
||||
export function extractMessageText(content: unknown): string {
|
||||
if (!content || !Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.map((block: any) => {
|
||||
if (block.inline && Array.isArray(block.inline)) {
|
||||
return block.inline
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) return item.ship;
|
||||
if (item.break !== undefined) return "\n";
|
||||
if (item.link && item.link.href) return item.link.href;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isSummarizationRequest(messageText: string): boolean {
|
||||
const patterns = [
|
||||
/summarize\s+(this\s+)?(channel|chat|conversation)/i,
|
||||
/what\s+did\s+i\s+miss/i,
|
||||
/catch\s+me\s+up/i,
|
||||
/channel\s+summary/i,
|
||||
/tldr/i,
|
||||
];
|
||||
return patterns.some((pattern) => pattern.test(messageText));
|
||||
}
|
||||
|
||||
export function formatChangesDate(daysAgo = 5): string {
|
||||
const now = new Date();
|
||||
const targetDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
const year = targetDate.getFullYear();
|
||||
const month = targetDate.getMonth() + 1;
|
||||
const day = targetDate.getDate();
|
||||
return `~${year}.${month}.${day}..20.19.51..9b9d`;
|
||||
}
|
||||
Loaded 100 of 319 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user