Compare commits

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

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

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

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

all tests passing (77 tests)
2026-01-24 08:39:46 +00:00
Peter Steinberger 09a72f1ede docs: changelog msteams probe (#1574) (thanks @Evizero) 2026-01-24 08:35:10 +00:00
ChristofandChristof Salis 2b8b3c4b10 fix(msteams): remove remaining /.default postfix (#1574)
This fixes the msteams probe which otherwise incorrectly assumes teams is not working.

The @microsoft/agents-hosting SDK's MsalTokenProvider automatically appends /.default to all scope strings in its token acquisition methods (acquireAccessTokenViaSecret, acquireAccessTokenViaFIC, acquireAccessTokenViaWID, acquireTokenWithCertificate in msalTokenProvider.ts). This is consistent SDK behavior, not a recent change.

The current code is including .default in scope URLs, resulting in invalid double suffixes like https://graph.microsoft.com/.default/.default. I am not sure how the .default postfixed worked in the past for you if I am honest.

This was confirmed to cause Graph API authentication errors. Removing the .default suffix from our scope strings allows the SDK to append it correctly, resolving the issue. I confirmed it manually on my teams setup

Before: we pass .default -> SDK appends -> double .default (broken)
After: we pass base URL -> SDK appends -> single .default (works)

Co-authored-by: Christof Salis <c.salis@vertifymed.com>
2026-01-24 08:30:34 +00:00
Peter Steinberger 8ea8801d06 fix: show tool error fallback for tool-only replies 2026-01-24 08:17:50 +00:00
Peter Steinberger c97bf23a4a fix: gate openai reasoning downgrade on model switches (#1562) (thanks @roshanasingh4) 2026-01-24 08:16:42 +00:00
Peter Steinberger 3fff943ba1 fix: harden gateway lock validation (#1572) (thanks @steipete) 2026-01-24 08:15:07 +00:00
Peter Steinberger 90685ef814 docs(fly): comprehensive deployment guide with real-world learnings
Based on actual Flawd deployment experience:
- Proper fly.toml configuration with all required settings
- Step-by-step guide following exe.dev doc format
- Troubleshooting section with common issues and fixes
- Config file creation via SSH
- Cost estimates
2026-01-24 08:15:07 +00:00
Peter Steinberger a8f2ac5411 docs(fly): add configuration guidance for bind mode, memory, and troubleshooting 2026-01-24 08:15:07 +00:00
Peter Steinberger dea96a2c3d fix: handle PID recycling in container gateway lock
In containers, PIDs can be recycled quickly after restarts. When a container
restarts, a different process might get the same PID as the previous gateway,
causing the lock check to incorrectly think the old gateway is still running.

This fix adds isGatewayProcess() which verifies on Linux that the PID actually
belongs to a clawdbot gateway by checking /proc/PID/cmdline. If the cmdline
doesn't contain 'clawdbot' or 'gateway', we assume the lock is stale.

Fixes gateway boot-loop in Docker/Fly.io deployments.
2026-01-24 08:15:07 +00:00
Peter Steinberger 90ae2f541c feat: add Fly.io deployment support
- Add fly.toml configuration for Fly.io deployment
- Add docs/platforms/fly.md with deployment guide
- Uses London (lhr) region by default
- Includes persistent volume for data storage
2026-01-24 08:15:07 +00:00
Peter Steinberger d9a467fe3b feat: move TTS into core (#1559) (thanks @Glucksberg) 2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 aef88cd9f1 test(telegram-tts): add unit tests for summarizeText function
- Export summarizeText in _test for testing
- Add 8 tests covering:
  - Successful summarization with metrics
  - OpenAI API call parameters verification
  - targetLength validation (min/max boundaries)
  - Error handling (API failures, empty responses)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 104d977d12 feat(telegram-tts): add latency logging, status tracking, and unit tests
- Add latency metrics to summarizeText and textToSpeech functions
- Add /tts_status command showing config and last attempt result
- Add /tts_summary command for feature flag control
- Fix atomic write to clean up temp file on rename failure
- Add timer.unref() to prevent blocking process shutdown
- Add unit tests for validation functions (13 tests)
- Update README with new commands and features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 4b24753be7 feat(telegram-tts): add /tts_limit command and auto-summarization
- Add /tts_limit command to configure max text length (default 1500)
- Auto-summarize long texts with gpt-4o-mini before TTS conversion
- Add truncation safeguard if summary exceeds hard limit
- Validate targetLength parameter (100-10000)
- Use conservative max_tokens for multilingual text
- Add prompt injection defense with XML delimiters

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 df09e583aa feat(telegram-tts): add auto-TTS hook and provider switching
- Integrate message_sending hook into Telegram delivery path
- Send text first, then audio as voice message after
- Add /tts_provider command to switch between OpenAI and ElevenLabs
- Implement automatic fallback when primary provider fails
- Use gpt-4o-mini-tts as default OpenAI model
- Add hook integration to route-reply.ts for other channels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 46e6546bb9 feat(telegram-tts): make extension self-contained with direct API calls
- Remove sag CLI dependency
- Add direct ElevenLabs API integration via fetch
- Add OpenAI TTS as alternative provider
- Support multi-provider configuration
- Add tts.providers RPC method
- Update config schema with OpenAI options
- Bump version to 0.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
GlucksbergandClaude Opus 4.5 5428c97685 feat(extensions): add telegram-tts extension for voice responses
Add a new extension that provides automatic text-to-speech for chat
responses using ElevenLabs API.

Features:
- `speak` tool for converting text to voice messages
- RPC methods: tts.status, tts.enable, tts.disable, tts.convert
- User preferences file for persistent TTS state
- Configurable voice ID, model, and max text length

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 08:00:44 +00:00
Roshan Singh 202d7af855 Fix OpenAI Responses transcript after model switch 2026-01-24 07:58:25 +00:00
Bradley PriestandPeter Steinberger 72020b37c3 fix(bird skill): gate brew install to macOS (#1569)
* fix(bird skill): gate brew install to macOS

* fix: gate bird brew install to macOS (#1569) (thanks @bradleypriest)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 07:53:29 +00:00
Peter Steinberger b051621bd4 fix: update changelog + clawtributors (#1571) (thanks @Takhoffman) 2026-01-24 07:47:35 +00:00
Tak hoffman ff52aec38e Agents: drop bash tool alias 2026-01-24 07:44:04 +00:00
Peter Steinberger 15620b1092 fix: guard tool allowlists with warnings 2026-01-24 07:38:42 +00:00
Peter Steinberger ad7fc4964a fix: gate TUI lifecycle updates to active run (#1567) (thanks @vignesh07) 2026-01-24 07:23:41 +00:00
Tak HoffmanandTak hoffman 8f4426052c CLI: fix Windows node argv stripping (#1564)
Co-authored-by: Tak hoffman <takayukihoffman@gmail.com>
2026-01-24 07:10:40 +00:00
Peter Steinberger 6a60d47c53 fix: cover slack open policy gating (#1563) (thanks @itsjaydesu) 2026-01-24 07:09:26 +00:00
Peter Steinberger b1482957f5 feat: add cron time context 2026-01-24 07:08:33 +00:00
Jay Winder 4d2e9e8113 fix(slack): apply open policy consistently to slash commands
Address reviewer feedback: slash commands now use the same
hasExplicitConfig check as regular messages, so unlisted
channels are allowed under groupPolicy: "open" for both
message handling and slash commands.
2026-01-24 07:05:55 +00:00
Jay Winder 72d62a54c6 fix: groupPolicy: "open" ignored when channel-specific config exists
## Summary

Fix Slack `groupPolicy: "open"` to allow unlisted channels even when `channels.slack.channels` contains custom entries.

## Problem

When `groupPolicy` is set to `"open"`, the bot should respond in **any channel** it's invited to. However, if `channels.slack.channels` contains *any* entries—even just one channel with a custom system prompt—the open policy is ignored. Only explicitly listed channels receive responses; all others get an ephemeral "This channel is not allowed" error.

### Example config

```json
{
  "channels": {
    "slack": {
      "groupPolicy": "open",
      "channels": {
        "C0123456789": { "systemPrompt": "Custom prompt for this channel" }
      }
    }
  }
}
```

With this config, the bot only responds in `C0123456789`. Messages in any other channel are blocked—even though the policy is `"open"`.

## Root Cause

In `src/slack/monitor/context.ts`, `isChannelAllowed()` has two sequential checks:

1. `isSlackChannelAllowedByPolicy()` — correctly returns `true` for open policy
2. A secondary `!channelAllowed` check — was blocking channels when `resolveSlackChannelConfig()` returned `{ allowed: false }` for unlisted channels

The second check conflated "channel not in config" with "channel explicitly denied."

## Fix

Use `matchSource` to distinguish explicit denial from absence of config:

```ts
const hasExplicitConfig = Boolean(channelConfig?.matchSource);
if (!channelAllowed && (params.groupPolicy !== "open" || hasExplicitConfig)) {
  return false;
}
```

When `matchSource` is undefined, the channel has no explicit config entry and should be allowed under open policy.

## Behavior After Fix

| Scenario | Result |
|----------|--------|
| `groupPolicy: "open"`, channel unlisted | ✅ Allowed |
| `groupPolicy: "open"`, channel explicitly denied (`allow: false`) | ❌ Blocked |
| `groupPolicy: "open"`, channel with custom config | ✅ Allowed |
| `groupPolicy: "allowlist"`, channel unlisted | ❌ Blocked |

## Test Plan

- [x] Open policy + unlisted channel → allowed
- [x] Open policy + explicitly denied channel → blocked
- [x] Allowlist policy + unlisted channel → blocked
- [x] Allowlist policy + listed channel → allowed
2026-01-24 07:05:55 +00:00
Peter Steinberger ae48066d28 fix: track TUI agent events for external runs (#1567) (thanks @vignesh07) 2026-01-24 07:00:01 +00:00
Vignesh Natarajan f56f799990 tui: filter agent events by active chat run id
Agent events are emitted per run; filter against activeChatRunId instead of session id. Adds unit tests for tool + lifecycle events.
2026-01-24 07:00:01 +00:00
Andrii 7e498ab94a anthropic-payload-log mvp
Added a dedicated Anthropic payload logger that writes exact request
JSON (as sent) plus per‑run usage stats (input/output/cache read/write)
to a
  standalone JSONL file, gated by an env flag.

  Changes

  - New logger: src/agents/anthropic-payload-log.ts (writes
logs/anthropic-payload.jsonl under the state dir, optional override via
env).
  - Hooked into embedded runs to wrap the stream function and record
usage: src/agents/pi-embedded-runner/run/attempt.ts.

  How to enable

  - CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1
  - Optional:
CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl

  What you’ll get (JSONL)

  - stage: "request" with payload (exact Anthropic params) +
payloadDigest
  - stage: "usage" with usage
(input/output/cacheRead/cacheWrite/totalTokens/etc.)

  Notes

  - Usage is taken from the last assistant message in the run; if the
run fails before usage is present, you’ll only see an error field.

  Files touched

  - src/agents/anthropic-payload-log.ts
  - src/agents/pi-embedded-runner/run/attempt.ts

  Tests not run.
2026-01-24 06:43:51 +00:00
GlucksbergandClaude Opus 4.5 6bd6ae41b1 fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Peter Steinberger 66eec295b8 perf: stabilize system prompt time 2026-01-24 06:24:04 +00:00
Peter Steinberger 675019cb6f fix: trigger fallback on auth profile exhaustion 2026-01-24 06:14:23 +00:00
Peter Steinberger 795b592286 fix: sync protocol swift models 2026-01-24 06:01:19 +00:00
Peter Steinberger 9d98e55ed5 fix: enforce group tool policy inheritance for subagents (#1557) (thanks @adam91holt) 2026-01-24 05:49:39 +00:00
Adam Holt c07949a99c Channels: add per-group tool policies 2026-01-24 05:49:39 +00:00
Peter Steinberger e51bf46abe fix: regenerate protocol swift models 2026-01-24 05:41:00 +00:00
Peter Steinberger eba0625a70 fix: ignore identity template placeholders 2026-01-24 05:35:50 +00:00
Peter Steinberger 886752217d fix: gate diagnostic logs behind verbose 2026-01-24 05:06:42 +00:00
Peter Steinberger 5662a9cdfc fix: honor tools.exec ask/security in approvals 2026-01-24 04:53:44 +00:00
Peter Steinberger fd23b9b209 fix: normalize outbound media payloads 2026-01-24 04:53:34 +00:00
Peter Steinberger 975f5a5284 fix: guard session store against array corruption 2026-01-24 04:51:46 +00:00
Peter Steinberger 63176ccb8a test: isolate heartbeat runner workspace in tests 2026-01-24 04:48:01 +00:00
Peter Steinberger 6c3a9fc092 fix: handle extension relay session reuse 2026-01-24 04:41:28 +00:00
Peter Steinberger d9f173a03d test: stabilize service-env path tests on windows 2026-01-24 04:36:52 +00:00
Peter Steinberger c3cb26f7ca feat: add node browser proxy routing 2026-01-24 04:21:47 +00:00
JustYanniccandPeter Steinberger dd06028827 feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty

- Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments
- Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM
- Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist
- Preserves existing behavior when file is missing (lets LLM decide)
- Added comprehensive test coverage for empty file detection
- Saves API calls/costs when heartbeat file has no meaningful content

* chore: update HEARTBEAT.md template to be effectively empty by default

Changed instruction text to comment format so new workspaces benefit from
heartbeat optimization immediately. Users still get clear guidance on usage.

* fix: only treat markdown headers (# followed by space) as comments, not #TODO etc

* refactor: simplify regex per code review suggestion

* docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 04:19:01 +00:00
Peter Steinberger 71203829d8 feat: add system cli 2026-01-24 04:03:07 +00:00
Peter Steinberger dfa80e1e5d fix(ui): align control ui chat and config rendering 2026-01-24 03:55:43 +00:00
Peter Steinberger 951a4ea065 fix: anchor MEDIA tag parsing 2026-01-24 03:46:27 +00:00
Peter Steinberger 4fa1517e6d docs: add channels list usage troubleshooting 2026-01-24 03:44:44 +00:00
Peter Steinberger de2d986008 fix: render Telegram media captions 2026-01-24 03:39:25 +00:00
Peter Steinberger d57cb2e1a8 fix(ui): cache control ui markdown 2026-01-24 03:27:28 +00:00
Peter Steinberger b697374ce5 fix: update docker gateway command 2026-01-24 03:24:28 +00:00
Peter Steinberger b9106ba5f9 fix: guard console settings recursion (#1555) (thanks @travisp) 2026-01-24 03:15:05 +00:00
Travis 3ba9821254 Logging: guard console settings recursion 2026-01-24 03:12:40 +00:00
Peter Steinberger 17f2a990a8 docs: add changelog entry for memory slot none (#1554) (thanks @andreabadesso) 2026-01-24 03:11:31 +00:00
André Abadesso 71f7bd1cfd test: add tests for normalizePluginsConfig memory slot handling 2026-01-24 03:08:27 +00:00
André Abadesso c4c01089ab fix: respect "none" value for plugins.slots.memory 2026-01-24 03:08:27 +00:00
Peter Steinberger b6591c3f69 fix: add log hint for agent failure (#1550) (thanks @sweepies) 2026-01-24 02:56:38 +00:00
google-labs-jules[bot] e6fdbae79b Fix formatting of 'Agent failed before reply' error messages
- Remove hardcoded period after error message to avoid double periods
- Move 'Check gateway logs for details' to a new line for better readability
2026-01-24 02:54:42 +00:00
Peter Steinberger a4e57d3ac4 fix: align service path tests with platform delimiters 2026-01-24 02:34:54 +00:00
Peter Steinberger 1d862cf5c2 fix: add readability fallback extraction 2026-01-24 02:15:13 +00:00
Peter Steinberger 0840029982 fix: stabilize embedded runner queueing 2026-01-24 02:05:41 +00:00
Peter Steinberger 309fcc5321 fix: publish llm-task docs and harden tool 2026-01-24 01:44:51 +00:00
Peter Steinberger 00ae21bed2 fix: inline auth probe errors in status table 2026-01-24 01:37:08 +00:00
Peter Steinberger 00fd57b8f5 fix: honor wildcard tool allowlists 2026-01-24 01:30:44 +00:00
Peter Steinberger aabe0bed30 fix: clean wrapped banner tagline 2026-01-24 01:26:17 +00:00
Peter Steinberger 350131b4d7 fix: improve web image optimization 2026-01-24 01:18:58 +00:00
Vignesh 95d45c0aa7 feat: add optional llm-task JSON-only tool (#1498)
* feat(llm-task): add optional JSON-only LLM task tool

* fix(llm-task): fix invalid package.json

* fix(llm-task): fix invalid plugin manifest JSON

* fix(llm-task): fix index.ts import quoting

* fix(llm-task): load embedded runner from src or bundled dist
2026-01-24 01:18:47 +00:00
Peter Steinberger cb06e133ca docs: update bedrock discovery changelog ref (#1553) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger 4e77483051 fix: refine bedrock discovery defaults (#1543) (thanks @fal3) 2026-01-24 01:18:33 +00:00
Peter Steinberger 81535d512a fix: clarify auth order exclusions 2026-01-24 01:18:03 +00:00
Alex Fallah 8effb557d5 feat: add dynamic Bedrock model discovery
Add automatic discovery of AWS Bedrock models using ListFoundationModels API.
When AWS credentials are detected, models that support streaming and text output
are automatically discovered and made available.

- Add @aws-sdk/client-bedrock dependency
- Add discoverBedrockModels() with caching (default 1 hour)
- Add resolveImplicitBedrockProvider() for auto-registration
- Add BedrockDiscoveryConfig for optional filtering by provider/region
- Filter to active, streaming, text-output models only
- Update docs/bedrock.md with auto-discovery documentation
2026-01-24 01:15:06 +00:00
Peter Steinberger c66b1fd18b docs: add changelog entry for sidebar fix (#1515) (thanks @pookNast) 2026-01-24 01:00:19 +00:00
c04f8ba1ea fix(ui): Make sidebar sticky while scrolling content (#1515)
The left navigation sidebar now stays fixed when scrolling through
long content pages like /skills. Changed .shell from min-height to
fixed height with overflow: hidden, allowing nav and content to
scroll independently within their grid cells.

Co-authored-by: pookNast <pook@nast.local>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:58:37 +00:00
Peter Steinberger c1b7f6b6ba Merge pull request #1544 from wca4a/feature/add-tlon-plugin
Add Tlon/Urbit channel plugin
2026-01-24 00:57:44 +00:00
Peter Steinberger e4708b3b99 test: relax tailscale binary expectations 2026-01-24 00:49:04 +00:00
Peter Steinberger f938f6617b docs: extend cron vs heartbeat guide 2026-01-24 00:42:56 +00:00
justyannicc e882f7d207 docs: add cron vs heartbeat decision guide
- New docs/automation/cron-vs-heartbeat.md with complete guidance
- Cross-links from heartbeat.md and cron-jobs.md
- Updated AGENTS.md template with practical guidance
- Added navigation entry in docs.json
2026-01-24 00:42:56 +00:00
AJ (@techfren) e38fd8603f docs: remove misplaced Google Docs Editor from showcase (#1547)
- Was incorrectly placed in Voice & Phone section
- Not a Clawdbot project (Claude Code skill)
- No valid link available
2026-01-24 00:42:48 +00:00
Travis 89283aa788 Plugins: move clawdbot to devDependencies + add zod 2026-01-24 00:42:33 +00:00
Peter Steinberger f7dc27f2d0 fix: move probe errors below table 2026-01-24 00:32:49 +00:00
google-labs-jules[bot] ed560e466f fix(doctor): align sandbox image check with main logic
Updated `dockerImageExists` in `src/commands/doctor-sandbox.ts` to mirror the logic in `src/agents/sandbox/docker.ts`. It now re-throws errors unless they are explicitly "No such image" errors.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot] b5f1dc9d95 chore(tests): remove reproduction test
Removed the test file `src/agents/sandbox/docker.test.ts` as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot] f58ad7625f fix(sandbox): simplify docker image check
Simplify the stderr check in `dockerImageExists` to only look for "No such image", as requested in code review.
2026-01-24 00:30:24 +00:00
google-labs-jules[bot] 49c6d8019f fix(sandbox): improve docker image existence check error handling
Previously, `dockerImageExists` assumed any error from `docker image inspect` meant the image did not exist. This masked other errors like socket permission issues.

This change:
- Modifies `dockerImageExists` to inspect stderr when the exit code is non-zero.
- Returns `false` only if the error explicitly indicates "No such image" or "No such object".
- Throws an error with the stderr content for all other failures.
- Adds a reproduction test in `src/agents/sandbox/docker.test.ts`.
2026-01-24 00:30:24 +00:00
Peter Steinberger 86db180a17 docs: clarify PR merge preference 2026-01-24 00:30:11 +00:00
Peter Steinberger c69111a4e6 Merge branch 'main' into feature/add-tlon-plugin 2026-01-24 00:27:24 +00:00
Peter Steinberger 31e59cd583 fix: hide probe logs without verbose 2026-01-24 00:27:05 +00:00
Peter Steinberger d2bfcd70e7 fix: stabilize tests and sync protocol models 2026-01-24 00:25:58 +00:00
Peter Steinberger 12d22e1c89 chore: update clawtributors 2026-01-24 00:25:39 +00:00
Peter Steinberger 75cb78a5b1 chore: drop tlon node_modules 2026-01-24 00:25:39 +00:00
Peter Steinberger 791b568f78 feat: add tlon channel plugin 2026-01-24 00:25:39 +00:00
william arztandWilliam Arzt d46642319b Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-24 00:25:38 +00:00
Peter Steinberger a96d37ca69 docs: clarify plugin dependency rules 2026-01-24 00:23:21 +00:00
Peter Steinberger f8046268bc chore: drop tlon node_modules 2026-01-24 00:18:58 +00:00
Peter Steinberger 9cdd0c28be feat: add tlon channel plugin 2026-01-24 00:17:58 +00:00
Peter Steinberger 05b0b82937 fix: guard tailscale sudo fallback (#1551) (thanks @sweepies) 2026-01-24 00:17:20 +00:00
google-labs-jules[bot] 908d9331af feat: use sudo fallback for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now attempt the command directly first. If it fails, we catch the error and retry
with `sudo -n`. This preserves existing behavior for users where it works, but
attempts to escalate privileges (non-interactively) if needed.

- Added `execWithSudoFallback` helper in `src/infra/tailscale.ts`.
- Updated `ensureFunnel`, `enableTailscaleServe`, `disableTailscaleServe`,
  `enableTailscaleFunnel`, and `disableTailscaleFunnel` to use the fallback helper.
- Added tests in `src/infra/tailscale.test.ts` to verify fallback behavior.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot] 29f0463f65 feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
2026-01-24 00:17:20 +00:00
google-labs-jules[bot] 66f353fe7a feat: use sudo for tailscale configuration commands
To avoid permission denied errors when modifying Tailscale configuration (serve/funnel),
we now prepend `sudo -n` to these commands. This ensures that if the user has appropriate
sudo privileges (specifically passwordless for these commands or generally), the operation
succeeds. If sudo fails (e.g. requires password non-interactively), it will throw an error
which is caught and logged as a warning, preserving existing behavior but attempting to escalate privileges first.

- Updated `ensureFunnel` to use `sudo -n` for the enabling step.
- Updated `enableTailscaleServe`, `disableTailscaleServe`, `enableTailscaleFunnel`, `disableTailscaleFunnel` to use `sudo -n`.
- Added tests in `src/infra/tailscale.test.ts` to verify `sudo` usage.
2026-01-24 00:17:20 +00:00
Robby 511a0c22b7 fix(sessions): reset token counts to 0 on /new (#1523)
- Set inputTokens, outputTokens, totalTokens to 0 in sessions.reset
- Clear TUI sessionInfo tokens immediately before async reset
- Prevents stale token display after session reset

Fixes #1523
2026-01-24 00:15:42 +00:00
william arztandWilliam Arzt 24de8cecf6 Add Tlon/Urbit channel plugin
Adds built-in Tlon (Urbit) channel plugin to support decentralized messaging on the Urbit network.

Features:
- DM and group chat support
- SSE-based real-time message monitoring
- Auto-discovery of group channels
- Thread replies and reactions
- Integration with Urbit's HTTP API

This resolves cron delivery issues with external Tlon plugins by making it a first-class built-in channel alongside Telegram, Signal, and other messaging platforms.

Implementation includes:
- Plugin registration via ClawdbotPluginApi
- Outbound delivery with sendText and sendMedia
- Gateway adapter for inbound message handling
- Urbit SSE client for event streaming
- Core bridge for Clawdbot runtime integration

Co-authored-by: William Arzt <william@arzt.co>
2026-01-23 15:17:07 -05:00
319 changed files with 17737 additions and 1192 deletions

No files matched your search

+2
View File
@@ -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
View File
@@ -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)
+25 -23
View File
@@ -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
View File
@@ -20,7 +20,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND:-lan}",
"--port",
+6 -3
View File
@@ -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
+274
View File
@@ -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
+32
View File
@@ -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
+1
View File
@@ -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.
+133
View File
@@ -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).
+1
View File
@@ -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
+45
View File
@@ -0,0 +1,45 @@
---
summary: "CLI reference for `clawdbot contacts` (unified contact graph)"
read_when:
- You want to list or link contacts across channels
- You are using the contacts-search plugin
---
# `clawdbot contacts`
Unified contact graph and identity linking.
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
Concept overview: [Contact graph](/contact).
## Examples
```bash
clawdbot contacts list
clawdbot contacts list --query "sarah" --platform slack
clawdbot contacts show <contact-id>
clawdbot contacts search "alice"
clawdbot contacts link <primary-id> <secondary-id>
clawdbot contacts unlink slack U12345678
clawdbot contacts suggestions
clawdbot contacts auto-link --dry-run
clawdbot contacts stats
clawdbot contacts alias <contact-id> "Alias Name"
clawdbot contacts alias <contact-id> "Old Alias" --remove
```
## Commands
- `list`: list contacts (supports `--query`, `--platform`, `--limit`, `--json`).
- `show <id>`: show a contact + identities (accepts a canonical id or a search query).
- `search <query>`: search contacts by name/alias/username.
- `link <primary> <secondary>`: merge two contacts.
- `unlink <platform> <platformId>`: detach an identity into a new contact.
- `suggestions`: show link suggestions.
- `auto-link`: link high-confidence matches (use `--dry-run` to preview).
- `stats`: store statistics by platform.
- `alias <contactId> <alias>`: add or remove aliases (`--remove`).
## Notes
- `--platform` expects a channel id (e.g. `slack`, `discord`, `whatsapp`).
- `unlink` uses the platform id stored on the identity (not the contact id).
+26 -5
View File
@@ -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:
+18
View File
@@ -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
+36
View File
@@ -0,0 +1,36 @@
---
summary: "CLI reference for `clawdbot search` (cross-platform message search)"
read_when:
- You want to search indexed messages across channels
- You are using the contacts-search plugin
---
# `clawdbot search`
Search indexed messages across channels.
Provided by the [Contacts + Search plugin](/plugins/contacts-search).
## Examples
```bash
clawdbot search "meeting tomorrow"
clawdbot search "deadline" --from alice
clawdbot search "project" --platform slack --since 1w
clawdbot search "invoice" --since 2025-12-01 --until 2025-12-31
clawdbot search "handoff" --limit 50 --json
```
## Options
- `--from <contact>`: filter by sender name/alias/username or contact id.
- `--platform <name>`: filter by channel id (e.g. `slack`, `discord`, `whatsapp`).
- `--since <time>`: start time (`1h`, `2d`, `1w`, `1m`, or ISO date).
- `--until <time>`: end time (same formats as `--since`).
- `--limit <n>`: limit results (default `20`).
- `--json`: raw JSON output.
## Notes
- Results come from the local contacts store (`~/.clawdbot/contacts/contacts.sqlite`).
- Only inbound messages are indexed (no backfill).
- Concept overview: [Contact graph](/contact).
+55
View File
@@ -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.
-35
View File
@@ -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.
+5 -5
View File
@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling
The system prompt includes a dedicated **Current Date & Time** section when 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
View File
@@ -0,0 +1,100 @@
---
summary: "Unified contacts: contact graph, identity linking, and message indexing"
read_when:
- You want to understand how Clawdbot merges identities across channels
- You are using the Contacts + Search plugin
---
# Contact graph
Clawdbot can maintain a **unified contact graph** that links the same person across multiple channels (Slack, Discord, WhatsApp, etc.).
This powers cross-platform message search and manual identity linking.
The contact graph is provided by the **Contacts + Search** plugin and is **disabled by default**.
## Enable
Install/enable the plugin on the **Gateway host**, then restart the Gateway.
```bash
clawdbot plugins enable contacts-search
```
Config equivalent:
```json5
{
plugins: {
entries: {
"contacts-search": { enabled: true }
}
}
}
```
Related:
- [Contacts + Search plugin](/plugins/contacts-search)
- [Plugins overview](/plugin)
## Data model
The contact graph has three layers:
1) **Canonical contact**
- One logical person.
- Has a `canonicalId`, display name, and optional aliases.
2) **Platform identity**
- One account on one channel (e.g. `slack:U123...`).
- Links back to a canonical contact.
- Optional username, phone, display name, and last-seen time.
3) **Indexed message**
- Text of inbound messages tied to a platform identity.
- Used by cross-platform search.
## How contacts are created
Contacts are created automatically when **inbound messages** arrive:
- The plugin extracts sender identity details from the inbound message.
- If the platform identity is new, a new canonical contact is created.
- If it already exists, the identity metadata is refreshed.
There is **no backfill** step today; indexing starts when the plugin is enabled.
## Linking identities
You can link identities that belong to the same person:
- **Manual link**: merge two contacts into one canonical contact.
- **Suggestions**: name/phone similarity hints (preview-only).
- **Auto-link**: high-confidence matches (same phone number).
CLI reference: [Contacts CLI](/cli/contacts)
## Searching messages
Use the CLI or slash command:
- `clawdbot search "query"` (CLI)
- `/search <query>` (chat)
Search uses SQLite FTS when available; otherwise it falls back to SQL `LIKE`.
CLI reference: [Search CLI](/cli/search)
Slash commands: [Slash commands](/tools/slash-commands)
## Storage + privacy
- Stored locally on the Gateway host at `~/.clawdbot/contacts/contacts.sqlite`.
- No cloud sync by default.
- Treat this file as **sensitive** (names, handles, phone numbers).
To reset the graph, disable the plugin and move the SQLite file to Trash, then restart the Gateway.
## Troubleshooting
- **No results**: the plugin only indexes **new inbound messages**.
- **Missing contacts**: ensure the plugin is enabled and the Gateway restarted.
- **Search feels shallow**: FTS may be unavailable; check that SQLite FTS5 is supported on your runtime.
+8 -8
View File
@@ -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
View File
@@ -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"
]
+39
View File
@@ -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):
+8 -1
View File
@@ -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
+267
View File
@@ -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.
+1 -1
View File
@@ -184,7 +184,7 @@ services:
[
"node",
"dist/index.js",
"gateway-daemon",
"gateway",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
+1
View File
@@ -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)
+71
View File
@@ -38,6 +38,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Contacts + Search](/plugins/contacts-search) — bundled unified contacts + cross-platform search (disabled by default)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -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
+70
View File
@@ -0,0 +1,70 @@
---
summary: "Contacts + Search plugin: unified contacts and cross-platform message search"
read_when:
- You want unified contacts or cross-platform message search
- You are enabling the contacts-search plugin
---
# Contacts + Search (plugin)
Unified contact graph + cross-platform message search.
Indexes incoming messages, links platform identities, and exposes `/search` plus CLI tools.
## What it adds
- `clawdbot contacts ...` (link, list, search, stats)
- `clawdbot search ...` (message search)
- `/search ...` slash command (text surfaces)
## Where it runs
Runs inside the Gateway process. Enable it on the **Gateway host**, then restart the Gateway.
## Enable (bundled)
```bash
clawdbot plugins enable contacts-search
```
Or in config:
```json5
{
plugins: {
entries: {
"contacts-search": { enabled: true }
}
}
}
```
Restart the Gateway after enabling.
## Data location
The contact store lives under the Clawdbot state directory:
- `~/.clawdbot/contacts/contacts.sqlite`
If you run with `--profile <name>` or `--dev`, the state root changes accordingly.
## Indexing notes
- Messages are indexed as they arrive (no backfill).
- Search uses SQLite FTS when available; otherwise falls back to SQL `LIKE` queries.
## CLI quickstart
```bash
clawdbot contacts list
clawdbot contacts search "sarah"
clawdbot contacts show <contact-id>
clawdbot search "meeting notes" --from sarah --since 1w
```
Related:
- CLI: [contacts](/cli/contacts)
- CLI: [search](/cli/search)
- Concept: [Contact graph](/contact)
- Slash commands: [Slash commands](/tools/slash-commands)
- Plugins: [Plugins](/plugin)
+17
View File
@@ -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?
+2 -1
View File
@@ -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.
+10 -5
View File
@@ -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)*
---
+1
View File
@@ -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.
+2
View File
@@ -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.
+4
View File
@@ -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?
+1
View File
@@ -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)
+1 -7
View File
@@ -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>
+13
View File
@@ -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
+1
View File
@@ -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).
+6
View File
@@ -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
+114
View File
@@ -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).
+46
View File
@@ -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.
+9 -1
View File
@@ -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)
+2
View File
@@ -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 }) => ({
+2 -1
View File
@@ -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({
+2
View File
@@ -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": {}
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import {
configureContactStore,
closeContactStore,
} from "./src/contacts/index.js";
import { registerContactsCli } from "./src/cli/contacts-cli.js";
import { registerSearchCli } from "./src/cli/search-cli.js";
import { runSearchCommand } from "./src/commands/search-command.js";
import { indexInboundMessage } from "./src/hooks/message-indexer.js";
const contactsSearchPlugin = {
id: "contacts-search",
name: "Contacts + Search",
description: "Unified contact graph with cross-platform message search",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
const stateDir = api.runtime.state.resolveStateDir();
configureContactStore({ stateDir });
api.registerCli(
({ program }) => {
registerContactsCli(program);
registerSearchCli(program);
},
{ commands: ["contacts", "search"] },
);
api.registerCommand({
name: "search",
description: "Search messages across platforms.",
acceptsArgs: true,
handler: async (ctx) => ({ text: runSearchCommand(ctx.commandBody) }),
});
api.on(
"message_received",
(event, ctx) => {
indexInboundMessage({ event, ctx, logger: api.logger });
},
);
api.registerService({
id: "contacts-search",
start: () => {},
stop: () => {
closeContactStore();
},
});
},
};
export default contactsSearchPlugin;
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/contacts-search",
"version": "2026.1.21",
"type": "module",
"description": "Clawdbot unified contacts and cross-platform search plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
@@ -0,0 +1,474 @@
import type { Command } from "commander";
import {
autoLinkHighConfidence,
findLinkSuggestions,
getContactStore,
linkContacts,
unlinkIdentity,
} from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
import { formatDocsLink } from "clawdbot/plugin-sdk";
import { cli, formatDanger, formatSuccess, renderTable, theme } from "./formatting.js";
function formatPlatformList(platforms: string[]): string {
return platforms.join(", ");
}
function formatContactRow(contact: {
canonicalId: string;
displayName: string;
aliases: string[];
identities: Array<{ platform: string; platformId: string; username?: string | null }>;
}) {
const platforms = [...new Set(contact.identities.map((i) => i.platform))];
return {
ID: contact.canonicalId,
Name: contact.displayName,
Platforms: formatPlatformList(platforms),
Identities: String(contact.identities.length),
};
}
export function registerContactsCli(program: Command) {
const contacts = program
.command("contacts")
.description("Unified contact graph - cross-platform identity management")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink(
"/cli/contacts",
"docs.clawd.bot/cli/contacts",
)}\n`,
)
.action(() => {
contacts.help({ error: true });
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts list
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("list")
.description("List all contacts in the unified graph")
.option("--query <text>", "Search by name or alias")
.option("--platform <name>", "Filter by platform (channel id)")
.option("--limit <n>", "Limit results", "50")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 50;
const platform = opts.platform
? ((opts.platform as string).toLowerCase() as Platform)
: undefined;
const contactsList = store.listContacts({
query: opts.query as string | undefined,
platform,
limit,
});
const contactsWithIdentities = contactsList
.map((c) => store.getContactWithIdentities(c.canonicalId))
.filter((c): c is NonNullable<typeof c> => c !== null);
if (opts.json) {
cli.log(JSON.stringify(contactsWithIdentities, null, 2));
return;
}
if (contactsWithIdentities.length === 0) {
cli.log(theme.muted("No contacts found."));
return;
}
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
`${theme.heading("Contacts")} ${theme.muted(`(${contactsWithIdentities.length})`)}`,
);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 20, flex: true },
{ key: "Name", header: "Name", minWidth: 16, flex: true },
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
{ key: "Identities", header: "#", minWidth: 4 },
],
rows: contactsWithIdentities.map(formatContactRow),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts show
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("show")
.description("Show details for a specific contact")
.argument("<id>", "Contact canonical ID or search query")
.option("--json", "Output JSON", false)
.action(async (id: string, opts) => {
try {
const store = getContactStore();
// Try exact match first
let contact = store.getContactWithIdentities(id);
// If not found, search
if (!contact) {
const matches = store.searchContacts(id, 1);
contact = matches[0] ?? null;
}
if (!contact) {
cli.error(formatDanger(`Contact not found: ${id}`));
cli.exit(1);
return;
}
if (opts.json) {
cli.log(JSON.stringify(contact, null, 2));
return;
}
cli.log(`${theme.heading("Contact")}`);
cli.log(` ID: ${contact.canonicalId}`);
cli.log(` Name: ${contact.displayName}`);
if (contact.aliases.length > 0) {
cli.log(` Aliases: ${contact.aliases.join(", ")}`);
}
cli.log("");
cli.log(
`${theme.heading("Platform Identities")} (${contact.identities.length})`,
);
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Platform", header: "Platform", minWidth: 10 },
{ key: "ID", header: "Platform ID", minWidth: 20, flex: true },
{ key: "Username", header: "Username", minWidth: 12, flex: true },
{ key: "Phone", header: "Phone", minWidth: 14 },
],
rows: contact.identities.map((i) => ({
Platform: i.platform,
ID: i.platformId,
Username: i.username ?? "",
Phone: i.phone ?? "",
})),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts search
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("search")
.description("Search contacts by name, alias, or username")
.argument("<query>", "Search query")
.option("--limit <n>", "Limit results", "10")
.option("--json", "Output JSON", false)
.action(async (query: string, opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 10;
const results = store.searchContacts(query, limit);
if (opts.json) {
cli.log(JSON.stringify(results, null, 2));
return;
}
if (results.length === 0) {
cli.log(theme.muted(`No contacts found matching "${query}".`));
return;
}
const tableWidth = Math.max(80, (process.stdout.columns ?? 120) - 1);
cli.log(
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 20, flex: true },
{ key: "Name", header: "Name", minWidth: 16, flex: true },
{ key: "Platforms", header: "Platforms", minWidth: 20, flex: true },
{ key: "Identities", header: "#", minWidth: 4 },
],
rows: results.map(formatContactRow),
}).trimEnd(),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts link
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("link")
.description("Link two contacts (merge into one)")
.argument("<primary>", "Primary contact ID (will keep this one)")
.argument("<secondary>", "Secondary contact ID (will be merged and deleted)")
.action(async (primary: string, secondary: string) => {
try {
const store = getContactStore();
const result = linkContacts(store, primary, secondary);
if (!result.success) {
cli.error(formatDanger(result.error ?? "Failed to link contacts"));
cli.exit(1);
return;
}
cli.log(formatSuccess(`Linked: ${secondary} merged into ${primary}`));
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts unlink
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("unlink")
.description("Unlink a platform identity from its contact (creates a new contact)")
.argument("<platform>", "Platform (channel id)")
.argument("<platformId>", "Platform-specific user ID")
.action(async (platform: string, platformId: string) => {
try {
const store = getContactStore();
const result = unlinkIdentity(store, platform, platformId);
if (!result.success) {
cli.error(formatDanger(result.error ?? "Failed to unlink identity"));
cli.exit(1);
return;
}
cli.log(
formatSuccess(
`Unlinked: ${platform}:${platformId} → new contact ${result.newContactId}`,
),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts suggestions
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("suggestions")
.description("Show link suggestions (contacts that may be the same person)")
.option("--min-score <n>", "Minimum name similarity score (0-1)", "0.85")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const minScore = parseFloat(opts.minScore as string) || 0.85;
const suggestions = findLinkSuggestions(store, { minNameScore: minScore });
if (opts.json) {
cli.log(JSON.stringify(suggestions, null, 2));
return;
}
if (suggestions.length === 0) {
cli.log(theme.muted("No link suggestions found."));
return;
}
cli.log(
`${theme.heading("Link Suggestions")} ${theme.muted(`(${suggestions.length})`)}`,
);
const tableWidth = Math.max(100, (process.stdout.columns ?? 120) - 1);
cli.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Source", header: "Source", minWidth: 20, flex: true },
{ key: "Target", header: "Target", minWidth: 20, flex: true },
{ key: "Reason", header: "Reason", minWidth: 14 },
{ key: "Confidence", header: "Confidence", minWidth: 10 },
{ key: "Score", header: "Score", minWidth: 6 },
],
rows: suggestions.map((s) => ({
Source: `${s.sourceIdentity.platform}:${s.sourceIdentity.displayName || s.sourceIdentity.platformId}`,
Target: `${s.targetIdentity.platform}:${s.targetIdentity.displayName || s.targetIdentity.platformId}`,
Reason: s.reason,
Confidence: s.confidence,
Score: s.score.toFixed(2),
})),
}).trimEnd(),
);
cli.log("");
cli.log(
theme.muted("To link: clawdbot contacts link <source-contact-id> <target-contact-id>"),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts auto-link
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("auto-link")
.description("Automatically link high-confidence matches (e.g., same phone number)")
.option("--dry-run", "Show what would be linked without making changes", false)
.action(async (opts) => {
try {
const store = getContactStore();
if (opts.dryRun) {
const suggestions = findLinkSuggestions(store);
const highConfidence = suggestions.filter((s) => s.confidence === "high");
if (highConfidence.length === 0) {
cli.log(theme.muted("No high-confidence matches found."));
return;
}
cli.log(
`${theme.heading("Would auto-link")} ${theme.muted(`(${highConfidence.length})`)}`,
);
for (const s of highConfidence) {
cli.log(
` ${s.sourceIdentity.contactId} + ${s.targetIdentity.contactId} (${s.reason})`,
);
}
return;
}
const result = autoLinkHighConfidence(store);
if (result.linked === 0) {
cli.log(theme.muted("No high-confidence matches found to auto-link."));
return;
}
cli.log(formatSuccess(`Auto-linked ${result.linked} contact(s)`));
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts stats
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("stats")
.description("Show contact store statistics")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
const store = getContactStore();
const stats = store.getStats();
if (opts.json) {
cli.log(JSON.stringify(stats, null, 2));
return;
}
cli.log(`${theme.heading("Contact Store Statistics")}`);
cli.log(` Contacts: ${stats.contacts}`);
cli.log(` Identities: ${stats.identities}`);
cli.log(` Indexed Messages: ${stats.messages}`);
cli.log("");
cli.log(`${theme.heading("Identities by Platform")}`);
for (const [platform, count] of Object.entries(stats.platforms)) {
cli.log(` ${platform}: ${count}`);
}
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
// ─────────────────────────────────────────────────────────────────────────────
// contacts alias
// ─────────────────────────────────────────────────────────────────────────────
contacts
.command("alias")
.description("Add or remove an alias for a contact")
.argument("<contactId>", "Contact ID")
.argument("<alias>", "Alias to add")
.option("--remove", "Remove the alias instead of adding", false)
.action(async (contactId: string, alias: string, opts) => {
try {
const store = getContactStore();
const contact = store.getContact(contactId);
if (!contact) {
cli.error(formatDanger(`Contact not found: ${contactId}`));
cli.exit(1);
return;
}
const currentAliases = contact.aliases;
let newAliases: string[];
if (opts.remove) {
newAliases = currentAliases.filter((a) => a !== alias);
if (newAliases.length === currentAliases.length) {
cli.log(theme.muted(`Alias "${alias}" not found on this contact.`));
return;
}
} else {
if (currentAliases.includes(alias)) {
cli.log(theme.muted(`Alias "${alias}" already exists on this contact.`));
return;
}
newAliases = [...currentAliases, alias];
}
store.updateContact(contactId, { aliases: newAliases });
cli.log(
formatSuccess(
opts.remove ? `Removed alias "${alias}"` : `Added alias "${alias}"`,
),
);
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
}
@@ -0,0 +1,96 @@
type TableColumn = {
key: string;
header: string;
minWidth?: number;
flex?: boolean;
};
type TableRow = Record<string, string | number | null | undefined>;
type TableOptions = {
columns: TableColumn[];
rows: TableRow[];
width?: number;
};
const pad = (value: string, width: number) => value.padEnd(width);
const truncate = (value: string, width: number) => {
if (value.length <= width) return pad(value, width);
if (width <= 3) return value.slice(0, width);
return value.slice(0, width - 3) + "...";
};
export const theme = {
heading: (value: string) => value,
muted: (value: string) => value,
accent: (value: string) => value,
accentBright: (value: string) => value,
};
export const cli = {
log: (message: string) => {
// eslint-disable-next-line no-console
console.log(message);
},
error: (message: string) => {
// eslint-disable-next-line no-console
console.error(message);
},
exit: (code: number) => {
process.exit(code);
},
};
export const formatSuccess = (message: string) => message;
export const formatDanger = (message: string) => message;
export function renderTable({ columns, rows, width }: TableOptions): string {
const widths = columns.map((column) => {
const headerWidth = column.header.length;
const minWidth = column.minWidth ?? 0;
const maxRowWidth = rows.reduce((max, row) => {
const value = String(row[column.key] ?? "");
return Math.max(max, value.length);
}, 0);
return Math.max(minWidth, headerWidth, maxRowWidth);
});
if (width) {
const baseWidth = widths.reduce((sum, colWidth) => sum + colWidth, 0);
const totalWidth = baseWidth + (columns.length - 1) * 2;
if (totalWidth > width) {
const flexColumns = columns
.map((column, index) => (column.flex ? index : -1))
.filter((index) => index >= 0);
if (flexColumns.length > 0) {
const excess = totalWidth - width;
const shrinkEach = Math.ceil(excess / flexColumns.length);
for (const index of flexColumns) {
const minWidth = columns[index]!.minWidth ?? 4;
widths[index] = Math.max(minWidth, widths[index]! - shrinkEach);
}
}
}
}
const header = columns
.map((column, index) => truncate(column.header, widths[index]!))
.join(" ");
const separator = columns
.map((_, index) => "-".repeat(widths[index]!))
.join(" ");
const lines = [header, separator];
for (const row of rows) {
lines.push(
columns
.map((column, index) =>
truncate(String(row[column.key] ?? ""), widths[index]!),
)
.join(" "),
);
}
return lines.join("\n");
}
@@ -0,0 +1,161 @@
import type { Command } from "commander";
import { formatDocsLink } from "clawdbot/plugin-sdk";
import { getContactStore } from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
import { cli, formatDanger, theme } from "./formatting.js";
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: "short" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
function parseTimestamp(value: string): number | null {
if (!value) return null;
// Handle relative times like "1h", "2d", "1w"
const relativeMatch = value.match(/^(\d+)([hdwm])$/i);
if (relativeMatch) {
const amount = parseInt(relativeMatch[1]!, 10);
const unit = relativeMatch[2]!.toLowerCase();
const now = Date.now();
switch (unit) {
case "h":
return now - amount * 60 * 60 * 1000;
case "d":
return now - amount * 24 * 60 * 60 * 1000;
case "w":
return now - amount * 7 * 24 * 60 * 60 * 1000;
case "m":
return now - amount * 30 * 24 * 60 * 60 * 1000;
}
}
// Handle ISO date strings
const parsed = Date.parse(value);
if (!isNaN(parsed)) {
return parsed;
}
return null;
}
export function registerSearchCli(program: Command) {
program
.command("search")
.description("Search messages across all messaging platforms")
.argument("<query>", "Search query")
.option("--from <contact>", "Filter by sender (contact name, username, or ID)")
.option(
"--platform <name>",
"Filter by platform (channel id)",
)
.option("--since <time>", "Filter messages after this time (e.g., 1h, 2d, 1w, or ISO date)")
.option("--until <time>", "Filter messages before this time")
.option("--limit <n>", "Limit results", "20")
.option("--json", "Output JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.muted("Examples:")}\n` +
` clawdbot search "meeting tomorrow"\n` +
` clawdbot search "deadline" --from alice\n` +
` clawdbot search "project" --platform slack --since 1w\n` +
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/search", "docs.clawd.bot/cli/search")}\n`,
)
.action(async (query: string, opts) => {
try {
const store = getContactStore();
const limit = parseInt(opts.limit as string, 10) || 20;
// Parse platforms
let platforms: Platform[] | undefined;
if (opts.platform) {
const platform = (opts.platform as string).toLowerCase() as Platform;
platforms = [platform];
}
// Parse timestamps
const since = opts.since ? parseTimestamp(opts.since as string) : undefined;
const until = opts.until ? parseTimestamp(opts.until as string) : undefined;
if (opts.since && since === null) {
cli.error(formatDanger(`Invalid --since value: ${opts.since}`));
cli.exit(1);
return;
}
if (opts.until && until === null) {
cli.error(formatDanger(`Invalid --until value: ${opts.until}`));
cli.exit(1);
return;
}
const results = store.searchMessages({
query,
from: opts.from as string | undefined,
platforms,
since: since ?? undefined,
until: until ?? undefined,
limit,
});
if (opts.json) {
cli.log(JSON.stringify(results, null, 2));
return;
}
if (results.length === 0) {
cli.log(theme.muted(`No messages found matching "${query}".`));
// Helpful hints
if (opts.from) {
const contactMatches = store.searchContacts(opts.from as string, 5);
if (contactMatches.length === 0) {
cli.log(theme.muted(`Note: No contacts found matching "${opts.from}".`));
}
}
return;
}
cli.log(
`${theme.heading("Search Results")} ${theme.muted(`(${results.length})`)}`,
);
cli.log("");
for (const result of results) {
const { message, contact, snippet } = result;
const senderName = contact?.displayName ?? message.senderId;
const time = formatTimestamp(message.timestamp);
cli.log(
`${theme.accent(`[${message.platform}]`)} ${theme.accentBright(senderName)} ${theme.muted(`- ${time}`)}`,
);
cli.log(` ${snippet}`);
cli.log("");
}
if (results.length === limit) {
cli.log(
theme.muted(`Showing first ${limit} results. Use --limit to see more.`),
);
}
} catch (err) {
cli.error(formatDanger(String(err)));
cli.exit(1);
}
});
}
@@ -0,0 +1,105 @@
import type { Platform } from "../contacts/types.js";
/**
* Parse relative time strings like "1h", "2d", "1w"
*/
function parseRelativeTime(value: string): number | null {
const match = value.match(/^(\d+)([hdwm])$/i);
if (!match) return null;
const amount = parseInt(match[1]!, 10);
const unit = match[2]!.toLowerCase();
const now = Date.now();
switch (unit) {
case "h":
return now - amount * 60 * 60 * 1000;
case "d":
return now - amount * 24 * 60 * 60 * 1000;
case "w":
return now - amount * 7 * 24 * 60 * 60 * 1000;
case "m":
return now - amount * 30 * 24 * 60 * 60 * 1000;
default:
return null;
}
}
/**
* Parse search command arguments.
* Format: /search <query> [--from <contact>] [--platform <name>] [--since <time>]
*/
function tokenizeArgs(input: string): string[] {
const tokens: string[] = [];
const pattern = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g;
for (const match of input.matchAll(pattern)) {
const raw = match[1] ?? match[2] ?? match[0];
tokens.push(raw.replace(/\\(["'\\])/g, "$1"));
}
return tokens;
}
export function parseSearchArgs(commandBody: string): {
query: string;
from?: string;
platform?: Platform;
since?: number;
error?: string;
} {
const argsStr = commandBody.replace(/^\/search\s*/i, "").trim();
if (!argsStr) {
return {
query: "",
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
};
}
let query = "";
let from: string | undefined;
let platform: Platform | undefined;
let since: number | undefined;
const parts = tokenizeArgs(argsStr);
const queryParts: string[] = [];
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (part === "--from" && i + 1 < parts.length) {
const fromParts: string[] = [];
while (i + 1 < parts.length && !parts[i + 1]!.startsWith("--")) {
fromParts.push(parts[++i]!);
}
if (fromParts.length === 0) {
return { query: "", error: "Missing value for --from" };
}
from = fromParts.join(" ");
} else if (part === "--platform" && i + 1 < parts.length) {
platform = parts[++i]!.toLowerCase() as Platform;
} else if (part === "--since" && i + 1 < parts.length) {
const timeStr = parts[++i]!;
const parsed = parseRelativeTime(timeStr);
if (parsed === null) {
return {
query: "",
error: `Invalid --since value: ${timeStr}. Use format like 1h, 2d, 1w, 1m`,
};
}
since = parsed;
} else if (part.startsWith("--")) {
return { query: "", error: `Unknown option: ${part}` };
} else {
queryParts.push(part);
}
}
query = queryParts.join(" ");
if (!query) {
return {
query: "",
error: "Usage: /search <query> [--from <contact>] [--platform <name>] [--since <time>]",
};
}
return { query, from, platform, since };
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { parseSearchArgs } from "./search-args.js";
describe("parseSearchArgs", () => {
it("handles multi-word --from without quotes", () => {
const parsed = parseSearchArgs('/search budget --from Sarah Smith');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("budget");
expect(parsed.from).toBe("Sarah Smith");
});
it("handles quoted multi-word --from", () => {
const parsed = parseSearchArgs('/search budget --from "Sarah Smith" --since 1w');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("budget");
expect(parsed.from).toBe("Sarah Smith");
expect(parsed.since).toBeTypeOf("number");
});
it("keeps multi-word query alongside --from", () => {
const parsed = parseSearchArgs('/search quarterly report --from Sarah Smith');
expect(parsed.error).toBeUndefined();
expect(parsed.query).toBe("quarterly report");
expect(parsed.from).toBe("Sarah Smith");
});
});
@@ -0,0 +1,74 @@
import { getContactStore } from "../contacts/index.js";
import { parseSearchArgs } from "./search-args.js";
/**
* Format a timestamp for display
*/
function formatTimestamp(ts: number): string {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return date.toLocaleDateString([], { weekday: "short" });
}
return date.toLocaleDateString([], { month: "short", day: "numeric" });
}
export function runSearchCommand(commandBody: string): string {
const parsed = parseSearchArgs(commandBody);
if (parsed.error) {
return `❌ ${parsed.error}`;
}
try {
const store = getContactStore();
const results = store.searchMessages({
query: parsed.query,
from: parsed.from,
platforms: parsed.platform ? [parsed.platform] : undefined,
since: parsed.since,
limit: 10,
});
if (results.length === 0) {
let msg = `🔍 No messages found matching "${parsed.query}"`;
if (parsed.from) {
const contactMatches = store.searchContacts(parsed.from, 5);
if (contactMatches.length === 0) {
msg += `\n\n⚠️ Note: No contacts found matching "${parsed.from}"`;
}
}
return msg;
}
const lines = [`🔍 Search Results (${results.length})\n`];
for (const result of results) {
const { message, contact, snippet } = result;
const senderName = contact?.displayName ?? message.senderId;
const time = formatTimestamp(message.timestamp);
const platformLabel = message.platform.toUpperCase();
lines.push(`[${platformLabel}] ${senderName} - ${time}`);
lines.push(` ${snippet}`);
lines.push("");
}
if (results.length === 10) {
lines.push('Use the CLI for more results: clawdbot search "' + parsed.query + '" --limit 50');
}
return lines.join("\n").trim();
} catch (err) {
return `❌ Search error: ${err instanceof Error ? err.message : String(err)}`;
}
}
@@ -0,0 +1,407 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import {
extractDiscordContact,
extractIMessageContact,
extractSignalContact,
extractSlackContact,
extractTelegramContact,
extractWhatsAppContact,
importContactFromMessage,
importDiscordGuildMembers,
importSlackUsers,
importWhatsAppGroupParticipants,
isJidGroup,
jidToE164,
} from "./importer.js";
describe("jidToE164", () => {
it("extracts phone from standard JID", () => {
expect(jidToE164("14155551234@s.whatsapp.net")).toBe("+14155551234");
});
it("extracts phone from JID with device suffix", () => {
expect(jidToE164("14155551234:0@s.whatsapp.net")).toBe("+14155551234");
});
it("returns null for invalid JID", () => {
expect(jidToE164("")).toBeNull();
expect(jidToE164("invalid")).toBeNull();
expect(jidToE164("abc@s.whatsapp.net")).toBeNull();
});
it("returns null for group JID", () => {
// Group JIDs don't have phone numbers
expect(jidToE164("123456789-1234567890@g.us")).toBeNull();
});
});
describe("isJidGroup", () => {
it("returns true for group JIDs", () => {
expect(isJidGroup("123456789-1234567890@g.us")).toBe(true);
expect(isJidGroup("status@broadcast")).toBe(true);
});
it("returns false for user JIDs", () => {
expect(isJidGroup("14155551234@s.whatsapp.net")).toBe(false);
});
});
describe("extractWhatsAppContact", () => {
it("extracts contact data from sender JID", () => {
const data = extractWhatsAppContact({
senderJid: "14155551234@s.whatsapp.net",
pushName: "John Doe",
});
expect(data).toEqual({
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("returns null for group JID", () => {
const data = extractWhatsAppContact({
senderJid: "123-456@g.us",
pushName: "Group Name",
});
expect(data).toBeNull();
});
it("handles missing push name", () => {
const data = extractWhatsAppContact({
senderJid: "14155551234@s.whatsapp.net",
});
expect(data?.displayName).toBeNull();
});
});
describe("extractTelegramContact", () => {
it("extracts contact data from user info", () => {
const data = extractTelegramContact({
userId: 123456789,
username: "johndoe",
firstName: "John",
lastName: "Doe",
});
expect(data).toEqual({
platform: "telegram",
platformId: "123456789",
username: "johndoe",
phone: null,
displayName: "John Doe",
});
});
it("handles missing fields", () => {
const data = extractTelegramContact({
userId: 123456789,
});
expect(data?.username).toBeNull();
expect(data?.displayName).toBeNull();
});
it("handles first name only", () => {
const data = extractTelegramContact({
userId: 123456789,
firstName: "John",
});
expect(data?.displayName).toBe("John");
});
});
describe("extractDiscordContact", () => {
it("extracts contact data from user info", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
globalName: "John Doe",
nick: "Johnny",
});
expect(data).toEqual({
platform: "discord",
platformId: "123456789012345678",
username: "johndoe",
phone: null,
displayName: "Johnny", // Nick takes precedence
});
});
it("falls back to globalName when no nick", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
globalName: "John Doe",
});
expect(data?.displayName).toBe("John Doe");
});
it("falls back to username when no globalName", () => {
const data = extractDiscordContact({
userId: "123456789012345678",
username: "johndoe",
});
expect(data?.displayName).toBe("johndoe");
});
});
describe("extractSlackContact", () => {
it("extracts contact data from user info", () => {
const data = extractSlackContact({
userId: "U12345678",
username: "john.doe",
displayName: "John Doe",
realName: "John Michael Doe",
});
expect(data).toEqual({
platform: "slack",
platformId: "U12345678",
username: "john.doe",
phone: null,
displayName: "John Doe", // displayName takes precedence
});
});
it("falls back to realName when no displayName", () => {
const data = extractSlackContact({
userId: "U12345678",
username: "john.doe",
realName: "John Doe",
});
expect(data?.displayName).toBe("John Doe");
});
});
describe("extractSignalContact", () => {
it("extracts contact data from signal envelope", () => {
const data = extractSignalContact({
sourceNumber: "+14155551234",
sourceUuid: "uuid-123-456",
sourceName: "John Doe",
});
expect(data).toEqual({
platform: "signal",
platformId: "uuid-123-456", // UUID preferred
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("uses phone as platformId when no UUID", () => {
const data = extractSignalContact({
sourceNumber: "+14155551234",
sourceName: "John Doe",
});
expect(data?.platformId).toBe("+14155551234");
});
it("returns null when no identifier", () => {
const data = extractSignalContact({
sourceName: "John Doe",
});
expect(data).toBeNull();
});
});
describe("extractIMessageContact", () => {
it("extracts contact from phone number", () => {
const data = extractIMessageContact({
senderId: "+14155551234",
senderName: "John Doe",
});
expect(data).toEqual({
platform: "imessage",
platformId: "+14155551234",
username: null,
phone: "+14155551234",
displayName: "John Doe",
});
});
it("extracts contact from email", () => {
const data = extractIMessageContact({
senderId: "john@example.com",
senderName: "John Doe",
});
expect(data).toEqual({
platform: "imessage",
platformId: "john@example.com",
username: null,
phone: null, // Email is not a phone
displayName: "John Doe",
});
});
});
describe("importContactFromMessage", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
it("creates new contact for unknown sender", () => {
const { contactId, isNew } = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(isNew).toBe(true);
expect(contactId).toMatch(/^john-doe-[a-f0-9]{8}$/);
const contact = store.getContactWithIdentities(contactId);
expect(contact?.displayName).toBe("John Doe");
expect(contact?.identities.length).toBe(1);
expect(contact?.identities[0]?.platform).toBe("telegram");
});
it("returns existing contact for known sender", () => {
// First import
const first = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(first.isNew).toBe(true);
// Second import of same sender
const second = importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
expect(second.isNew).toBe(false);
expect(second.contactId).toBe(first.contactId);
});
it("updates identity metadata for known sender", () => {
importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johndoe",
displayName: "John Doe",
phone: null,
});
importContactFromMessage(store, {
platform: "telegram",
platformId: "123456789",
username: "johnny",
displayName: "John D",
phone: "+14155551234",
});
const identity = store.getIdentityByPlatformId("telegram", "123456789");
expect(identity?.username).toBe("johnny");
expect(identity?.displayName).toBe("John D");
expect(identity?.phone).toBe("+14155551234");
});
it("uses platformId as displayName fallback", () => {
const { contactId } = importContactFromMessage(store, {
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
displayName: null,
phone: "+14155551234",
});
const contact = store.getContact(contactId);
expect(contact?.displayName).toBe("14155551234@s.whatsapp.net");
});
});
describe("bulk importers", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("importSlackUsers", () => {
it("imports users from Slack API response", async () => {
const mockUsers = [
{ id: "U1", name: "alice", displayName: "Alice", isBot: false },
{ id: "U2", name: "bob", realName: "Bob Smith", isBot: false },
{ id: "U3", name: "bot", displayName: "Bot", isBot: true }, // Should be skipped
{ id: "U4", name: "deleted", displayName: "Deleted", deleted: true }, // Should be skipped
];
const result = await importSlackUsers(store, async () => mockUsers);
expect(result.platform).toBe("slack");
expect(result.imported).toBe(2); // Only Alice and Bob
expect(result.errors.length).toBe(0);
const contacts = store.listContacts();
expect(contacts.length).toBe(2);
});
it("handles API errors gracefully", async () => {
const result = await importSlackUsers(store, async () => {
throw new Error("API error");
});
expect(result.imported).toBe(0);
expect(result.errors.length).toBe(1);
expect(result.errors[0]).toContain("Failed to list Slack users");
});
});
describe("importDiscordGuildMembers", () => {
it("imports members from Discord API response", async () => {
const mockMembers = [
{ user: { id: "1", username: "alice", global_name: "Alice" }, nick: null },
{ user: { id: "2", username: "bob", global_name: "Bob" }, nick: "Bobby" },
{ user: { id: "3", username: "botuser", bot: true } }, // Should be skipped
];
const result = await importDiscordGuildMembers(store, async () => mockMembers);
expect(result.platform).toBe("discord");
expect(result.imported).toBe(2);
expect(result.errors.length).toBe(0);
const contacts = store.listContacts();
expect(contacts.length).toBe(2);
});
});
describe("importWhatsAppGroupParticipants", () => {
it("imports participants from group metadata", async () => {
const mockGetMetadata = async (_jid: string) => ({
subject: "Test Group",
participants: [
{ id: "14155551111@s.whatsapp.net" },
{ id: "14155552222@s.whatsapp.net" },
{ id: "123-456@g.us" }, // Group JID should be skipped
],
});
const result = await importWhatsAppGroupParticipants(store, mockGetMetadata, "123-456@g.us");
expect(result.platform).toBe("whatsapp");
expect(result.imported).toBe(2);
expect(result.errors.length).toBe(0);
});
});
});
@@ -0,0 +1,393 @@
/**
* Contact importers for each messaging platform.
*
* Since many platforms don't have bulk contact APIs, importers use a combination of:
* 1. Direct API calls where available (Slack users.list, Discord members)
* 2. Message-based discovery (observing incoming messages)
* 3. Group metadata extraction
*/
import type { ContactStore } from "./store.js";
import type { ImportResult, Platform, PlatformIdentityInput } from "./types.js";
/**
* Base interface for platform-specific contact importers.
*/
export type ContactImporter = {
/** Platform this importer handles */
platform: Platform;
/** Import contacts from this platform */
import(store: ContactStore): Promise<ImportResult>;
};
/**
* Extract E.164 phone number from WhatsApp JID.
* JID format: "1234567890@s.whatsapp.net" or "1234567890:0@s.whatsapp.net"
*/
export function jidToE164(jid: string): string | null {
if (!jid) return null;
// Remove suffix
const numberPart = jid.split("@")[0];
if (!numberPart) return null;
// Handle device suffix (e.g., "1234567890:0")
const phone = numberPart.split(":")[0];
if (!phone || !/^\d{7,15}$/.test(phone)) return null;
return `+${phone}`;
}
/**
* Check if a JID is a group.
*/
export function isJidGroup(jid: string): boolean {
return jid.includes("@g.us") || jid.includes("@broadcast");
}
/**
* Data extracted from an incoming message for contact discovery.
*/
export type MessageContactData = {
platform: Platform;
platformId: string;
username?: string | null;
phone?: string | null;
displayName?: string | null;
};
/**
* Import a contact from message data.
* Creates a new contact if the platform identity doesn't exist,
* or updates the existing contact's metadata.
*/
export function importContactFromMessage(
store: ContactStore,
data: MessageContactData,
): { contactId: string; isNew: boolean } {
// Check if identity already exists
const existing = store.getIdentityByPlatformId(data.platform, data.platformId);
if (existing) {
const updated = {
contactId: existing.contactId,
platform: existing.platform,
platformId: existing.platformId,
username: data.username ?? existing.username,
phone: data.phone ?? existing.phone,
displayName: data.displayName ?? existing.displayName,
lastSeenAt: Date.now(),
} satisfies PlatformIdentityInput;
store.addIdentity(updated);
return { contactId: existing.contactId, isNew: false };
}
// Create new contact and identity
const displayName = data.displayName || data.username || data.platformId;
const contact = store.createContact(displayName);
const input: PlatformIdentityInput = {
contactId: contact.canonicalId,
platform: data.platform,
platformId: data.platformId,
username: data.username ?? null,
phone: data.phone ?? null,
displayName: data.displayName ?? null,
lastSeenAt: Date.now(),
};
store.addIdentity(input);
return { contactId: contact.canonicalId, isNew: true };
}
/**
* WhatsApp contact data extraction from message.
*/
export function extractWhatsAppContact(params: {
senderJid: string;
pushName?: string | null;
}): MessageContactData | null {
const { senderJid, pushName } = params;
if (!senderJid || isJidGroup(senderJid)) return null;
const phone = jidToE164(senderJid);
return {
platform: "whatsapp",
platformId: senderJid,
username: null,
phone,
displayName: pushName ?? null,
};
}
/**
* Telegram contact data extraction from message.
*/
export function extractTelegramContact(params: {
userId: number | string;
username?: string | null;
firstName?: string | null;
lastName?: string | null;
}): MessageContactData | null {
const { userId, username, firstName, lastName } = params;
if (!userId) return null;
const displayName = [firstName, lastName].filter(Boolean).join(" ") || null;
return {
platform: "telegram",
platformId: String(userId),
username: username ?? null,
phone: null,
displayName,
};
}
/**
* Discord contact data extraction from message.
*/
export function extractDiscordContact(params: {
userId: string;
username?: string | null;
globalName?: string | null;
nick?: string | null;
}): MessageContactData | null {
const { userId, username, globalName, nick } = params;
if (!userId) return null;
// Prefer display names: nick > globalName > username
const displayName = nick || globalName || username || null;
return {
platform: "discord",
platformId: userId,
username: username ?? null,
phone: null,
displayName,
};
}
/**
* Slack contact data extraction from message.
*/
export function extractSlackContact(params: {
userId: string;
username?: string | null;
displayName?: string | null;
realName?: string | null;
}): MessageContactData | null {
const { userId, username, displayName, realName } = params;
if (!userId) return null;
return {
platform: "slack",
platformId: userId,
username: username ?? null,
phone: null,
displayName: displayName || realName || null,
};
}
/**
* Signal contact data extraction from message.
*/
export function extractSignalContact(params: {
sourceNumber?: string | null;
sourceUuid?: string | null;
sourceName?: string | null;
}): MessageContactData | null {
const { sourceNumber, sourceUuid, sourceName } = params;
// Prefer UUID as platformId, fall back to phone
const platformId = sourceUuid || sourceNumber;
if (!platformId) return null;
return {
platform: "signal",
platformId,
username: null,
phone: sourceNumber ?? null,
displayName: sourceName ?? null,
};
}
/**
* iMessage contact data extraction from message.
*/
export function extractIMessageContact(params: {
senderId: string;
senderName?: string | null;
}): MessageContactData | null {
const { senderId, senderName } = params;
if (!senderId) return null;
// iMessage senderId can be phone or email
const isPhone = /^\+?\d{10,}$/.test(senderId.replace(/\D/g, ""));
return {
platform: "imessage",
platformId: senderId,
username: null,
phone: isPhone ? senderId : null,
displayName: senderName ?? null,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// BULK IMPORTERS (for platforms with bulk APIs)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Slack bulk importer using users.list API.
*/
export async function importSlackUsers(
store: ContactStore,
listUsers: () => Promise<
Array<{
id: string;
name?: string;
displayName?: string;
realName?: string;
email?: string;
isBot?: boolean;
deleted?: boolean;
}>
>,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "slack",
imported: 0,
linked: 0,
errors: [],
};
try {
const users = await listUsers();
for (const user of users) {
// Skip bots and deleted users
if (user.isBot || user.deleted) continue;
if (!user.id) continue;
try {
const data = extractSlackContact({
userId: user.id,
username: user.name,
displayName: user.displayName,
realName: user.realName,
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import user ${user.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Slack users: ${String(err)}`);
}
return result;
}
/**
* Discord bulk importer using guild member search.
*/
export async function importDiscordGuildMembers(
store: ContactStore,
listMembers: () => Promise<
Array<{
user: {
id: string;
username?: string;
global_name?: string;
bot?: boolean;
};
nick?: string | null;
}>
>,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "discord",
imported: 0,
linked: 0,
errors: [],
};
try {
const members = await listMembers();
for (const member of members) {
// Skip bots
if (member.user.bot) continue;
if (!member.user.id) continue;
try {
const data = extractDiscordContact({
userId: member.user.id,
username: member.user.username,
globalName: member.user.global_name,
nick: member.nick,
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import member ${member.user.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to list Discord members: ${String(err)}`);
}
return result;
}
/**
* WhatsApp group participants importer.
*/
export async function importWhatsAppGroupParticipants(
store: ContactStore,
getGroupMetadata: (groupJid: string) => Promise<{
subject?: string;
participants?: Array<{ id: string }>;
}>,
groupJid: string,
): Promise<ImportResult> {
const result: ImportResult = {
platform: "whatsapp",
imported: 0,
linked: 0,
errors: [],
};
try {
const meta = await getGroupMetadata(groupJid);
const participants = meta.participants ?? [];
for (const participant of participants) {
if (!participant.id) continue;
if (isJidGroup(participant.id)) continue;
try {
const data = extractWhatsAppContact({
senderJid: participant.id,
pushName: null, // Group metadata doesn't include push names
});
if (data) {
const { isNew } = importContactFromMessage(store, data);
if (isNew) result.imported++;
}
} catch (err) {
result.errors.push(`Failed to import participant ${participant.id}: ${String(err)}`);
}
}
} catch (err) {
result.errors.push(`Failed to get group metadata for ${groupJid}: ${String(err)}`);
}
return result;
}
@@ -0,0 +1,49 @@
/**
* Contacts module - Unified contact graph for cross-platform identity resolution.
*
* This module provides:
* - Canonical contact management (create, link, search)
* - Platform identity linking (WhatsApp, Telegram, Discord, Slack, Signal, etc.)
* - Message indexing for cross-platform search
* - Auto-linking heuristics based on phone/email/name matching
*/
export { ContactStore, configureContactStore, getContactStore, closeContactStore } from "./store.js";
export { ensureContactStoreSchema, dropContactStoreTables } from "./schema.js";
export {
importContactFromMessage,
extractWhatsAppContact,
extractTelegramContact,
extractDiscordContact,
extractSlackContact,
extractSignalContact,
extractIMessageContact,
importSlackUsers,
importDiscordGuildMembers,
importWhatsAppGroupParticipants,
jidToE164,
isJidGroup,
} from "./importer.js";
export type {
Contact,
ContactSearchOptions,
ContactWithIdentities,
ImportResult,
IndexedMessage,
LinkConfidence,
LinkSuggestion,
MessageSearchOptions,
MessageSearchResult,
Platform,
PlatformIdentity,
PlatformIdentityInput,
} from "./types.js";
export type { ContactImporter, MessageContactData } from "./importer.js";
export {
findPhoneMatches,
findNameMatches,
findLinkSuggestions,
linkContacts,
unlinkIdentity,
autoLinkHighConfidence,
} from "./linker.js";
@@ -0,0 +1,600 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import {
autoLinkHighConfidence,
findLinkSuggestions,
findNameMatches,
findPhoneMatches,
linkContacts,
unlinkIdentity,
} from "./linker.js";
describe("linker", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("findPhoneMatches", () => {
it("finds contacts with same phone number", () => {
// Create two contacts with same phone on different platforms
const contact1 = store.createContact("John on WhatsApp");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "John",
lastSeenAt: null,
});
const contact2 = store.createContact("John on Signal");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "uuid-john",
username: null,
phone: "+14155551234",
displayName: "John D",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(1);
expect(suggestions[0]?.reason).toBe("phone_match");
expect(suggestions[0]?.confidence).toBe("high");
expect(suggestions[0]?.score).toBe(1.0);
});
it("does not suggest already-linked contacts", () => {
const contact = store.createContact("John");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-john",
username: null,
phone: "+14155551234",
displayName: "John WA",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "signal",
platformId: "signal-john",
username: null,
phone: "+14155551234",
displayName: "John Signal",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(0);
});
it("returns empty for no phone matches", () => {
const contact1 = store.createContact("Alice");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-alice",
username: null,
phone: "+14155551111",
displayName: "Alice",
lastSeenAt: null,
});
const contact2 = store.createContact("Bob");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "whatsapp",
platformId: "wa-bob",
username: null,
phone: "+14155552222",
displayName: "Bob",
lastSeenAt: null,
});
const suggestions = findPhoneMatches(store);
expect(suggestions.length).toBe(0);
});
});
describe("findNameMatches", () => {
it("finds contacts with similar names", () => {
const contact1 = store.createContact("John Doe");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe",
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const contact2 = store.createContact("John Doe"); // Same name
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: "johndoe",
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const suggestions = findNameMatches(store);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0]?.reason).toBe("name_similarity");
expect(suggestions[0]?.score).toBe(1.0);
});
it("finds contacts with slightly different names", () => {
const contact1 = store.createContact("John Doe");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: null,
phone: null,
displayName: "John Doe",
lastSeenAt: null,
});
const contact2 = store.createContact("John D"); // Shorter version
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: null,
phone: null,
displayName: "John D",
lastSeenAt: null,
});
// With default threshold of 0.85, these may or may not match
const suggestions = findNameMatches(store, { minScore: 0.6 });
// At least should find something with low threshold
expect(suggestions.length).toBeGreaterThan(0);
});
it("respects minimum score threshold", () => {
const contact1 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const contact2 = store.createContact("Bob Jones");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-bob",
username: null,
phone: null,
displayName: "Bob Jones",
lastSeenAt: null,
});
// Completely different names should not match
const suggestions = findNameMatches(store, { minScore: 0.85 });
expect(suggestions.length).toBe(0);
});
});
describe("findLinkSuggestions", () => {
it("combines phone and name matches", () => {
// Phone match
const contact1 = store.createContact("Phone User 1");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-1",
username: null,
phone: "+14155551234",
displayName: "Phone User",
lastSeenAt: null,
});
const contact2 = store.createContact("Phone User 2");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-1",
username: null,
phone: "+14155551234",
displayName: "Phone User",
lastSeenAt: null,
});
// Name match (different person)
const contact3 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact3.canonicalId,
platform: "telegram",
platformId: "tg-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const contact4 = store.createContact("Alice Smith");
store.addIdentity({
contactId: contact4.canonicalId,
platform: "discord",
platformId: "dc-alice",
username: null,
phone: null,
displayName: "Alice Smith",
lastSeenAt: null,
});
const suggestions = findLinkSuggestions(store);
expect(suggestions.length).toBeGreaterThanOrEqual(2);
// Phone matches should come first (high confidence)
const phoneMatch = suggestions.find((s) => s.reason === "phone_match");
expect(phoneMatch).toBeDefined();
expect(phoneMatch?.confidence).toBe("high");
});
it("sorts by confidence then score", () => {
// Create multiple potential matches with different confidence levels
const contact1 = store.createContact("Test User A");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-a",
username: null,
phone: "+14155559999",
displayName: "Test User A",
lastSeenAt: null,
});
const contact2 = store.createContact("Test User A");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-a",
username: null,
phone: "+14155559999",
displayName: "Test User A",
lastSeenAt: null,
});
const suggestions = findLinkSuggestions(store);
expect(suggestions.length).toBeGreaterThan(0);
// Verify sorted by confidence
for (let i = 1; i < suggestions.length; i++) {
const prev = suggestions[i - 1]!;
const curr = suggestions[i]!;
const confidenceOrder = { high: 3, medium: 2, low: 1 };
const prevConf = confidenceOrder[prev.confidence];
const currConf = confidenceOrder[curr.confidence];
expect(prevConf).toBeGreaterThanOrEqual(currConf);
}
});
});
describe("linkContacts", () => {
it("merges two contacts", () => {
const contact1 = store.createContact("John on Telegram");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe_tg",
phone: null,
displayName: "John TG",
lastSeenAt: null,
});
const contact2 = store.createContact("John on Discord");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: "johndoe_dc",
phone: null,
displayName: "John DC",
lastSeenAt: null,
});
const result = linkContacts(store, contact1.canonicalId, contact2.canonicalId);
expect(result.success).toBe(true);
// Primary contact should have both identities
const merged = store.getContactWithIdentities(contact1.canonicalId);
expect(merged?.identities.length).toBe(2);
expect(merged?.identities.map((i) => i.platform)).toContain("telegram");
expect(merged?.identities.map((i) => i.platform)).toContain("discord");
// Secondary contact should be deleted
expect(store.getContact(contact2.canonicalId)).toBeNull();
// Aliases should include secondary contact's name
expect(merged?.aliases).toContain("John on Discord");
});
it("reassigns message history when merging contacts", () => {
const primary = store.createContact("Merge Primary");
store.addIdentity({
contactId: primary.canonicalId,
platform: "telegram",
platformId: "tg-merge",
username: null,
phone: null,
displayName: "TG Merge",
lastSeenAt: null,
});
const secondary = store.createContact("Merge Secondary");
store.addIdentity({
contactId: secondary.canonicalId,
platform: "discord",
platformId: "dc-merge",
username: null,
phone: null,
displayName: "DC Merge",
lastSeenAt: null,
});
store.indexMessage({
id: "msg-merge-1",
content: "merge history one",
platform: "telegram",
senderId: "tg-merge",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-merge-2",
content: "merge history two",
platform: "discord",
senderId: "dc-merge",
channelId: "c2",
timestamp: Date.now(),
});
const result = linkContacts(store, primary.canonicalId, secondary.canonicalId);
expect(result.success).toBe(true);
const results = store.searchMessages({ query: "merge history", from: primary.canonicalId });
expect(results.length).toBe(2);
const ids = results.map((entry) => entry.message.id);
expect(ids).toContain("msg-merge-1");
expect(ids).toContain("msg-merge-2");
expect(results.every((entry) => entry.message.contactId === primary.canonicalId)).toBe(true);
});
it("returns error for non-existent primary contact", () => {
const contact = store.createContact("Test");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-test",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = linkContacts(store, "fake-id", contact.canonicalId);
expect(result.success).toBe(false);
expect(result.error).toContain("Primary contact not found");
});
it("returns error for non-existent secondary contact", () => {
const contact = store.createContact("Test");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-test",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = linkContacts(store, contact.canonicalId, "fake-id");
expect(result.success).toBe(false);
expect(result.error).toContain("Secondary contact not found");
});
});
describe("unlinkIdentity", () => {
it("creates new contact for unlinked identity", () => {
const contact = store.createContact("Multi Platform User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-user",
username: "user_tg",
phone: null,
displayName: "TG User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-user",
username: "user_dc",
phone: null,
displayName: "DC User",
lastSeenAt: null,
});
const result = unlinkIdentity(store, "discord", "dc-user");
expect(result.success).toBe(true);
expect(result.newContactId).toBeDefined();
// Original contact should only have telegram identity
const original = store.getContactWithIdentities(contact.canonicalId);
expect(original?.identities.length).toBe(1);
expect(original?.identities[0]?.platform).toBe("telegram");
// New contact should have discord identity
const newContact = store.getContactWithIdentities(result.newContactId!);
expect(newContact?.identities.length).toBe(1);
expect(newContact?.identities[0]?.platform).toBe("discord");
});
it("moves message history to new contact when unlinking", () => {
const contact = store.createContact("Unlink User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-unlink",
username: null,
phone: null,
displayName: "TG Unlink",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-unlink",
username: null,
phone: null,
displayName: "DC Unlink",
lastSeenAt: null,
});
store.indexMessage({
id: "msg-unlink",
content: "unlink history message",
platform: "discord",
senderId: "dc-unlink",
channelId: "c3",
timestamp: Date.now(),
});
const result = unlinkIdentity(store, "discord", "dc-unlink");
expect(result.success).toBe(true);
const newContactId = result.newContactId!;
const newResults = store.searchMessages({
query: "unlink history",
from: newContactId,
});
expect(newResults.length).toBe(1);
expect(newResults[0]?.message.contactId).toBe(newContactId);
const oldResults = store.searchMessages({
query: "unlink history",
from: contact.canonicalId,
});
expect(oldResults.length).toBe(0);
});
it("returns error for non-existent identity", () => {
const result = unlinkIdentity(store, "telegram", "fake-id");
expect(result.success).toBe(false);
expect(result.error).toContain("Identity not found");
});
it("returns error when trying to unlink only identity", () => {
const contact = store.createContact("Single Identity User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-single",
username: null,
phone: null,
displayName: null,
lastSeenAt: null,
});
const result = unlinkIdentity(store, "telegram", "tg-single");
expect(result.success).toBe(false);
expect(result.error).toContain("Cannot unlink the only identity");
});
});
describe("autoLinkHighConfidence", () => {
it("automatically links high confidence matches", () => {
// Create contacts with same phone (high confidence)
const contact1 = store.createContact("Auto Link User 1");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "whatsapp",
platformId: "wa-auto",
username: null,
phone: "+14155557777",
displayName: "Auto User WA",
lastSeenAt: null,
});
const contact2 = store.createContact("Auto Link User 2");
store.addIdentity({
contactId: contact2.canonicalId,
platform: "signal",
platformId: "signal-auto",
username: null,
phone: "+14155557777",
displayName: "Auto User Signal",
lastSeenAt: null,
});
const initialCount = store.listContacts().length;
expect(initialCount).toBe(2);
const result = autoLinkHighConfidence(store);
expect(result.linked).toBe(1);
// Should now have only one contact
const finalCount = store.listContacts().length;
expect(finalCount).toBe(1);
// The remaining contact should have both identities
const contacts = store.listContacts();
const merged = store.getContactWithIdentities(contacts[0]!.canonicalId);
expect(merged?.identities.length).toBe(2);
});
it("does not link medium/low confidence matches", () => {
// Create contacts with similar but not exact names (medium confidence)
const contact1 = store.createContact("John Smith");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: null,
phone: null,
displayName: "John Smith",
lastSeenAt: null,
});
const contact2 = store.createContact("John Smyth"); // Similar but not same
store.addIdentity({
contactId: contact2.canonicalId,
platform: "discord",
platformId: "dc-john",
username: null,
phone: null,
displayName: "John Smyth",
lastSeenAt: null,
});
const result = autoLinkHighConfidence(store);
// Name similarity below threshold should not auto-link
// They may or may not be linked depending on exact similarity
// But we verify auto-link only processes high confidence
expect(result.suggestions.every((s) => s.confidence === "high")).toBe(true);
});
});
});
@@ -0,0 +1,396 @@
/**
* Auto-linking heuristics for the unified contact graph.
*
* This module provides algorithms to automatically detect and suggest
* links between platform identities that likely belong to the same person.
*
* Linking heuristics (in priority order):
* 1. Phone match: Same E.164 phone across platforms (high confidence)
* 2. Name similarity: Fuzzy name matching with high threshold (medium confidence)
*/
import type { ContactStore } from "./store.js";
import type { LinkConfidence, LinkSuggestion, PlatformIdentity } from "./types.js";
/**
* Calculate Levenshtein distance between two strings.
*/
function levenshteinDistance(a: string, b: string): number {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix: number[][] = [];
// Initialize matrix
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0]![j] = j;
}
// Fill in the rest of the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
const cost = a[j - 1] === b[i - 1] ? 0 : 1;
matrix[i]![j] = Math.min(
matrix[i - 1]![j]! + 1, // deletion
matrix[i]![j - 1]! + 1, // insertion
matrix[i - 1]![j - 1]! + cost, // substitution
);
}
}
return matrix[b.length]![a.length]!;
}
/**
* Calculate similarity score between two strings (0-1).
* 1 = identical, 0 = completely different.
*/
function calculateSimilarity(a: string, b: string): number {
if (!a || !b) return 0;
// Normalize: lowercase, trim, remove extra whitespace
const normalizedA = a.toLowerCase().trim().replace(/\s+/g, " ");
const normalizedB = b.toLowerCase().trim().replace(/\s+/g, " ");
if (normalizedA === normalizedB) return 1;
if (normalizedA.length === 0 || normalizedB.length === 0) return 0;
const maxLength = Math.max(normalizedA.length, normalizedB.length);
const distance = levenshteinDistance(normalizedA, normalizedB);
return 1 - distance / maxLength;
}
/**
* Normalize a phone number for comparison.
* Strips non-digit characters except leading +.
*/
function normalizePhone(phone: string | null): string | null {
if (!phone) return null;
// Keep only digits and leading +
let normalized = phone.replace(/[^+\d]/g, "");
// Ensure it starts with +
if (!normalized.startsWith("+") && normalized.length >= 10) {
// Assume US if 10 digits without country code
if (normalized.length === 10) {
normalized = `+1${normalized}`;
} else {
normalized = `+${normalized}`;
}
}
return normalized.length >= 10 ? normalized : null;
}
/**
* Find link suggestions based on phone number matching.
* This is the highest confidence match since phone numbers are unique identifiers.
*/
export function findPhoneMatches(store: ContactStore): LinkSuggestion[] {
const suggestions: LinkSuggestion[] = [];
// Get all contacts with their identities
const contacts = store.listContacts();
// Build phone → identities map
const phoneToIdentities = new Map<string, PlatformIdentity[]>();
for (const contact of contacts) {
const withIdentities = store.getContactWithIdentities(contact.canonicalId);
if (!withIdentities) continue;
for (const identity of withIdentities.identities) {
const phone = normalizePhone(identity.phone);
if (!phone) continue;
const existing = phoneToIdentities.get(phone) ?? [];
existing.push(identity);
phoneToIdentities.set(phone, existing);
}
}
// Find identities with same phone but different contacts
for (const [_phone, identities] of phoneToIdentities) {
if (identities.length < 2) continue;
// Group by contact ID
const byContact = new Map<string, PlatformIdentity[]>();
for (const identity of identities) {
const existing = byContact.get(identity.contactId) ?? [];
existing.push(identity);
byContact.set(identity.contactId, existing);
}
// If all belong to same contact, already linked
if (byContact.size < 2) continue;
// Create suggestions for each pair of contacts
const contactIds = Array.from(byContact.keys());
for (let i = 0; i < contactIds.length; i++) {
for (let j = i + 1; j < contactIds.length; j++) {
const sourceIdentity = byContact.get(contactIds[i]!)![0]!;
const targetIdentity = byContact.get(contactIds[j]!)![0]!;
suggestions.push({
sourceIdentity,
targetIdentity,
reason: "phone_match",
confidence: "high",
score: 1.0,
});
}
}
}
return suggestions;
}
/**
* Find link suggestions based on name similarity.
* Uses fuzzy matching with a configurable threshold.
*/
export function findNameMatches(
store: ContactStore,
options: { minScore?: number } = {},
): LinkSuggestion[] {
const { minScore = 0.85 } = options;
const suggestions: LinkSuggestion[] = [];
// Get all contacts with their identities
const contacts = store.listContacts();
const contactsWithIdentities = contacts
.map((c) => store.getContactWithIdentities(c.canonicalId))
.filter((c): c is NonNullable<typeof c> => c !== null);
// Compare each pair of contacts
for (let i = 0; i < contactsWithIdentities.length; i++) {
for (let j = i + 1; j < contactsWithIdentities.length; j++) {
const contactA = contactsWithIdentities[i]!;
const contactB = contactsWithIdentities[j]!;
// Skip if already same contact
if (contactA.canonicalId === contactB.canonicalId) continue;
// Compare display names
const similarity = calculateSimilarity(contactA.displayName, contactB.displayName);
if (similarity >= minScore) {
// Get representative identities for the suggestion
const sourceIdentity = contactA.identities[0];
const targetIdentity = contactB.identities[0];
if (sourceIdentity && targetIdentity) {
suggestions.push({
sourceIdentity,
targetIdentity,
reason: "name_similarity",
confidence: similarity >= 0.95 ? "high" : "medium",
score: similarity,
});
}
}
// Also compare identity display names
for (const identityA of contactA.identities) {
for (const identityB of contactB.identities) {
if (!identityA.displayName || !identityB.displayName) continue;
const identitySimilarity = calculateSimilarity(
identityA.displayName,
identityB.displayName,
);
if (identitySimilarity >= minScore) {
// Avoid duplicate suggestions
const alreadySuggested = suggestions.some(
(s) =>
(s.sourceIdentity.id === identityA.id && s.targetIdentity.id === identityB.id) ||
(s.sourceIdentity.id === identityB.id && s.targetIdentity.id === identityA.id),
);
if (!alreadySuggested) {
suggestions.push({
sourceIdentity: identityA,
targetIdentity: identityB,
reason: "name_similarity",
confidence: identitySimilarity >= 0.95 ? "high" : "medium",
score: identitySimilarity,
});
}
}
}
}
}
}
return suggestions;
}
/**
* Find all link suggestions across all heuristics.
* Returns suggestions sorted by confidence and score.
*/
export function findLinkSuggestions(
store: ContactStore,
options: { minNameScore?: number } = {},
): LinkSuggestion[] {
const phoneSuggestions = findPhoneMatches(store);
const nameSuggestions = findNameMatches(store, { minScore: options.minNameScore });
// Combine and sort by confidence (high first) then score
const all = [...phoneSuggestions, ...nameSuggestions];
const confidenceOrder: Record<LinkConfidence, number> = {
high: 3,
medium: 2,
low: 1,
};
return all.sort((a, b) => {
const confDiff = confidenceOrder[b.confidence] - confidenceOrder[a.confidence];
if (confDiff !== 0) return confDiff;
return b.score - a.score;
});
}
/**
* Link two contacts by merging all identities into the primary contact.
* The secondary contact is deleted.
*/
export function linkContacts(
store: ContactStore,
primaryContactId: string,
secondaryContactId: string,
): { success: boolean; error?: string } {
const primary = store.getContactWithIdentities(primaryContactId);
const secondary = store.getContactWithIdentities(secondaryContactId);
if (!primary) {
return { success: false, error: `Primary contact not found: ${primaryContactId}` };
}
if (!secondary) {
return { success: false, error: `Secondary contact not found: ${secondaryContactId}` };
}
// Move all identities from secondary to primary
for (const identity of secondary.identities) {
store.addIdentity({
contactId: primary.canonicalId,
platform: identity.platform,
platformId: identity.platformId,
username: identity.username,
phone: identity.phone,
displayName: identity.displayName,
lastSeenAt: identity.lastSeenAt,
});
store.updateMessageContactForIdentity({
contactId: primary.canonicalId,
platform: identity.platform,
senderId: identity.platformId,
});
}
// Merge aliases
const newAliases = [...primary.aliases];
if (!newAliases.includes(secondary.displayName)) {
newAliases.push(secondary.displayName);
}
for (const alias of secondary.aliases) {
if (!newAliases.includes(alias)) {
newAliases.push(alias);
}
}
store.updateContact(primary.canonicalId, { aliases: newAliases });
// Delete secondary contact
store.deleteContact(secondaryContactId);
return { success: true };
}
/**
* Unlink a platform identity from its current contact.
* Creates a new contact for the identity.
*/
export function unlinkIdentity(
store: ContactStore,
platform: string,
platformId: string,
): { success: boolean; newContactId?: string; error?: string } {
const identity = store.getIdentityByPlatformId(platform, platformId);
if (!identity) {
return { success: false, error: `Identity not found: ${platform}:${platformId}` };
}
const currentContact = store.getContactWithIdentities(identity.contactId);
if (!currentContact) {
return { success: false, error: `Contact not found: ${identity.contactId}` };
}
// If this is the only identity, nothing to unlink
if (currentContact.identities.length === 1) {
return { success: false, error: "Cannot unlink the only identity from a contact" };
}
// Create new contact for this identity
const displayName = identity.displayName || identity.username || identity.platformId;
const newContact = store.createContact(displayName);
// Move the identity to the new contact
store.addIdentity({
contactId: newContact.canonicalId,
platform: identity.platform,
platformId: identity.platformId,
username: identity.username,
phone: identity.phone,
displayName: identity.displayName,
lastSeenAt: identity.lastSeenAt,
});
store.updateMessageContactForIdentity({
contactId: newContact.canonicalId,
platform: identity.platform,
senderId: identity.platformId,
});
return { success: true, newContactId: newContact.canonicalId };
}
/**
* Auto-apply high-confidence link suggestions.
* Returns the number of links applied.
*/
export function autoLinkHighConfidence(store: ContactStore): {
linked: number;
suggestions: LinkSuggestion[];
} {
const suggestions = findLinkSuggestions(store);
const highConfidence = suggestions.filter((s) => s.confidence === "high");
let linked = 0;
const processedContacts = new Set<string>();
for (const suggestion of highConfidence) {
const sourceContactId = suggestion.sourceIdentity.contactId;
const targetContactId = suggestion.targetIdentity.contactId;
// Skip if either contact was already processed (merged into another)
if (processedContacts.has(sourceContactId) || processedContacts.has(targetContactId)) {
continue;
}
// Skip if same contact (already linked)
if (sourceContactId === targetContactId) {
continue;
}
const result = linkContacts(store, sourceContactId, targetContactId);
if (result.success) {
linked++;
processedContacts.add(targetContactId);
}
}
return { linked, suggestions: highConfidence };
}
@@ -0,0 +1,125 @@
import type { DatabaseSync } from "node:sqlite";
/**
* Ensures the contact store schema is created in the SQLite database.
* Creates tables for contacts, platform identities, and indexed messages.
*/
export function ensureContactStoreSchema(db: DatabaseSync): {
ftsAvailable: boolean;
ftsError?: string;
} {
// Unified contacts table - canonical contact records
db.exec(`
CREATE TABLE IF NOT EXISTS contacts (
canonical_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
aliases TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Platform identities table - links platform-specific IDs to canonical contacts
db.exec(`
CREATE TABLE IF NOT EXISTS platform_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contact_id TEXT NOT NULL REFERENCES contacts(canonical_id) ON DELETE CASCADE,
platform TEXT NOT NULL,
platform_id TEXT NOT NULL,
username TEXT,
phone TEXT,
display_name TEXT,
last_seen_at INTEGER,
UNIQUE(platform, platform_id)
);
`);
// Indexed messages table - for cross-platform message search
db.exec(`
CREATE TABLE IF NOT EXISTS indexed_messages (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
contact_id TEXT REFERENCES contacts(canonical_id) ON DELETE SET NULL,
platform TEXT NOT NULL,
sender_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
embedding TEXT
);
`);
// Indexes for efficient queries
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_contact_id ON platform_identities(contact_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_platform ON platform_identities(platform);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_phone ON platform_identities(phone);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_platform_identities_username ON platform_identities(username);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_contact_id ON indexed_messages(contact_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_platform ON indexed_messages(platform);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_sender_id ON indexed_messages(sender_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_channel_id ON indexed_messages(channel_id);`,
);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_indexed_messages_timestamp ON indexed_messages(timestamp);`,
);
// Full-text search virtual table for message content
let ftsAvailable = false;
let ftsError: string | undefined;
try {
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
id UNINDEXED,
contact_id UNINDEXED,
platform UNINDEXED,
sender_id UNINDEXED,
channel_id UNINDEXED,
timestamp UNINDEXED
);
`);
ftsAvailable = true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ftsAvailable = false;
ftsError = message;
}
// Migration helper - add columns if they don't exist
ensureColumn(db, "contacts", "aliases", "TEXT NOT NULL DEFAULT '[]'");
return { ftsAvailable, ...(ftsError ? { ftsError } : {}) };
}
/**
* Ensures a column exists on a table, adding it if missing.
*/
function ensureColumn(db: DatabaseSync, table: string, column: string, definition: string): void {
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (rows.some((row) => row.name === column)) return;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
/**
* Drop all contact store tables (for testing/reset).
*/
export function dropContactStoreTables(db: DatabaseSync): void {
db.exec(`DROP TABLE IF EXISTS messages_fts;`);
db.exec(`DROP TABLE IF EXISTS indexed_messages;`);
db.exec(`DROP TABLE IF EXISTS platform_identities;`);
db.exec(`DROP TABLE IF EXISTS contacts;`);
}
@@ -0,0 +1,547 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ContactStore } from "./store.js";
import type { Platform } from "./types.js";
describe("ContactStore", () => {
let store: ContactStore;
beforeEach(() => {
store = ContactStore.openInMemory();
});
afterEach(() => {
store.close();
});
describe("contacts", () => {
it("creates a contact with generated canonical ID", () => {
const contact = store.createContact("Sarah Jones");
expect(contact.canonicalId).toMatch(/^sarah-jones-[a-f0-9]{8}$/);
expect(contact.displayName).toBe("Sarah Jones");
expect(contact.aliases).toEqual([]);
expect(contact.createdAt).toBeGreaterThan(0);
expect(contact.updatedAt).toBe(contact.createdAt);
});
it("creates a contact with aliases", () => {
const contact = store.createContact("Bob Smith", ["Bobby", "Bob S"]);
expect(contact.aliases).toEqual(["Bobby", "Bob S"]);
});
it("retrieves a contact by canonical ID", () => {
const created = store.createContact("Alice Doe");
const retrieved = store.getContact(created.canonicalId);
expect(retrieved).toEqual(created);
});
it("returns null for non-existent contact", () => {
const retrieved = store.getContact("non-existent-id");
expect(retrieved).toBeNull();
});
it("updates contact display name", () => {
const contact = store.createContact("Old Name");
const success = store.updateContact(contact.canonicalId, { displayName: "New Name" });
expect(success).toBe(true);
const updated = store.getContact(contact.canonicalId);
expect(updated?.displayName).toBe("New Name");
// updatedAt should be >= createdAt (may be same millisecond in fast tests)
expect(updated?.updatedAt).toBeGreaterThanOrEqual(contact.updatedAt);
});
it("updates contact aliases", () => {
const contact = store.createContact("Test User");
store.updateContact(contact.canonicalId, { aliases: ["Tester", "TU"] });
const updated = store.getContact(contact.canonicalId);
expect(updated?.aliases).toEqual(["Tester", "TU"]);
});
it("returns false when updating non-existent contact", () => {
const success = store.updateContact("fake-id", { displayName: "Test" });
expect(success).toBe(false);
});
it("deletes a contact", () => {
const contact = store.createContact("To Delete");
expect(store.getContact(contact.canonicalId)).not.toBeNull();
const deleted = store.deleteContact(contact.canonicalId);
expect(deleted).toBe(true);
expect(store.getContact(contact.canonicalId)).toBeNull();
});
it("returns false when deleting non-existent contact", () => {
const deleted = store.deleteContact("fake-id");
expect(deleted).toBe(false);
});
it("lists all contacts", () => {
store.createContact("Alpha User");
store.createContact("Beta User");
store.createContact("Gamma User");
const contacts = store.listContacts();
expect(contacts.length).toBe(3);
});
it("lists contacts with query filter", () => {
store.createContact("John Doe");
store.createContact("Jane Doe", ["Janey"]);
store.createContact("Bob Smith");
const contacts = store.listContacts({ query: "doe" });
expect(contacts.length).toBe(2);
});
it("lists contacts with limit", () => {
store.createContact("User 1");
store.createContact("User 2");
store.createContact("User 3");
const contacts = store.listContacts({ limit: 2 });
expect(contacts.length).toBe(2);
});
});
describe("platform identities", () => {
it("adds a platform identity to a contact", () => {
const contact = store.createContact("Test User");
const identity = store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "123456789",
username: "testuser",
phone: null,
displayName: "Test User",
lastSeenAt: null,
});
expect(identity.id).toBeGreaterThan(0);
expect(identity.contactId).toBe(contact.canonicalId);
expect(identity.platform).toBe("telegram");
expect(identity.platformId).toBe("123456789");
});
it("retrieves identities by contact ID", () => {
const contact = store.createContact("Multi Platform User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-123",
username: "teleuser",
phone: null,
displayName: "Tele User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "dc-456",
username: "discorduser",
phone: null,
displayName: "Discord User",
lastSeenAt: null,
});
const identities = store.getIdentitiesByContact(contact.canonicalId);
expect(identities.length).toBe(2);
expect(identities.map((i) => i.platform)).toContain("telegram");
expect(identities.map((i) => i.platform)).toContain("discord");
});
it("retrieves identity by platform and platform ID", () => {
const contact = store.createContact("User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "+14155551234@s.whatsapp.net",
username: null,
phone: "+14155551234",
displayName: "WA User",
lastSeenAt: null,
});
const identity = store.getIdentityByPlatformId("whatsapp", "+14155551234@s.whatsapp.net");
expect(identity).not.toBeNull();
expect(identity?.contactId).toBe(contact.canonicalId);
expect(identity?.phone).toBe("+14155551234");
});
it("returns null for non-existent identity", () => {
const identity = store.getIdentityByPlatformId("telegram", "fake-id");
expect(identity).toBeNull();
});
it("finds identities by phone number", () => {
const contact = store.createContact("Phone User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-jid-1",
username: null,
phone: "+14155551234",
displayName: "WA User",
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "signal",
platformId: "signal-uuid-1",
username: null,
phone: "+14155551234",
displayName: "Signal User",
lastSeenAt: null,
});
const identities = store.findIdentitiesByPhone("+14155551234");
expect(identities.length).toBe(2);
});
it("updates last seen timestamp", () => {
const contact = store.createContact("User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-id",
username: "user",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.updateIdentityLastSeen("telegram", "tg-id");
const identity = store.getIdentityByPlatformId("telegram", "tg-id");
expect(identity?.lastSeenAt).toBeGreaterThan(0);
});
it("resolves platform sender to contact ID", () => {
const contact = store.createContact("Resolvable User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "discord",
platformId: "discord-user-id",
username: "discorduser",
phone: null,
displayName: "Discord Display",
lastSeenAt: null,
});
const resolved = store.resolveContact("discord", "discord-user-id");
expect(resolved).toBe(contact.canonicalId);
});
it("returns null when resolving unknown sender", () => {
const resolved = store.resolveContact("telegram", "unknown-id");
expect(resolved).toBeNull();
});
});
describe("contact search", () => {
it("searches contacts by display name", () => {
store.createContact("Alice Wonderland");
store.createContact("Bob Builder");
store.createContact("Alice Cooper");
const results = store.searchContacts("alice");
expect(results.length).toBe(2);
expect(results.map((r) => r.displayName)).toContain("Alice Wonderland");
expect(results.map((r) => r.displayName)).toContain("Alice Cooper");
});
it("searches contacts by username", () => {
const contact = store.createContact("John Doe");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-john",
username: "johndoe",
phone: null,
displayName: null,
lastSeenAt: null,
});
const results = store.searchContacts("johndoe");
expect(results.length).toBe(1);
expect(results[0]?.displayName).toBe("John Doe");
});
it("returns contact with all identities", () => {
const contact = store.createContact("Multi User");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-multi",
username: "multi_tg",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "slack",
platformId: "slack-multi",
username: "multi_slack",
phone: null,
displayName: null,
lastSeenAt: null,
});
const results = store.searchContacts("multi");
expect(results.length).toBe(1);
expect(results[0]?.identities.length).toBe(2);
});
});
describe("message indexing", () => {
it("indexes a message", () => {
store.indexMessage({
id: "msg-1",
content: "Hello, this is a test message",
platform: "telegram" as Platform,
senderId: "sender-123",
channelId: "channel-456",
timestamp: Date.now(),
});
const stats = store.getStats();
expect(stats.messages).toBe(1);
});
it("links message to contact when sender is resolved", () => {
const contact = store.createContact("Known Sender");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "sender-known",
username: "known",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "msg-linked",
content: "Message from known sender",
platform: "telegram" as Platform,
senderId: "sender-known",
channelId: "chat-1",
timestamp: Date.now(),
});
// Search should find the message
const results = store.searchMessages({ query: "known sender" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
});
it("filters messages by canonical contact id", () => {
const contact = store.createContact("Filter Sender");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "sender-filter",
username: "filter",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "msg-filter",
content: "Message for canonical filter",
platform: "telegram" as Platform,
senderId: "sender-filter",
channelId: "chat-1",
timestamp: Date.now(),
});
const results = store.searchMessages({
query: "canonical filter",
from: contact.canonicalId,
});
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.contactId).toBe(contact.canonicalId);
});
it("searches messages by content", () => {
store.indexMessage({
id: "msg-search-1",
content: "The quick brown fox jumps over the lazy dog",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-search-2",
content: "A slow red turtle crawls under the fence",
platform: "discord" as Platform,
senderId: "s2",
channelId: "c2",
timestamp: Date.now(),
});
const results = store.searchMessages({ query: "quick fox" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.message.id).toBe("msg-search-1");
});
it("filters messages by platform", () => {
store.indexMessage({
id: "msg-tg",
content: "Telegram message about deadlines",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
store.indexMessage({
id: "msg-dc",
content: "Discord message about deadlines",
platform: "discord" as Platform,
senderId: "s2",
channelId: "c2",
timestamp: Date.now(),
});
const results = store.searchMessages({
query: "deadlines",
platforms: ["telegram"],
});
expect(results.length).toBe(1);
expect(results[0]?.message.platform).toBe("telegram");
});
it("filters messages by timestamp range", () => {
const now = Date.now();
store.indexMessage({
id: "msg-old",
content: "Old message about projects",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: now - 7 * 24 * 60 * 60 * 1000, // 7 days ago
});
store.indexMessage({
id: "msg-new",
content: "New message about projects",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: now,
});
const results = store.searchMessages({
query: "projects",
since: now - 24 * 60 * 60 * 1000, // Last 24 hours
});
expect(results.length).toBe(1);
expect(results[0]?.message.id).toBe("msg-new");
});
it("creates snippet with context", () => {
store.indexMessage({
id: "msg-snippet",
content:
"This is a very long message that contains the word deadline somewhere in the middle and continues with more text after that point to test the snippet creation functionality.",
platform: "telegram" as Platform,
senderId: "s1",
channelId: "c1",
timestamp: Date.now(),
});
const results = store.searchMessages({ query: "deadline" });
expect(results.length).toBeGreaterThan(0);
expect(results[0]?.snippet).toContain("deadline");
expect(results[0]?.snippet.length).toBeLessThan(250);
});
});
describe("getContactWithIdentities", () => {
it("returns contact with all platform identities", () => {
const contact = store.createContact("Full Contact");
store.addIdentity({
contactId: contact.canonicalId,
platform: "telegram",
platformId: "tg-full",
username: "full_tg",
phone: "+14155551111",
displayName: "TG Full",
lastSeenAt: Date.now(),
});
store.addIdentity({
contactId: contact.canonicalId,
platform: "whatsapp",
platformId: "wa-full",
username: null,
phone: "+14155551111",
displayName: "WA Full",
lastSeenAt: null,
});
const result = store.getContactWithIdentities(contact.canonicalId);
expect(result).not.toBeNull();
expect(result?.displayName).toBe("Full Contact");
expect(result?.identities.length).toBe(2);
});
it("returns null for non-existent contact", () => {
const result = store.getContactWithIdentities("fake-id");
expect(result).toBeNull();
});
});
describe("statistics", () => {
it("returns accurate stats", () => {
const contact1 = store.createContact("Stats User 1");
const contact2 = store.createContact("Stats User 2");
store.addIdentity({
contactId: contact1.canonicalId,
platform: "telegram",
platformId: "tg-stats-1",
username: "stats1",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact1.canonicalId,
platform: "discord",
platformId: "dc-stats-1",
username: "stats1dc",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.addIdentity({
contactId: contact2.canonicalId,
platform: "telegram",
platformId: "tg-stats-2",
username: "stats2",
phone: null,
displayName: null,
lastSeenAt: null,
});
store.indexMessage({
id: "stats-msg-1",
content: "Stats test message",
platform: "telegram" as Platform,
senderId: "tg-stats-1",
channelId: "c1",
timestamp: Date.now(),
});
const stats = store.getStats();
expect(stats.contacts).toBe(2);
expect(stats.identities).toBe(3);
expect(stats.messages).toBe(1);
expect(stats.platforms.telegram).toBe(2);
expect(stats.platforms.discord).toBe(1);
});
});
});
@@ -0,0 +1,838 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync, StatementSync } from "node:sqlite";
import { requireNodeSqlite } from "../sqlite.js";
import { ensureContactStoreSchema } from "./schema.js";
import type {
Contact,
ContactSearchOptions,
ContactWithIdentities,
IndexedMessage,
MessageSearchOptions,
MessageSearchResult,
Platform,
PlatformIdentity,
PlatformIdentityInput,
} from "./types.js";
const CONTACTS_DB_FILENAME = "contacts.sqlite";
/**
* ContactStore manages the unified contact graph and message index.
*
* Key capabilities:
* - Store and retrieve canonical contacts
* - Link platform-specific identities to canonical contacts
* - Index messages for cross-platform search
* - Resolve sender identities to canonical contacts
*/
export class ContactStore {
private db: DatabaseSync;
private ftsAvailable: boolean;
// Prepared statements for performance
private stmtInsertContact: StatementSync;
private stmtUpdateContact: StatementSync;
private stmtGetContact: StatementSync;
private stmtDeleteContact: StatementSync;
private stmtInsertIdentity: StatementSync;
private stmtGetIdentitiesByContact: StatementSync;
private stmtGetIdentityByPlatformId: StatementSync;
private stmtUpdateIdentityLastSeen: StatementSync;
private stmtInsertMessage: StatementSync;
private stmtInsertMessageFts: StatementSync | null;
private stmtUpdateMessageContactBySender: StatementSync;
private stmtUpdateMessageFtsContactBySender: StatementSync | null;
private constructor(db: DatabaseSync, ftsAvailable: boolean) {
this.db = db;
this.ftsAvailable = ftsAvailable;
// Prepare statements
this.stmtInsertContact = db.prepare(`
INSERT INTO contacts (canonical_id, display_name, aliases, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`);
this.stmtUpdateContact = db.prepare(`
UPDATE contacts SET display_name = ?, aliases = ?, updated_at = ? WHERE canonical_id = ?
`);
this.stmtGetContact = db.prepare(`
SELECT canonical_id, display_name, aliases, created_at, updated_at
FROM contacts WHERE canonical_id = ?
`);
this.stmtDeleteContact = db.prepare(`DELETE FROM contacts WHERE canonical_id = ?`);
this.stmtInsertIdentity = db.prepare(`
INSERT OR REPLACE INTO platform_identities
(contact_id, platform, platform_id, username, phone, display_name, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
this.stmtGetIdentitiesByContact = db.prepare(`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE contact_id = ?
`);
this.stmtGetIdentityByPlatformId = db.prepare(`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE platform = ? AND platform_id = ?
`);
this.stmtUpdateIdentityLastSeen = db.prepare(`
UPDATE platform_identities SET last_seen_at = ? WHERE platform = ? AND platform_id = ?
`);
this.stmtInsertMessage = db.prepare(`
INSERT OR REPLACE INTO indexed_messages
(id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
this.stmtInsertMessageFts = ftsAvailable
? db.prepare(`
INSERT OR REPLACE INTO messages_fts
(content, id, contact_id, platform, sender_id, channel_id, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
: null;
this.stmtUpdateMessageContactBySender = db.prepare(`
UPDATE indexed_messages SET contact_id = ? WHERE platform = ? AND sender_id = ?
`);
this.stmtUpdateMessageFtsContactBySender = ftsAvailable
? db.prepare(`
UPDATE messages_fts SET contact_id = ? WHERE platform = ? AND sender_id = ?
`)
: null;
}
/**
* Open or create a contact store database.
*/
static open(params: { dbPath?: string; stateDir?: string } = {}): ContactStore {
const nodeSqlite = requireNodeSqlite();
const resolvedPath =
params.dbPath ??
(params.stateDir ? path.join(params.stateDir, "contacts", CONTACTS_DB_FILENAME) : undefined);
if (!resolvedPath) {
throw new Error("ContactStore.open requires dbPath or stateDir");
}
// Ensure directory exists
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new nodeSqlite.DatabaseSync(resolvedPath);
// Enable foreign keys
db.exec("PRAGMA foreign_keys = ON;");
// Set up schema
const { ftsAvailable } = ensureContactStoreSchema(db);
return new ContactStore(db, ftsAvailable);
}
/**
* Create a new in-memory store (for testing).
*/
static openInMemory(): ContactStore {
const nodeSqlite = requireNodeSqlite();
const db = new nodeSqlite.DatabaseSync(":memory:");
db.exec("PRAGMA foreign_keys = ON;");
const { ftsAvailable } = ensureContactStoreSchema(db);
return new ContactStore(db, ftsAvailable);
}
/**
* Close the database connection.
*/
close(): void {
this.db.close();
}
// ─────────────────────────────────────────────────────────────────────────────
// CONTACT OPERATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Generate a canonical ID from a display name.
*/
private generateCanonicalId(displayName: string): string {
const slug = displayName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${slug || "contact"}-${suffix}`;
}
/**
* Create a new canonical contact.
*/
createContact(displayName: string, aliases: string[] = []): Contact {
const now = Date.now();
const canonicalId = this.generateCanonicalId(displayName);
this.stmtInsertContact.run(canonicalId, displayName, JSON.stringify(aliases), now, now);
return {
canonicalId,
displayName,
aliases,
createdAt: now,
updatedAt: now,
};
}
/**
* Get a contact by canonical ID.
*/
getContact(canonicalId: string): Contact | null {
const row = this.stmtGetContact.get(canonicalId) as
| {
canonical_id: string;
display_name: string;
aliases: string;
created_at: number;
updated_at: number;
}
| undefined;
if (!row) return null;
return {
canonicalId: row.canonical_id,
displayName: row.display_name,
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Update a contact's display name and/or aliases.
*/
updateContact(
canonicalId: string,
updates: { displayName?: string; aliases?: string[] },
): boolean {
const existing = this.getContact(canonicalId);
if (!existing) return false;
const displayName = updates.displayName ?? existing.displayName;
const aliases = updates.aliases ?? existing.aliases;
const now = Date.now();
this.stmtUpdateContact.run(displayName, JSON.stringify(aliases), now, canonicalId);
return true;
}
/**
* Delete a contact and all its platform identities.
*/
deleteContact(canonicalId: string): boolean {
const result = this.stmtDeleteContact.run(canonicalId);
return result.changes > 0;
}
/**
* List all contacts with optional filtering.
*/
listContacts(options: ContactSearchOptions = {}): Contact[] {
let sql = `SELECT canonical_id, display_name, aliases, created_at, updated_at FROM contacts`;
const params: (string | number)[] = [];
const conditions: string[] = [];
if (options.query) {
conditions.push(`(display_name LIKE ? OR aliases LIKE ?)`);
const pattern = `%${options.query}%`;
params.push(pattern, pattern);
}
if (options.platform) {
conditions.push(
`canonical_id IN (SELECT contact_id FROM platform_identities WHERE platform = ?)`,
);
params.push(this.normalizePlatform(options.platform));
}
if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(" AND ")}`;
}
sql += ` ORDER BY updated_at DESC`;
if (options.limit) {
sql += ` LIMIT ?`;
params.push(options.limit);
}
const stmt = this.db.prepare(sql);
const rows = stmt.all(...params) as Array<{
canonical_id: string;
display_name: string;
aliases: string;
created_at: number;
updated_at: number;
}>;
return rows.map((row) => ({
canonicalId: row.canonical_id,
displayName: row.display_name,
aliases: JSON.parse(row.aliases) as string[],
createdAt: row.created_at,
updatedAt: row.updated_at,
}));
}
/**
* Get a contact with all its platform identities.
*/
getContactWithIdentities(canonicalId: string): ContactWithIdentities | null {
const contact = this.getContact(canonicalId);
if (!contact) return null;
const identities = this.getIdentitiesByContact(canonicalId);
return { ...contact, identities };
}
/**
* Search contacts by name, alias, or username.
*/
searchContacts(query: string, limit = 10): ContactWithIdentities[] {
const pattern = `%${query}%`;
// Search in contacts table
const contactRows = this.db
.prepare(
`
SELECT DISTINCT c.canonical_id
FROM contacts c
LEFT JOIN platform_identities pi ON c.canonical_id = pi.contact_id
WHERE c.display_name LIKE ?
OR c.aliases LIKE ?
OR pi.username LIKE ?
OR pi.display_name LIKE ?
ORDER BY c.updated_at DESC
LIMIT ?
`,
)
.all(pattern, pattern, pattern, pattern, limit) as Array<{ canonical_id: string }>;
return contactRows
.map((row) => this.getContactWithIdentities(row.canonical_id))
.filter((c): c is ContactWithIdentities => c !== null);
}
// ─────────────────────────────────────────────────────────────────────────────
// PLATFORM IDENTITY OPERATIONS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Add a platform identity to a contact.
*/
addIdentity(input: PlatformIdentityInput): PlatformIdentity {
const platform = this.normalizePlatform(input.platform);
const phone = input.phone ? this.normalizePhone(input.phone) : null;
this.stmtInsertIdentity.run(
input.contactId,
platform,
input.platformId,
input.username,
phone,
input.displayName,
input.lastSeenAt,
);
// Get the inserted row to return
const identity = this.getIdentityByPlatformId(platform, input.platformId);
if (!identity) {
throw new Error(
`Failed to retrieve inserted identity: ${platform}:${input.platformId}`,
);
}
return identity;
}
/**
* Get all platform identities for a contact.
*/
getIdentitiesByContact(contactId: string): PlatformIdentity[] {
const rows = this.stmtGetIdentitiesByContact.all(contactId) as Array<{
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}>;
return rows.map((row) => ({
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
}));
}
/**
* Get a platform identity by platform and platform-specific ID.
*/
getIdentityByPlatformId(platform: string, platformId: string): PlatformIdentity | null {
const normalizedPlatform = this.normalizePlatform(platform);
const row = this.stmtGetIdentityByPlatformId.get(normalizedPlatform, platformId) as
| {
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}
| undefined;
if (!row) return null;
return {
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
};
}
/**
* Find identities by phone number across all platforms.
*/
findIdentitiesByPhone(phone: string): PlatformIdentity[] {
const normalized = this.normalizePhone(phone);
const rows = this.db
.prepare(
`
SELECT id, contact_id, platform, platform_id, username, phone, display_name, last_seen_at
FROM platform_identities WHERE phone = ?
`,
)
.all(normalized) as Array<{
id: number;
contact_id: string;
platform: string;
platform_id: string;
username: string | null;
phone: string | null;
display_name: string | null;
last_seen_at: number | null;
}>;
return rows.map((row) => ({
id: row.id,
contactId: row.contact_id,
platform: row.platform as Platform,
platformId: row.platform_id,
username: row.username,
phone: row.phone,
displayName: row.display_name,
lastSeenAt: row.last_seen_at,
}));
}
/**
* Update last seen timestamp for a platform identity.
*/
updateIdentityLastSeen(platform: string, platformId: string): void {
const normalizedPlatform = this.normalizePlatform(platform);
this.stmtUpdateIdentityLastSeen.run(Date.now(), normalizedPlatform, platformId);
}
/**
* Resolve a platform sender to a canonical contact ID.
* Returns null if the sender is not in the contact graph.
*/
resolveContact(platform: string, platformId: string): string | null {
const identity = this.getIdentityByPlatformId(platform, platformId);
return identity?.contactId ?? null;
}
/**
* Reassign indexed messages for a platform identity to a contact.
*/
updateMessageContactForIdentity(params: {
contactId: string | null;
platform: string;
senderId: string;
}): void {
const platform = this.normalizePlatform(params.platform);
this.stmtUpdateMessageContactBySender.run(params.contactId, platform, params.senderId);
this.stmtUpdateMessageFtsContactBySender?.run(params.contactId, platform, params.senderId);
}
/**
* Normalize a phone number to E.164 format.
*/
private normalizePhone(phone: string): string {
// Strip everything except digits and leading +
let normalized = phone.replace(/[^+\d]/g, "");
// Ensure it starts with +
if (!normalized.startsWith("+") && normalized.length >= 10) {
// Assume US if no country code and 10 digits
if (normalized.length === 10) {
normalized = `+1${normalized}`;
} else {
normalized = `+${normalized}`;
}
}
return normalized;
}
private normalizePlatform(platform: string): string {
return platform.trim().toLowerCase();
}
// ─────────────────────────────────────────────────────────────────────────────
// MESSAGE INDEXING
// ─────────────────────────────────────────────────────────────────────────────
/**
* Index a message for cross-platform search.
*/
indexMessage(message: Omit<IndexedMessage, "embedding"> & { embedding?: string | null }): void {
const platform = this.normalizePlatform(message.platform);
// Try to resolve the sender to a canonical contact
const contactId = this.resolveContact(platform, message.senderId);
this.stmtInsertMessage.run(
message.id,
message.content,
contactId,
platform,
message.senderId,
message.channelId,
message.timestamp,
message.embedding ?? null,
);
// Also insert into FTS table
if (this.stmtInsertMessageFts && message.content) {
this.stmtInsertMessageFts.run(
message.content,
message.id,
contactId,
platform,
message.senderId,
message.channelId,
message.timestamp,
);
}
// Update last seen timestamp for the sender
if (contactId) {
this.updateIdentityLastSeen(platform, message.senderId);
}
}
/**
* Search indexed messages.
*/
searchMessages(options: MessageSearchOptions): MessageSearchResult[] {
const results: MessageSearchResult[] = [];
if (!options.query) return results;
const normalizedPlatforms = options.platforms?.map((platform) =>
this.normalizePlatform(platform),
);
// Resolve "from" filter to contact IDs
let contactIds: string[] | null = null;
if (options.from) {
const normalized = options.from.trim();
const exact = normalized ? this.getContact(normalized) : null;
const matches = this.searchContacts(options.from, 10);
const ids = new Set<string>(matches.map((m) => m.canonicalId));
if (exact) ids.add(exact.canonicalId);
if (ids.size === 0) {
return results;
}
contactIds = [...ids];
}
// Build query based on FTS availability
const normalizedOptions = {
...options,
platforms: normalizedPlatforms,
};
if (this.ftsAvailable) {
return this.searchMessagesFts(normalizedOptions, contactIds);
}
return this.searchMessagesLike(normalizedOptions, contactIds);
}
private searchMessagesFts(
options: MessageSearchOptions,
contactIds: string[] | null,
): MessageSearchResult[] {
let sql = `
SELECT m.id, m.content, m.contact_id, m.platform, m.sender_id, m.channel_id, m.timestamp, m.embedding,
bm25(messages_fts) as score
FROM messages_fts fts
JOIN indexed_messages m ON fts.id = m.id
WHERE messages_fts MATCH ?
`;
const params: (string | number)[] = [options.query];
if (contactIds && contactIds.length > 0) {
const placeholders = contactIds.map(() => "?").join(",");
sql += ` AND m.contact_id IN (${placeholders})`;
params.push(...contactIds);
}
if (options.platforms && options.platforms.length > 0) {
const placeholders = options.platforms.map(() => "?").join(",");
sql += ` AND m.platform IN (${placeholders})`;
params.push(...options.platforms);
}
if (options.channelId) {
sql += ` AND m.channel_id = ?`;
params.push(options.channelId);
}
if (options.since) {
sql += ` AND m.timestamp >= ?`;
params.push(options.since);
}
if (options.until) {
sql += ` AND m.timestamp <= ?`;
params.push(options.until);
}
sql += ` ORDER BY score LIMIT ?`;
params.push(options.limit ?? 50);
const rows = this.db.prepare(sql).all(...params) as Array<{
id: string;
content: string;
contact_id: string | null;
platform: string;
sender_id: string;
channel_id: string;
timestamp: number;
embedding: string | null;
score: number;
}>;
return rows.map((row) => {
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
return {
message: {
id: row.id,
content: row.content,
contactId: row.contact_id,
platform: row.platform as Platform,
senderId: row.sender_id,
channelId: row.channel_id,
timestamp: row.timestamp,
embedding: row.embedding,
},
contact,
score: Math.abs(row.score), // BM25 returns negative scores
snippet: this.createSnippet(row.content, options.query),
};
});
}
private searchMessagesLike(
options: MessageSearchOptions,
contactIds: string[] | null,
): MessageSearchResult[] {
let sql = `
SELECT id, content, contact_id, platform, sender_id, channel_id, timestamp, embedding
FROM indexed_messages
WHERE content LIKE ?
`;
const params: (string | number)[] = [`%${options.query}%`];
if (contactIds && contactIds.length > 0) {
const placeholders = contactIds.map(() => "?").join(",");
sql += ` AND contact_id IN (${placeholders})`;
params.push(...contactIds);
}
if (options.platforms && options.platforms.length > 0) {
const placeholders = options.platforms.map(() => "?").join(",");
sql += ` AND platform IN (${placeholders})`;
params.push(...options.platforms);
}
if (options.channelId) {
sql += ` AND channel_id = ?`;
params.push(options.channelId);
}
if (options.since) {
sql += ` AND timestamp >= ?`;
params.push(options.since);
}
if (options.until) {
sql += ` AND timestamp <= ?`;
params.push(options.until);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
params.push(options.limit ?? 50);
const rows = this.db.prepare(sql).all(...params) as Array<{
id: string;
content: string;
contact_id: string | null;
platform: string;
sender_id: string;
channel_id: string;
timestamp: number;
embedding: string | null;
}>;
return rows.map((row) => {
const contact = row.contact_id ? this.getContact(row.contact_id) : null;
return {
message: {
id: row.id,
content: row.content,
contactId: row.contact_id,
platform: row.platform as Platform,
senderId: row.sender_id,
channelId: row.channel_id,
timestamp: row.timestamp,
embedding: row.embedding,
},
contact,
score: 1.0, // Simple LIKE doesn't provide scoring
snippet: this.createSnippet(row.content, options.query),
};
});
}
/**
* Create a snippet with the query highlighted.
*/
private createSnippet(content: string, query: string, maxLength = 200): string {
const lowerContent = content.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerContent.indexOf(lowerQuery);
if (index === -1) {
return content.slice(0, maxLength) + (content.length > maxLength ? "..." : "");
}
// Center the snippet around the match
const contextBefore = 50;
const contextAfter = 100;
let start = Math.max(0, index - contextBefore);
let end = Math.min(content.length, index + query.length + contextAfter);
// Adjust to word boundaries if possible
if (start > 0) {
const spaceIndex = content.lastIndexOf(" ", start + 10);
if (spaceIndex > start - 20) start = spaceIndex + 1;
}
if (end < content.length) {
const spaceIndex = content.indexOf(" ", end - 10);
if (spaceIndex !== -1 && spaceIndex < end + 20) end = spaceIndex;
}
let snippet = content.slice(start, end);
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet = snippet + "...";
return snippet;
}
// ─────────────────────────────────────────────────────────────────────────────
// STATISTICS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get statistics about the contact store.
*/
getStats(): {
contacts: number;
identities: number;
messages: number;
platforms: Record<string, number>;
} {
const contactCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM contacts`).get() as { count: number }
).count;
const identityCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM platform_identities`).get() as {
count: number;
}
).count;
const messageCount = (
this.db.prepare(`SELECT COUNT(*) as count FROM indexed_messages`).get() as { count: number }
).count;
const platformRows = this.db
.prepare(`SELECT platform, COUNT(*) as count FROM platform_identities GROUP BY platform`)
.all() as Array<{ platform: string; count: number }>;
const platforms: Record<string, number> = {};
for (const row of platformRows) {
platforms[row.platform] = row.count;
}
return {
contacts: contactCount,
identities: identityCount,
messages: messageCount,
platforms,
};
}
}
// Singleton instance
let _store: ContactStore | null = null;
let _storeConfig: { dbPath?: string; stateDir?: string } = {};
export function configureContactStore(params: { dbPath?: string; stateDir?: string }): void {
_storeConfig = params;
if (_store) {
_store.close();
_store = null;
}
}
/**
* Get the global contact store instance.
*/
export function getContactStore(): ContactStore {
if (!_store) {
_store = ContactStore.open(_storeConfig);
}
return _store;
}
/**
* Close the global contact store instance.
*/
export function closeContactStore(): void {
if (_store) {
_store.close();
_store = null;
}
}
@@ -0,0 +1,165 @@
import type { ChannelId } from "clawdbot/plugin-sdk";
/**
* Types for the unified contact graph.
*
* The contact graph allows cross-platform identity resolution:
* - Multiple platform identities can be linked to a single canonical contact
* - Enables unified message search across all messaging channels
*/
/**
* A canonical contact in the unified contact graph.
* Represents a single person who may have multiple platform identities.
*/
export type Contact = {
/** Unique canonical identifier (e.g., "sarah-jones-abc123") */
canonicalId: string;
/** Primary display name for this contact */
displayName: string;
/** Alternative names/aliases for this contact */
aliases: string[];
/** When this contact was first created */
createdAt: number;
/** When this contact was last updated */
updatedAt: number;
};
/**
* Supported messaging platforms.
*/
export type Platform = ChannelId;
/**
* A platform-specific identity linked to a canonical contact.
* Each person may have one or more of these across different platforms.
*/
export type PlatformIdentity = {
/** Database row ID */
id: number;
/** Reference to the canonical contact */
contactId: string;
/** Which platform this identity belongs to */
platform: Platform;
/** Platform-specific user identifier (JID, user ID, etc.) */
platformId: string;
/** Platform-specific username (@handle) if available */
username: string | null;
/** E.164 phone number if available */
phone: string | null;
/** Platform-specific display name */
displayName: string | null;
/** When this identity was last seen in a message */
lastSeenAt: number | null;
};
/**
* Input for creating a new platform identity.
*/
export type PlatformIdentityInput = Omit<PlatformIdentity, "id">;
/**
* Result of a contact search/lookup.
*/
export type ContactWithIdentities = Contact & {
/** All platform identities associated with this contact */
identities: PlatformIdentity[];
};
/**
* Auto-link match confidence levels.
*/
export type LinkConfidence = "high" | "medium" | "low";
/**
* A suggested link between platform identities.
*/
export type LinkSuggestion = {
/** The source identity that was analyzed */
sourceIdentity: PlatformIdentity;
/** The target identity to potentially link with */
targetIdentity: PlatformIdentity;
/** Why this link is suggested */
reason: "phone_match" | "email_match" | "name_similarity";
/** How confident we are in this match */
confidence: LinkConfidence;
/** Score for ranking (0-1) */
score: number;
};
/**
* Contact import result from a platform.
*/
export type ImportResult = {
platform: Platform;
imported: number;
linked: number;
errors: string[];
};
/**
* Options for contact search.
*/
export type ContactSearchOptions = {
/** Search query (matches name, aliases, username) */
query?: string;
/** Filter by platform */
platform?: Platform;
/** Maximum results to return */
limit?: number;
};
/**
* A message indexed for cross-platform search.
*/
export type IndexedMessage = {
/** Unique message ID */
id: string;
/** Message content (may be empty for media) */
content: string;
/** Reference to canonical contact ID of sender */
contactId: string | null;
/** Platform this message came from */
platform: Platform;
/** Platform-specific sender ID */
senderId: string;
/** Platform-specific channel/chat ID */
channelId: string;
/** When the message was sent */
timestamp: number;
/** Optional: pre-computed embedding for semantic search */
embedding: string | null;
};
/**
* Options for message search.
*/
export type MessageSearchOptions = {
/** Text query to search for */
query: string;
/** Filter by sender (canonical contact ID or platform identity) */
from?: string;
/** Filter by platform */
platforms?: Platform[];
/** Filter by channel/chat ID */
channelId?: string;
/** Filter messages after this timestamp */
since?: number;
/** Filter messages before this timestamp */
until?: number;
/** Maximum results */
limit?: number;
};
/**
* Message search result.
*/
export type MessageSearchResult = {
message: IndexedMessage;
/** The contact who sent this message (if resolved) */
contact: Contact | null;
/** Search relevance score */
score: number;
/** Snippet with highlighted match */
snippet: string;
};
@@ -0,0 +1,104 @@
import { createHash, randomUUID } from "node:crypto";
import { importContactFromMessage, getContactStore } from "../contacts/index.js";
import type { Platform } from "../contacts/types.js";
function normalizePlatform(value: string): Platform {
return value.trim().toLowerCase() as Platform;
}
function resolveMessageId(params: {
messageId?: string;
platform: string;
senderId: string;
timestamp?: number;
content: string;
}): string {
if (params.messageId) {
return `${params.platform}:${params.messageId}`;
}
if (!params.timestamp) return randomUUID();
const hash = createHash("sha1");
hash.update(params.platform);
hash.update("|");
hash.update(params.senderId);
hash.update("|");
hash.update(String(params.timestamp));
hash.update("|");
hash.update(params.content);
return hash.digest("hex");
}
export function indexInboundMessage(params: {
event: {
from: string;
content: string;
timestamp?: number;
metadata?: Record<string, unknown>;
};
ctx: {
channelId: string;
accountId?: string;
conversationId?: string;
};
logger?: { warn?: (message: string) => void };
}): void {
const { event, ctx, logger } = params;
const channelId = (ctx.channelId ?? "").trim();
if (!channelId) return;
const metadata = event.metadata ?? {};
const meta = metadata as {
senderId?: string;
messageId?: string;
senderUsername?: string;
senderE164?: string;
senderName?: string;
};
const senderId = String(meta.senderId ?? event.from ?? "").trim();
if (!senderId) return;
const content = typeof event.content === "string" ? event.content.trim() : "";
const platform = normalizePlatform(channelId);
const timestamp =
typeof event.timestamp === "number" && Number.isFinite(event.timestamp)
? event.timestamp
: Date.now();
const metadataMessageId = meta.messageId;
const messageId = resolveMessageId({
messageId: typeof metadataMessageId === "string" ? metadataMessageId : undefined,
platform,
senderId,
timestamp,
content,
});
const conversationId = (ctx.conversationId ?? "").trim() || senderId;
try {
const store = getContactStore();
importContactFromMessage(store, {
platform,
platformId: senderId,
username: typeof meta.senderUsername === "string" ? meta.senderUsername : null,
phone: typeof meta.senderE164 === "string" ? meta.senderE164 : null,
displayName: typeof meta.senderName === "string" ? meta.senderName : null,
});
if (!content) return;
store.indexMessage({
id: messageId,
content,
contactId: null,
platform,
senderId,
channelId: conversationId,
timestamp,
embedding: null,
});
} catch (err) {
logger?.warn?.(
`[contacts-search] failed indexing message: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
+22
View File
@@ -0,0 +1,22 @@
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
export function requireNodeSqlite(): typeof import("node:sqlite") {
const onWarning = (warning: Error & { name?: string; message?: string }) => {
if (
warning.name === "ExperimentalWarning" &&
warning.message?.includes("SQLite is an experimental feature")
) {
return;
}
process.stderr.write(`${warning.stack ?? warning.toString()}\n`);
};
process.on("warning", onWarning);
try {
return require("node:sqlite") as typeof import("node:sqlite");
} finally {
process.off("warning", onWarning);
}
}
+2
View File
@@ -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+>"],
+2
View File
@@ -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: {
+97
View File
@@ -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.
+21
View File
@@ -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" }
}
}
}
+7
View File
@@ -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 });
}
+11
View File
@@ -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);
});
});
+218
View File
@@ -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
}
}
}
},
};
}
+5 -2
View File
@@ -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:*"
}
}
+2 -1
View File
@@ -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 }) =>
+2 -1
View File
@@ -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(),
+28 -1
View File
@@ -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;
}
+2
View File
@@ -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). */
+4
View File
@@ -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: {
+46
View File
@@ -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;
+1 -1
View File
@@ -65,7 +65,7 @@ export async function probeMSTeams(cfg?: MSTeamsConfig): Promise<ProbeMSTeamsRes
try {
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
await tokenProvider.getAccessToken("https://api.botframework.com/.default");
await tokenProvider.getAccessToken("https://api.botframework.com");
let graph:
| {
ok: boolean;
+2
View File
@@ -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(),
+16 -1
View File
@@ -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;
+2
View File
@@ -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. */
+2
View File
@@ -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 }) =>
+2
View File
@@ -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",
+5
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
{
"id": "tlon",
"channels": [
"tlon"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+18
View File
@@ -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;
+30
View File
@@ -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"
}
}
+379
View File
@@ -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,
});
},
},
};
+43
View File
@@ -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);
+71
View File
@@ -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 [];
}
}
+87
View File
@@ -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);
}
+501
View File
@@ -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,
};
}
+83
View File
@@ -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