Compare commits

...
Author SHA1 Message Date
Peter Steinberger 1561b1c491 fix: debounce inbound messages across channels (#971) (thanks @juanpablodlc) 2026-01-15 23:06:22 +00:00
Peter Steinberger 57d3c8572f chore: wip inbound debounce 2026-01-15 22:58:57 +00:00
juanpablodlc c609be185e feat(whatsapp): add debounceMs for batching rapid messages
Add a `debounceMs` configuration option to WhatsApp channel settings
that batches rapid consecutive messages from the same sender into a
single response. This prevents triggering separate agent runs for
each message when a user sends multiple short messages in quick
succession (e.g., "Hey!", "how are you?", "I was wondering...").

Changes:
- Add `debounceMs` config to WhatsAppConfig and WhatsAppAccountConfig
- Implement message buffering in `monitorWebInbox` with:
  - Map-based buffer keyed by sender (DM) or chat ID (groups)
  - Debounce timer that resets on each new message
  - Message combination with newline separator
  - Single message optimization (no modification if only one message)
- Wire `debounceMs` through account resolution and monitor tuning
- Add UI hints and schema documentation

Usage example:
{
  "channels": {
    "whatsapp": {
      "debounceMs": 5000  // 5 second window
    }
  }
}

Default behavior: `debounceMs: 0` (disabled by default)

Verified: All existing tests pass (3204 tests), TypeScript compilation
succeeds with no errors.

Implemented with assistance from AI coding tools.

Closes #967
2026-01-15 22:57:49 +00:00
Peter Steinberger d00f2d9c0c test: cover model picker ordering and openrouter index 2026-01-15 22:56:11 +00:00
Peter Steinberger 8b89980a89 feat(date-time): standardize time context and tool timestamps 2026-01-15 22:27:06 +00:00
634a429c50 fix(model-picker): list each provider/model combo separately (#970)
* fix(model-picker): list each provider/model combo separately

Previously, /model grouped models by name and showed all providers
that offer the same model (e.g. 'claude-sonnet-4-5 — anthropic, google-antigravity').
This was confusing because:
1. Users couldn't tell which provider would be used when selecting by number
2. The display implied choice between providers but selection was automatic

Now each provider/model combination is listed separately so users
can explicitly select the exact provider they want.

- Remove model grouping in buildModelPickerItems
- Display format changed from 'model — providers' to 'provider/model'
- pickProviderForModel now returns the single provider directly
- Updated tests to reflect new behavior

* fix: simplify model picker entries (#970) (thanks @mcinteerj)

---------

Co-authored-by: Keith the Silly Goose <keith@42bolton.macnet.nz>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 22:20:11 +00:00
Peter Steinberger bf90815b9e Merge pull request #969 from bjesuiter/fix/launchd-label-resolution
fix: unify daemon service label resolution with env parameter
2026-01-15 22:12:12 +00:00
Peter Steinberger 2b113c4d6c chore: update changelog (#969) (thanks @bjesuiter) 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter 7f6a288bd3 docs: clarify multi-gateway rescue bot guidance 2026-01-15 22:10:27 +00:00
Benjamin Jesuiter daf471c450 fix: unify daemon service label resolution with env 2026-01-15 22:10:27 +00:00
Peter Steinberger cb78fa46a1 fix: make node-llama-cpp optional 2026-01-15 18:37:02 +00:00
Shadow 316e8b2eb2 Discord: fix allowlist gating. Closes #961 2026-01-15 11:53:13 -06:00
Peter Steinberger 4de81ed6c4 docs(changelog): move browser relay fixes to 2026.1.15 2026-01-15 17:26:54 +00:00
Peter Steinberger b6fb24f6d2 Merge pull request #964 from bohdanpodvirnyi/feat/telegram-reactions
feat(telegram): add bidirectional emoji reactions support
2026-01-15 17:21:51 +00:00
Peter Steinberger 2b1c26f900 fix: refine telegram reactions (#964) (thanks @bohdanpodvirnyi) 2026-01-15 17:20:17 +00:00
Peter Steinberger 47ea9356d1 Merge pull request #959 from rdev/rdev/finally-fix-antigravity-claude
Fix antigravity claude
2026-01-15 17:10:04 +00:00
Bohdan Podvirnyi f12c1b391f fix: lint errors 2026-01-15 17:08:31 +00:00
Peter Steinberger 05658b6609 fix: tighten command arg value types 2026-01-15 17:08:09 +00:00
Bohdan Podvirnyi dfb6630de1 fix: remove dup definitions + add reaction config 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi eb7656d68c fix: lint errors 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi 0e1dcf9cb4 feat: added capability for clawdbot to react 2026-01-15 17:07:38 +00:00
Bohdan Podvirnyi d05c3d0659 feat: make telegram reactions visible to clawdbot 2026-01-15 17:07:38 +00:00
Peter Steinberger 36292d3fbb fix: preserve Antigravity Claude signatures (#959) (thanks @rdev) 2026-01-15 17:06:39 +00:00
Max 1ae344d8a6 Fix antigravity claude 2026-01-15 17:06:39 +00:00
Peter Steinberger 01c43b0b0c fix: avoid base string coercion in auto-reply formatting 2026-01-15 17:03:34 +00:00
Peter Steinberger fc4aa9a683 Merge pull request #954 from roshanasingh4/fix/946-openai-completions-trim
Fix model fallback crash on undefined provider/model (#946)
2026-01-15 16:59:19 +00:00
Peter Steinberger c043e9767f fix: default model fallback when provider/model missing (#954) (thanks @roshanasingh4) 2026-01-15 16:58:41 +00:00
Peter Steinberger 8b48299d8f Merge pull request #953 from roshanasingh4/fix/cli-quick-reference-system-prompt
Fix system prompt: prevent invented CLI commands
2026-01-15 16:55:52 +00:00
Roshan Singh 0f27cff247 Fix model fallback crash on undefined provider/model (#946) 2026-01-15 16:50:46 +00:00
Roshan Singh d0455f2683 fix(system-prompt): add CLI quick reference to prevent invented commands 2026-01-15 11:43:22 +00:00
115 changed files with 5552 additions and 683 deletions

No files matched your search

+16 -5
View File
@@ -2,7 +2,21 @@
## 2026.1.15 (unreleased)
- TBD
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
## 2026.1.14-1
@@ -23,10 +37,6 @@
- Browser: ship a built-in `chrome` profile for extension relay and start the relay automatically when running locally.
- Browser: default `browser.defaultProfile` to `chrome` (existing Chrome takeover mode).
- Browser: add `clawdbot browser extension install/path` and copy extension path to clipboard.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Control UI: show raw any-map entries in config views; move Docs link into the left nav.
#### Plugins
@@ -117,6 +127,7 @@
- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: preserve Antigravity Claude signatures and skip Gemini downgrades. (#959) — thanks @rdev.
#### macOS / Apps
- macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.
+3 -1
View File
@@ -191,7 +191,7 @@ Notes:
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or explicitly list channels under `channels.discord.guilds.<id>.channels`.
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
@@ -352,6 +352,7 @@ Allowlist matching notes:
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
Native command notes:
- The registered commands mirror Clawdbot’s chat commands.
@@ -362,6 +363,7 @@ The agent can call `discord` with actions like:
- `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
+1
View File
@@ -327,4 +327,5 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Read/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Slack `ts`.
- Attachments are downloaded to the media store when permitted and under the size limit.
+45
View File
@@ -297,6 +297,49 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Reaction notifications
**How reactions work:**
Telegram reactions arrive as **separate `message_reaction` events**, not as properties in message payloads. When a user adds a reaction, Clawdbot:
1. Receives the `message_reaction` update from Telegram API
2. Converts it to a **system event** with format: `"Telegram reaction added: {emoji} by {user} on msg {id}"`
3. Enqueues the system event using the **same session key** as regular messages
4. When the next message arrives in that conversation, system events are drained and prepended to the agent's context
The agent sees reactions as **system notifications** in the conversation history, not as message metadata.
**Configuration:**
- `channels.telegram.reactionNotifications`: Controls which reactions trigger notifications
- `"off"` — ignore all reactions (default when not set)
- `"own"` — notify when users react to bot messages (best-effort; in-memory)
- `"all"` — notify for all reactions
- `channels.telegram.reactionLevel`: Controls agent's reaction capability
- `"off"` — agent cannot react to messages
- `"ack"` — bot sends acknowledgment reactions (👀 while processing) (default)
- `"minimal"` — agent can react sparingly (guideline: 1 per 5-10 exchanges)
- `"extensive"` — agent can react liberally when appropriate
**Forum groups:** Reactions in forum groups include `message_thread_id` and use session keys like `agent:main:telegram:group:{chatId}:topic:{threadId}`. This ensures reactions and messages in the same topic stay together.
**Example config:**
```json5
{
channels: {
telegram: {
reactionNotifications: "all", // See all reactions
reactionLevel: "minimal" // Agent can react sparingly
}
}
}
```
**Requirements:**
- Telegram bots must explicitly request `message_reaction` in `allowed_updates` (configured automatically by Clawdbot)
- For webhook mode, reactions are included in the webhook `allowed_updates`
- For polling mode, reactions are included in the `getUpdates` `allowed_updates`
## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
@@ -360,6 +403,8 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `off` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `ack` when not set).
Related global options:
- `agents.list[].groupChat.mentionPatterns` (mention gating patterns).
+1 -1
View File
@@ -78,7 +78,7 @@ clawdbot gateway health --url ws://127.0.0.1:18789
- your configured remote gateway (if set), and
- localhost (loopback) **even if remote is configured**.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use profiles for redundancy, but most installs still run a single gateway.
If multiple gateways are reachable, it prints all of them. Multiple gateways are supported when you use isolated profiles/ports (e.g., a rescue bot), but most installs still run a single gateway.
```bash
clawdbot gateway status
+10
View File
@@ -33,6 +33,16 @@ Channels can redeliver the same message after reconnects. Clawdbot keeps a
short-lived cache keyed by channel/account/peer/session/message id so duplicate
deliveries do not trigger another agent run.
## Inbound debouncing
Rapid consecutive messages from the **same sender** can be batched into a single
agent turn via `messages.inbound`. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
Notes:
- Debounce applies to **text-only** messages; media/attachments flush immediately.
- Control commands bypass debouncing so they remain standalone.
## Sessions and devices
Sessions are owned by the gateway, not by clients.
+12 -5
View File
@@ -20,7 +20,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Workspace**: working directory (`agents.defaults.workspace`).
- **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Time**: UTC default + the user’s local time (already converted).
- **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line).
@@ -46,12 +46,19 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling
The Time line is compact and explicit:
The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
- Assume timestamps are **UTC** unless stated.
- The listed **user time** is already converted to `agents.defaults.userTimezone` (if set).
- The user’s **local time** (already converted).
- The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Use `agents.defaults.userTimezone` in `~/.clawdbot/clawdbot.json` to change the user time zone.
Configure with:
- `agents.defaults.userTimezone`
- `agents.defaults.timeFormat` (`auto` | `12` | `24`)
See [Date & Time](/date-time) for full behavior details.
## Skills
+14 -5
View File
@@ -19,10 +19,15 @@ Inbound messages are wrapped in an envelope like:
The timestamp in the envelope is **always UTC**, with minutes precision.
## Tool payloads (raw provider data)
## Tool payloads (raw provider data + normalized fields)
Tool calls (`channels.discord.readMessages`, `channels.slack.readMessages`, etc.) return **raw provider timestamps**.
These are typically UTC ISO strings (Discord) or UTC epoch strings (Slack). We do not rewrite them.
We also attach normalized fields for consistency:
- `timestampMs` (UTC epoch milliseconds)
- `timestampUtc` (ISO 8601 UTC string)
Raw provider fields are preserved.
## User timezone for the system prompt
@@ -31,10 +36,14 @@ unset, Clawdbot resolves the **host timezone at runtime** (no config write).
```json5
{
agent: { userTimezone: "America/Chicago" }
agents: { defaults: { userTimezone: "America/Chicago" } }
}
```
The system prompt includes:
- `User timezone: America/Chicago`
- `Current user time: 2026-01-05 15:26`
- `Current Date & Time` section with local time and timezone
- `Time format: 12-hour` or `24-hour`
You can control the prompt format with `agents.defaults.timeFormat` (`auto` | `12` | `24`).
See [Date & Time](/date-time) for the full behavior and examples.
+85
View File
@@ -0,0 +1,85 @@
---
summary: "Date and time handling across envelopes, prompts, tools, and connectors"
read_when:
- You are changing how timestamps are shown to the model or users
- You are debugging time formatting in messages or system prompt output
---
# Date & Time
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
We avoid rewriting provider timestamps so tools keep their native semantics.
## Message envelopes (UTC)
Inbound messages are wrapped with a UTC timestamp (minute precision):
```
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **always UTC**, regardless of the host timezone.
## System prompt: Current Date & Time
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
```
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
```
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
## System event lines (UTC)
Queued system events inserted into agent context are prefixed with a UTC timestamp:
```
System: [2026-01-12T20:19:17Z] Model switched.
```
### Configure user timezone + format
```json5
{
agents: {
defaults: {
userTimezone: "America/Chicago",
timeFormat: "auto" // auto | 12 | 24
}
}
}
```
- `userTimezone` sets the **user-local timezone** for prompt context.
- `timeFormat` controls **12h/24h display** in the prompt. `auto` follows OS prefs.
## Time format detection (auto)
When `timeFormat: "auto"`, Clawdbot inspects the OS preference (macOS/Windows)
and falls back to locale formatting. The detected value is **cached per process**
to avoid repeated system calls.
## Tool payloads + connectors (raw provider time + normalized fields)
Channel tools return **provider-native timestamps** and add normalized fields for consistency:
- `timestampMs`: epoch milliseconds (UTC)
- `timestampUtc`: ISO 8601 UTC string
Raw provider fields are preserved so nothing is lost.
- Slack: epoch-like strings from the API
- Discord: UTC ISO timestamps
- Telegram/WhatsApp: provider-specific numeric/ISO timestamps
If you need local time, convert it downstream using the known timezone.
## Related docs
- [System Prompt](/concepts/system-prompt)
- [Timezones](/concepts/timezone)
- [Messages](/concepts/messages)
+37 -1
View File
@@ -822,6 +822,31 @@ Controls how inbound messages behave when an agent run is already active.
}
```
### `messages.inbound`
Debounce rapid inbound messages from the **same sender** so multiple back-to-back
messages become a single agent turn. Debouncing is scoped per channel + conversation
and uses the most recent message for reply threading/IDs.
```json5
{
messages: {
inbound: {
debounceMs: 2000, // 0 disables
byChannel: {
whatsapp: 5000,
slack: 1500,
discord: 1500
}
}
}
}
```
Notes:
- Debounce batches **text-only** messages; media/attachments flush immediately.
- Control commands (e.g. `/queue`, `/new`) bypass debouncing so they stay standalone.
### `commands` (chat command handling)
Controls how chat commands are enabled across connectors.
@@ -1211,6 +1236,17 @@ message envelopes). If unset, Clawdbot uses the host timezone at runtime.
}
```
### `agents.defaults.timeFormat`
Controls the **time format** shown in the system prompt’s Current Date & Time section.
Default: `auto` (OS preference).
```json5
{
agents: { defaults: { timeFormat: "auto" } } // auto | 12 | 24
}
```
### `messages`
Controls inbound/outbound prefixes and optional ack reactions.
@@ -2512,7 +2548,7 @@ Requires full Gateway restart:
### Multi-instance isolation
To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
To run multiple gateways on one host (for redundancy or a rescue bot), isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agents.defaults.workspace` (memories)
+1 -1
View File
@@ -16,7 +16,7 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
## Terms
- **Gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs channels.
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
+1 -1
View File
@@ -9,7 +9,7 @@ read_when:
Last updated: 2025-12-11
## Why
- Ensure only one gateway instance runs per host.
- Ensure only one gateway instance runs per base port on the same host; additional gateways must use isolated profiles and unique ports.
- Survive crashes/SIGKILL without leaving stale lock files.
- Fail fast with a clear error when the control port is already occupied.
+3 -1
View File
@@ -63,6 +63,8 @@ Install metadata is embedded in the service config:
- `CLAWDBOT_SERVICE_KIND=gateway`
- `CLAWDBOT_SERVICE_VERSION=<version>`
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
@@ -205,7 +207,7 @@ Notes:
- `daemon status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are Clawdbot profile services.
We still recommend **one gateway per machine** unless you need redundant profiles.
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `clawdbot daemon uninstall` (current service) and `clawdbot doctor` (legacy migrations).
- `daemon install` is a no-op when already installed; use `clawdbot daemon install --force` to reinstall (profile/env/path changes).
+33 -1
View File
@@ -6,7 +6,7 @@ read_when:
---
# Multiple Gateways (same host)
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy, run separate Gateways. Both are supported.
Most setups should use one Gateway because a single Gateway can handle multiple messaging connections and agents. If you need stronger isolation or redundancy (e.g., a rescue bot), run separate Gateways with isolated profiles/ports.
## Isolation checklist (required)
- `CLAWDBOT_CONFIG_PATH` — per-instance config file
@@ -37,6 +37,38 @@ clawdbot --profile main daemon install
clawdbot --profile rescue daemon install
```
## Rescue-bot guide
Run a second Gateway on the same host with its own:
- profile/config
- state dir
- workspace
- base port (plus derived ports)
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
### How to install (rescue bot)
```bash
# Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
clawdbot onboard
clawdbot daemon install
# Rescue bot (isolated profile + ports)
clawdbot --profile rescue onboard
# Notes:
# - workspace name will be postfixed with -rescue per default
# - Port should be at least 18789 + 20 Ports,
# better choose completely different base port, like 19789,
# - rest of the onboarding is the same as normal
# To install the daemon (if not happened automatically during onboarding)
clawdbot --profile rescue daemon install
```
## Port mapping (derived)
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
+1 -1
View File
@@ -26,7 +26,7 @@ Flow example (Telegram → node):
- Node returns the result; Gateway replies back out to Telegram.
Notes:
- **Nodes do not run the gateway daemon.** Only one gateway should run per host.
- **Nodes do not run the gateway daemon.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
- macOS app “node mode” is just a node client over the Bridge.
## SSH tunnel (CLI + tools)
+1 -1
View File
@@ -66,7 +66,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
## Network model
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session.
- **One Gateway per host (recommended)**: it is the only process allowed to own the WhatsApp Web session. If you need a rescue bot or strict isolation, run multiple gateways with isolated profiles and ports; see [Multiple gateways](/gateway/multiple-gateways).
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- The wizard now generates a gateway token by default (even for loopback).
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
+4 -3
View File
@@ -665,7 +665,7 @@ Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
### Do nodes run a gateway daemon?
No. Only **one gateway** should run per host. Nodes are peripherals that connect
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
@@ -1542,10 +1542,11 @@ See [Streaming](/concepts/streaming).
### Discord doesn’t reply in my server even with `requireMention: false`. Why?
`requireMention` only controls mention‑gating **after** the channel passes allowlists.
By default `channels.discord.groupPolicy` is **allowlist**, so guild channels must be explicitly enabled.
By default `channels.discord.groupPolicy` is **allowlist**, so guilds must be explicitly enabled.
If you set `channels.discord.guilds.<guildId>.channels`, only the listed channels are allowed; omit it to allow all channels in the guild.
Fix checklist:
1) Set `channels.discord.groupPolicy: "open"` **or** add the guild/channel allowlist.
1) Set `channels.discord.groupPolicy: "open"` **or** add a guild allowlist entry (and optionally a channel allowlist).
2) Use **numeric channel IDs** in `channels.discord.guilds.<guildId>.channels`.
3) Put `requireMention: false` **under** `channels.discord.guilds` (global or per‑channel).
Top‑level `channels.discord.requireMention` is not a supported key.
+3 -1
View File
@@ -169,7 +169,6 @@
"json5": "^2.2.3",
"long": "5.3.2",
"markdown-it": "^14.1.0",
"node-llama-cpp": "3.14.5",
"osc-progress": "^0.2.0",
"playwright-core": "1.57.0",
"proper-lockfile": "^4.1.2",
@@ -181,6 +180,9 @@
"ws": "^8.19.0",
"zod": "^4.3.5"
},
"optionalDependencies": {
"node-llama-cpp": "3.14.5"
},
"devDependencies": {
"@grammyjs/types": "^3.23.0",
"@lit-labs/signals": "^0.2.0",
+175 -60
View File
@@ -122,9 +122,6 @@ importers:
markdown-it:
specifier: ^14.1.0
version: 14.1.0
node-llama-cpp:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
osc-progress:
specifier: ^0.2.0
version: 0.2.0
@@ -237,6 +234,10 @@ importers:
wireit:
specifier: ^0.14.12
version: 0.14.12
optionalDependencies:
node-llama-cpp:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
extensions/matrix:
dependencies:
@@ -4942,7 +4943,8 @@ snapshots:
hono: 4.11.4
optional: true
'@huggingface/jinja@0.5.3': {}
'@huggingface/jinja@0.5.3':
optional: true
'@img/colour@1.0.0': {}
@@ -5081,8 +5083,10 @@ snapshots:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
'@kwsites/promise-deferred@1.1.1': {}
'@kwsites/promise-deferred@1.1.1':
optional: true
'@lit-labs/signals@0.2.0':
dependencies:
@@ -5338,6 +5342,7 @@ snapshots:
'@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6)
'@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0
optional: true
'@octokit/auth-app@8.1.2':
dependencies:
@@ -5349,6 +5354,7 @@ snapshots:
toad-cache: 3.7.0
universal-github-app-jwt: 2.2.2
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-app@9.0.3':
dependencies:
@@ -5357,6 +5363,7 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-device@8.0.3':
dependencies:
@@ -5364,6 +5371,7 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-oauth-user@6.0.2':
dependencies:
@@ -5372,13 +5380,16 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/auth-token@6.0.0': {}
'@octokit/auth-token@6.0.0':
optional: true
'@octokit/auth-unauthenticated@7.0.3':
dependencies:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
optional: true
'@octokit/core@7.0.6':
dependencies:
@@ -5389,17 +5400,20 @@ snapshots:
'@octokit/types': 16.0.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/endpoint@11.0.2':
dependencies:
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/graphql@9.0.3':
dependencies:
'@octokit/request': 10.0.7
'@octokit/types': 16.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/oauth-app@8.0.3':
dependencies:
@@ -5411,8 +5425,10 @@ snapshots:
'@octokit/oauth-methods': 6.0.2
'@types/aws-lambda': 8.10.159
universal-user-agent: 7.0.3
optional: true
'@octokit/oauth-authorization-url@8.0.0': {}
'@octokit/oauth-authorization-url@8.0.0':
optional: true
'@octokit/oauth-methods@6.0.2':
dependencies:
@@ -5420,24 +5436,30 @@ snapshots:
'@octokit/request': 10.0.7
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
optional: true
'@octokit/openapi-types@27.0.0': {}
'@octokit/openapi-types@27.0.0':
optional: true
'@octokit/openapi-webhooks-types@12.1.0': {}
'@octokit/openapi-webhooks-types@12.1.0':
optional: true
'@octokit/plugin-paginate-graphql@6.0.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
optional: true
'@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
optional: true
'@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
optional: true
'@octokit/plugin-retry@8.0.3(@octokit/core@7.0.6)':
dependencies:
@@ -5445,16 +5467,19 @@ snapshots:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
bottleneck: 2.19.5
optional: true
'@octokit/plugin-throttling@11.0.3(@octokit/core@7.0.6)':
dependencies:
'@octokit/core': 7.0.6
'@octokit/types': 16.0.0
bottleneck: 2.19.5
optional: true
'@octokit/request-error@7.1.0':
dependencies:
'@octokit/types': 16.0.0
optional: true
'@octokit/request@10.0.7':
dependencies:
@@ -5463,18 +5488,22 @@ snapshots:
'@octokit/types': 16.0.0
fast-content-type-parse: 3.0.0
universal-user-agent: 7.0.3
optional: true
'@octokit/types@16.0.0':
dependencies:
'@octokit/openapi-types': 27.0.0
optional: true
'@octokit/webhooks-methods@6.0.0': {}
'@octokit/webhooks-methods@6.0.0':
optional: true
'@octokit/webhooks@14.2.0':
dependencies:
'@octokit/openapi-webhooks-types': 12.1.0
'@octokit/request-error': 7.1.0
'@octokit/webhooks-methods': 6.0.0
optional: true
'@oxc-project/types@0.107.0': {}
@@ -6111,7 +6140,8 @@ snapshots:
'@thi.ng/errors@2.6.0':
optional: true
'@tinyhttp/content-disposition@2.2.2': {}
'@tinyhttp/content-disposition@2.2.2':
optional: true
'@tokenizer/inflate@0.4.1':
dependencies:
@@ -6127,7 +6157,8 @@ snapshots:
tslib: 2.8.1
optional: true
'@types/aws-lambda@8.10.159': {}
'@types/aws-lambda@8.10.159':
optional: true
'@types/body-parser@1.19.6':
dependencies:
@@ -6426,7 +6457,8 @@ snapshots:
another-json@0.2.0: {}
ansi-escapes@6.2.1: {}
ansi-escapes@6.2.1:
optional: true
ansi-regex@5.0.1: {}
@@ -6445,12 +6477,14 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
aproba@2.1.0: {}
aproba@2.1.0:
optional: true
are-we-there-yet@3.0.1:
dependencies:
delegates: 1.0.0
readable-stream: 3.6.2
optional: true
argparse@2.0.1: {}
@@ -6469,6 +6503,7 @@ snapshots:
async-retry@1.3.3:
dependencies:
retry: 0.13.1
optional: true
asynckit@0.4.0: {}
@@ -6508,7 +6543,8 @@ snapshots:
base64-js@1.5.1: {}
before-after-hook@4.0.0: {}
before-after-hook@4.0.0:
optional: true
bignumber.js@9.3.1: {}
@@ -6593,7 +6629,8 @@ snapshots:
chalk@5.6.2: {}
chmodrp@1.0.2: {}
chmodrp@1.0.2:
optional: true
chokidar@3.6.0:
dependencies:
@@ -6611,7 +6648,8 @@ snapshots:
dependencies:
readdirp: 5.0.0
chownr@2.0.0: {}
chownr@2.0.0:
optional: true
chownr@3.0.0: {}
@@ -6621,7 +6659,8 @@ snapshots:
mitt: 3.0.1
zod: 3.25.76
ci-info@4.3.1: {}
ci-info@4.3.1:
optional: true
class-variance-authority@0.7.1:
dependencies:
@@ -6630,6 +6669,7 @@ snapshots:
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
optional: true
cli-highlight@2.1.11:
dependencies:
@@ -6640,7 +6680,8 @@ snapshots:
parse5-htmlparser2-tree-adapter: 6.0.1
yargs: 16.2.0
cli-spinners@2.9.2: {}
cli-spinners@2.9.2:
optional: true
cliui@7.0.4:
dependencies:
@@ -6653,6 +6694,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
optional: true
clsx@2.1.1: {}
@@ -6672,6 +6714,7 @@ snapshots:
yargs: 17.7.2
transitivePeerDependencies:
- supports-color
optional: true
codec-parser@2.5.0:
optional: true
@@ -6684,19 +6727,22 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
color-support@1.1.3:
optional: true
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@10.0.1: {}
commander@10.0.1:
optional: true
commander@14.0.2: {}
commander@8.3.0: {}
console-control-strings@1.1.0: {}
console-control-strings@1.1.0:
optional: true
content-disposition@1.0.1: {}
@@ -6730,11 +6776,13 @@ snapshots:
dependencies:
ms: 2.1.3
deep-extend@0.6.0: {}
deep-extend@0.6.0:
optional: true
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
delegates@1.0.0:
optional: true
depd@2.0.0: {}
@@ -6772,7 +6820,8 @@ snapshots:
ee-first@1.1.1: {}
emoji-regex@10.6.0: {}
emoji-regex@10.6.0:
optional: true
emoji-regex@8.0.0: {}
@@ -6782,7 +6831,8 @@ snapshots:
entities@4.5.0: {}
env-var@7.5.0: {}
env-var@7.5.0:
optional: true
es-define-property@1.0.1: {}
@@ -6885,7 +6935,8 @@ snapshots:
extend@3.0.2: {}
fast-content-type-parse@3.0.0: {}
fast-content-type-parse@3.0.0:
optional: true
fast-deep-equal@3.1.3: {}
@@ -6925,11 +6976,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
filename-reserved-regex@3.0.0: {}
filename-reserved-regex@3.0.0:
optional: true
filenamify@6.0.0:
dependencies:
filename-reserved-regex: 3.0.0
optional: true
fill-range@7.1.1:
dependencies:
@@ -6976,10 +7029,12 @@ snapshots:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
optional: true
fs-minipass@2.1.0:
dependencies:
minipass: 3.3.6
optional: true
fsevents@2.3.2:
optional: true
@@ -6999,6 +7054,7 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wide-align: 1.1.5
optional: true
gaxios@7.1.3:
dependencies:
@@ -7108,7 +7164,8 @@ snapshots:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {}
has-unicode@2.0.1:
optional: true
hashery@1.4.0:
dependencies:
@@ -7158,13 +7215,15 @@ snapshots:
ieee754@1.2.1: {}
ignore@7.0.5: {}
ignore@7.0.5:
optional: true
immediate@3.0.6: {}
inherits@2.0.4: {}
ini@1.3.8: {}
ini@1.3.8:
optional: true
ipaddr.js@1.9.1: {}
@@ -7191,6 +7250,7 @@ snapshots:
strip-ansi: 7.1.2
optionalDependencies:
'@reflink/reflink': 0.1.19
optional: true
is-binary-path@2.1.0:
dependencies:
@@ -7205,12 +7265,14 @@ snapshots:
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.4.0
optional: true
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-interactive@2.0.0: {}
is-interactive@2.0.0:
optional: true
is-network-error@1.3.0: {}
@@ -7220,9 +7282,11 @@ snapshots:
is-stream@2.0.1: {}
is-unicode-supported@1.3.0: {}
is-unicode-supported@1.3.0:
optional: true
is-unicode-supported@2.1.0: {}
is-unicode-supported@2.1.0:
optional: true
is-url@1.2.4: {}
@@ -7230,7 +7294,8 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.1: {}
isexe@3.1.1:
optional: true
istanbul-lib-coverage@3.2.2: {}
@@ -7283,6 +7348,7 @@ snapshots:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
optional: true
jsonwebtoken@9.0.3:
dependencies:
@@ -7340,9 +7406,11 @@ snapshots:
dependencies:
immediate: 3.0.6
lifecycle-utils@2.1.0: {}
lifecycle-utils@2.1.0:
optional: true
lifecycle-utils@3.0.1: {}
lifecycle-utils@3.0.1:
optional: true
lightningcss-android-arm64@1.30.2:
optional: true
@@ -7418,7 +7486,8 @@ snapshots:
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {}
lodash.debounce@4.0.8:
optional: true
lodash.includes@4.3.0: {}
@@ -7440,11 +7509,13 @@ snapshots:
dependencies:
chalk: 5.6.2
is-unicode-supported: 1.3.0
optional: true
log-symbols@7.0.1:
dependencies:
is-unicode-supported: 2.1.0
yoctocolors: 2.1.2
optional: true
loglevel@1.9.2: {}
@@ -7455,6 +7526,7 @@ snapshots:
lowdb@7.0.1:
dependencies:
steno: 4.0.2
optional: true
lru-cache@10.4.3: {}
@@ -7535,6 +7607,7 @@ snapshots:
memory-stream@1.0.0:
dependencies:
readable-stream: 3.6.2
optional: true
merge-descriptors@2.0.0: {}
@@ -7557,7 +7630,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
mimic-function@5.0.1: {}
mimic-function@5.0.1:
optional: true
minimatch@10.1.1:
dependencies:
@@ -7567,13 +7641,16 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.8: {}
minimist@1.2.8:
optional: true
minipass@3.3.6:
dependencies:
yallist: 4.0.0
optional: true
minipass@5.0.0: {}
minipass@5.0.0:
optional: true
minipass@7.1.2: {}
@@ -7581,6 +7658,7 @@ snapshots:
dependencies:
minipass: 3.3.6
yallist: 4.0.0
optional: true
minizlib@3.1.0:
dependencies:
@@ -7588,7 +7666,8 @@ snapshots:
mitt@3.0.1: {}
mkdirp@1.0.4: {}
mkdirp@1.0.4:
optional: true
mpg123-decoder@1.0.3:
dependencies:
@@ -7621,13 +7700,16 @@ snapshots:
nanoid@3.3.11: {}
nanoid@5.1.6: {}
nanoid@5.1.6:
optional: true
negotiator@1.0.0: {}
node-addon-api@8.5.0: {}
node-addon-api@8.5.0:
optional: true
node-api-headers@1.7.0: {}
node-api-headers@1.7.0:
optional: true
node-domexception@1.0.0: {}
@@ -7689,6 +7771,7 @@ snapshots:
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
optional: true
node-wav@0.0.2:
optional: true
@@ -7701,6 +7784,7 @@ snapshots:
console-control-strings: 1.1.0
gauge: 4.0.4
set-blocking: 2.0.0
optional: true
object-assign@4.1.1: {}
@@ -7723,6 +7807,7 @@ snapshots:
'@octokit/request-error': 7.1.0
'@octokit/types': 16.0.0
'@octokit/webhooks': 14.2.0
optional: true
ogg-opus-decoder@1.7.3:
dependencies:
@@ -7753,6 +7838,7 @@ snapshots:
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
optional: true
openai@6.10.0(ws@8.19.0)(zod@4.3.5):
optionalDependencies:
@@ -7775,6 +7861,7 @@ snapshots:
stdin-discarder: 0.2.2
string-width: 7.2.0
strip-ansi: 7.1.2
optional: true
osc-progress@0.2.0: {}
@@ -7845,9 +7932,11 @@ snapshots:
pako@1.0.11: {}
parse-ms@3.0.0: {}
parse-ms@3.0.0:
optional: true
parse-ms@4.0.0: {}
parse-ms@4.0.0:
optional: true
parse5-htmlparser2-tree-adapter@6.0.1:
dependencies:
@@ -7925,15 +8014,18 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pretty-bytes@6.1.1: {}
pretty-bytes@6.1.1:
optional: true
pretty-ms@8.0.0:
dependencies:
parse-ms: 3.0.0
optional: true
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
optional: true
prism-media@1.3.5:
optional: true
@@ -8043,6 +8135,7 @@ snapshots:
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
optional: true
readable-stream@2.3.8:
dependencies:
@@ -8059,6 +8152,7 @@ snapshots:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
optional: true
readable-stream@4.5.2:
dependencies:
@@ -8086,6 +8180,7 @@ snapshots:
dependencies:
onetime: 7.0.0
signal-exit: 4.1.0
optional: true
retry@0.12.0: {}
@@ -8198,7 +8293,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-blocking@2.0.0:
optional: true
setimmediate@1.0.5: {}
@@ -8288,6 +8384,7 @@ snapshots:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
optional: true
simple-yenc@1.0.4:
optional: true
@@ -8300,12 +8397,14 @@ snapshots:
sisteransi@1.0.5: {}
sleep-promise@9.1.0: {}
sleep-promise@9.1.0:
optional: true
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
optional: true
sonic-boom@4.2.0:
dependencies:
@@ -8328,7 +8427,8 @@ snapshots:
std-env@3.10.0: {}
stdin-discarder@0.2.2: {}
stdin-discarder@0.2.2:
optional: true
stdout-update@4.0.1:
dependencies:
@@ -8336,8 +8436,10 @@ snapshots:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.1.2
optional: true
steno@4.0.2: {}
steno@4.0.2:
optional: true
string-width@4.2.3:
dependencies:
@@ -8356,6 +8458,7 @@ snapshots:
emoji-regex: 10.6.0
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
optional: true
string_decoder@1.1.1:
dependencies:
@@ -8373,7 +8476,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strip-json-comments@2.0.1: {}
strip-json-comments@2.0.1:
optional: true
strnum@2.1.2: {}
@@ -8403,6 +8507,7 @@ snapshots:
minizlib: 2.1.2
mkdirp: 1.0.4
yallist: 4.0.0
optional: true
tar@7.5.2:
dependencies:
@@ -8443,7 +8548,8 @@ snapshots:
dependencies:
is-number: 7.0.0
toad-cache@3.7.0: {}
toad-cache@3.7.0:
optional: true
toidentifier@1.0.1: {}
@@ -8504,17 +8610,21 @@ snapshots:
pako: 0.2.9
tiny-inflate: 1.0.3
universal-github-app-jwt@2.2.2: {}
universal-github-app-jwt@2.2.2:
optional: true
universal-user-agent@7.0.3: {}
universal-user-agent@7.0.3:
optional: true
universalify@2.0.1: {}
universalify@2.0.1:
optional: true
unpipe@1.0.0: {}
urijs@1.19.11: {}
url-join@4.0.1: {}
url-join@4.0.1:
optional: true
util-deprecate@1.0.2: {}
@@ -8524,7 +8634,8 @@ snapshots:
uuid@8.3.2: {}
validate-npm-package-name@6.0.2: {}
validate-npm-package-name@6.0.2:
optional: true
vary@1.1.2: {}
@@ -8602,6 +8713,7 @@ snapshots:
which@5.0.0:
dependencies:
isexe: 3.1.1
optional: true
why-is-node-running@2.3.0:
dependencies:
@@ -8611,6 +8723,7 @@ snapshots:
wide-align@1.1.5:
dependencies:
string-width: 4.2.3
optional: true
wireit@0.14.12:
dependencies:
@@ -8651,7 +8764,8 @@ snapshots:
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs-parser@21.1.1:
optional: true
yargs@16.2.0:
dependencies:
@@ -8672,6 +8786,7 @@ snapshots:
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
optional: true
yoctocolors@2.1.2: {}
+4 -39
View File
@@ -9,6 +9,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { CliBackendConfig } from "../../config/types.js";
import { runExec } from "../../process/exec.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
@@ -69,44 +70,6 @@ export type CliOutput = {
usage?: CliUsage;
};
function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
@@ -134,7 +97,8 @@ export function buildSystemPrompt(params: {
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
defaultThinkLevel: params.defaultThinkLevel,
@@ -153,6 +117,7 @@ export function buildSystemPrompt(params: {
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles: params.contextFiles,
});
}
+164
View File
@@ -0,0 +1,164 @@
import { execSync } from "node:child_process";
export type TimeFormatPreference = "auto" | "12" | "24";
export type ResolvedTimeFormat = "12" | "24";
let cachedTimeFormat: ResolvedTimeFormat | undefined;
export function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
export function resolveUserTimeFormat(preference?: TimeFormatPreference): ResolvedTimeFormat {
if (preference === "12" || preference === "24") return preference;
if (cachedTimeFormat) return cachedTimeFormat;
cachedTimeFormat = detectSystemTimeFormat() ? "24" : "12";
return cachedTimeFormat;
}
export function normalizeTimestamp(
raw: unknown,
): { timestampMs: number; timestampUtc: string } | undefined {
if (raw == null) return undefined;
let timestampMs: number | undefined;
if (raw instanceof Date) {
timestampMs = raw.getTime();
} else if (typeof raw === "number" && Number.isFinite(raw)) {
timestampMs = raw < 1_000_000_000_000 ? Math.round(raw * 1000) : Math.round(raw);
} else if (typeof raw === "string") {
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) {
if (trimmed.includes(".")) {
timestampMs = Math.round(num * 1000);
} else if (trimmed.length >= 13) {
timestampMs = Math.round(num);
} else {
timestampMs = Math.round(num * 1000);
}
}
} else {
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) timestampMs = parsed;
}
}
if (timestampMs === undefined || !Number.isFinite(timestampMs)) return undefined;
return { timestampMs, timestampUtc: new Date(timestampMs).toISOString() };
}
export function withNormalizedTimestamp<T extends Record<string, unknown>>(
value: T,
rawTimestamp: unknown,
): T & { timestampMs?: number; timestampUtc?: string } {
const normalized = normalizeTimestamp(rawTimestamp);
if (!normalized) return value;
return {
...value,
timestampMs:
typeof value.timestampMs === "number" && Number.isFinite(value.timestampMs)
? value.timestampMs
: normalized.timestampMs,
timestampUtc:
typeof value.timestampUtc === "string" && value.timestampUtc.trim()
? value.timestampUtc
: normalized.timestampUtc,
};
}
function detectSystemTimeFormat(): boolean {
if (process.platform === "darwin") {
try {
const result = execSync("defaults read -g AppleICUForce24HourTime 2>/dev/null", {
encoding: "utf8",
timeout: 500,
}).trim();
if (result === "1") return true;
if (result === "0") return false;
} catch {
// Not set, fall through
}
}
if (process.platform === "win32") {
try {
const result = execSync(
'powershell -Command "(Get-Culture).DateTimeFormat.ShortTimePattern"',
{ encoding: "utf8", timeout: 1000 },
).trim();
if (result.startsWith("H")) return true;
if (result.startsWith("h")) return false;
} catch {
// Fall through
}
}
try {
const sample = new Date(2000, 0, 1, 13, 0);
const formatted = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).format(sample);
return formatted.includes("13");
} catch {
return false;
}
}
function ordinalSuffix(day: number): string {
if (day >= 11 && day <= 13) return "th";
switch (day % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
}
export function formatUserTime(
date: Date,
timeZone: string,
format: ResolvedTimeFormat,
): string | undefined {
const use24Hour = format === "24";
try {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: use24Hour ? "2-digit" : "numeric",
minute: "2-digit",
hourCycle: use24Hour ? "h23" : "h12",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute)
return undefined;
const dayNum = parseInt(map.day, 10);
const suffix = ordinalSuffix(dayNum);
const timePart = use24Hour
? `${map.hour}:${map.minute}`
: `${map.hour}:${map.minute} ${map.dayPeriod ?? ""}`.trim();
return `${map.weekday}, ${map.month} ${dayNum}${suffix}, ${map.year} — ${timePart}`;
} catch {
return undefined;
}
}
+28
View File
@@ -213,6 +213,34 @@ describe("runWithModelFallback", () => {
expect(calls).toEqual([{ provider: "anthropic", model: "claude-opus-4-5" }]);
});
it("defaults provider/model when missing (regression #946)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [],
},
},
},
});
const calls: Array<{ provider: string; model: string }> = [];
const result = await runWithModelFallback({
cfg,
provider: undefined as unknown as string,
model: undefined as unknown as string,
run: async (provider, model) => {
calls.push({ provider, model });
return "ok";
},
});
expect(result.result).toBe("ok");
expect(calls).toEqual([{ provider: "openai", model: "gpt-4.1-mini" }]);
});
it("falls back on missing API key errors", async () => {
const cfg = makeCfg();
const run = vi
+7 -5
View File
@@ -119,8 +119,6 @@ function resolveFallbackCandidates(params: {
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
}): ModelCandidate[] {
const provider = params.provider.trim() || DEFAULT_PROVIDER;
const model = params.model.trim() || DEFAULT_MODEL;
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
@@ -128,11 +126,15 @@ function resolveFallbackCandidates(params: {
defaultModel: DEFAULT_MODEL,
})
: null;
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
const defaultModel = primary?.model ?? DEFAULT_MODEL;
const provider = String(params.provider ?? "").trim() || defaultProvider;
const model = String(params.model ?? "").trim() || defaultModel;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider: DEFAULT_PROVIDER,
defaultProvider,
});
const allowlist = buildAllowedModelKeys(params.cfg, DEFAULT_PROVIDER);
const allowlist = buildAllowedModelKeys(params.cfg, defaultProvider);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
@@ -160,7 +162,7 @@ function resolveFallbackCandidates(params: {
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider: DEFAULT_PROVIDER,
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
+5
View File
@@ -8,6 +8,11 @@ export function isGoogleModelApi(api?: string | null): boolean {
);
}
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
if (api !== "google-antigravity") return false;
return modelId?.toLowerCase().includes("claude") ?? false;
}
export { sanitizeGoogleTurnOrdering };
/**
+9 -2
View File
@@ -30,7 +30,11 @@ function isEmptyAssistantErrorMessage(
export async function sanitizeSessionMessagesImages(
messages: AgentMessage[],
label: string,
options?: { sanitizeToolCallIds?: boolean; enforceToolCallLast?: boolean },
options?: {
sanitizeToolCallIds?: boolean;
enforceToolCallLast?: boolean;
preserveSignatures?: boolean;
},
): Promise<AgentMessage[]> {
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
@@ -76,7 +80,10 @@ export async function sanitizeSessionMessagesImages(
}
const content = assistantMsg.content;
if (Array.isArray(content)) {
const strippedContent = stripThoughtSignatures(content);
const strippedContent = options?.preserveSignatures
? content // Keep signatures for Antigravity Claude
: stripThoughtSignatures(content); // Strip for Gemini
const filteredContent = strippedContent.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
@@ -59,6 +59,34 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
});
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
modelId: "anthropic/claude-3.5-sonnet",
sessionManager,
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("preserves order when downgrading mixed assistant content", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
+6 -8
View File
@@ -53,13 +53,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
import { splitSdkTools } from "./tool-split.js";
import type { EmbeddedPiCompactResult } from "./types.js";
import {
describeUnknownError,
formatUserTime,
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "./utils.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
export async function compactEmbeddedPiSession(params: {
sessionId: string;
@@ -228,7 +223,8 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -251,6 +247,7 @@ export async function compactEmbeddedPiSession(params: {
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
@@ -302,6 +299,7 @@ export async function compactEmbeddedPiSession(params: {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
sessionManager,
sessionId: params.sessionId,
});
+7 -2
View File
@@ -13,6 +13,7 @@ import {
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import { log } from "./logger.js";
import { describeUnknownError } from "./utils.js";
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
@@ -152,19 +153,23 @@ export function applyGoogleTurnOrderingFix(params: {
export async function sanitizeSessionHistory(params: {
messages: AgentMessage[];
modelApi?: string | null;
modelId?: string;
sessionManager: SessionManager;
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = isGoogleModelApi(params.modelApi)
const downgradedThinking = shouldDowngradeGemini
? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools;
const downgraded = isGoogleModelApi(params.modelApi)
const downgraded = shouldDowngradeGemini
? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking;
+19 -7
View File
@@ -9,6 +9,7 @@ import { createAgentSession, SessionManager, SettingsManager } from "@mariozechn
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../../infra/machine-name.js";
import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js";
import { normalizeMessageChannel } from "../../../utils/message-channel.js";
import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { resolveUserPath } from "../../../utils.js";
@@ -57,12 +58,8 @@ import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manage
import { prepareSessionManagerForRun } from "../session-manager-init.js";
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
import { splitSdkTools } from "../tool-split.js";
import {
formatUserTime,
mapThinkingLevel,
resolveExecToolDefaults,
resolveUserTimezone,
} from "../utils.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../../date-time.js";
import { mapThinkingLevel, resolveExecToolDefaults } from "../utils.js";
import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
@@ -161,6 +158,17 @@ export async function runEmbeddedAttempt(
accountId: params.agentAccountId,
}) ?? [])
: undefined;
const reactionGuidance =
runtimeChannel === "telegram" && params.config
? (() => {
const resolved = resolveTelegramReactionLevel({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
const level = resolved.agentReactionGuidance;
return level ? { level, channel: "Telegram" } : undefined;
})()
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
@@ -174,7 +182,8 @@ export async function runEmbeddedAttempt(
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(params.provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTime = formatUserTime(new Date(), userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
@@ -192,12 +201,14 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
reactionGuidance,
runtimeInfo,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPromptReport = buildSystemPromptReport({
@@ -303,6 +314,7 @@ export async function runEmbeddedAttempt(
const prior = await sanitizeSessionHistory({
messages: activeSession.messages,
modelApi: params.model.api,
modelId: params.modelId,
sessionManager,
sessionId: params.sessionId,
});
@@ -1,4 +1,5 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import type { ResolvedTimeFormat } from "../date-time.js";
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
import { buildAgentSystemPrompt } from "../system-prompt.js";
import { buildToolSummaryMap } from "../tool-summaries.js";
@@ -14,6 +15,10 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean;
heartbeatPrompt?: string;
skillsPrompt?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
runtimeInfo: {
host: string;
os: string;
@@ -29,6 +34,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: string[];
userTimezone: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
}): string {
return buildAgentSystemPrompt({
@@ -40,6 +46,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
reactionGuidance: params.reactionGuidance,
runtimeInfo: params.runtimeInfo,
sandboxInfo: params.sandboxInfo,
toolNames: params.tools.map((tool) => tool.name),
@@ -47,6 +54,7 @@ export function buildEmbeddedSystemPrompt(params: {
modelAliasLines: params.modelAliasLines,
userTimezone: params.userTimezone,
userTime: params.userTime,
userTimeFormat: params.userTimeFormat,
contextFiles: params.contextFiles,
});
}
-39
View File
@@ -17,45 +17,6 @@ export function resolveExecToolDefaults(config?: ClawdbotConfig): ExecToolDefaul
return { ...tools.bash, ...tools.exec };
}
export function resolveUserTimezone(configured?: string): string {
const trimmed = configured?.trim();
if (trimmed) {
try {
new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format(new Date());
return trimmed;
} catch {
// ignore invalid timezone
}
}
const host = Intl.DateTimeFormat().resolvedOptions().timeZone;
return host?.trim() || "UTC";
}
export function formatUserTime(date: Date, timeZone: string): string | undefined {
try {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone,
weekday: "long",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const map: Record<string, string> = {};
for (const part of parts) {
if (part.type !== "literal") map[part.type] = part.value;
}
if (!map.weekday || !map.year || !map.month || !map.day || !map.hour || !map.minute) {
return undefined;
}
return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
} catch {
return undefined;
}
}
export function describeUnknownError(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
+53 -3
View File
@@ -34,6 +34,16 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("<final>...</final>");
});
it("includes a CLI quick reference section", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
});
expect(prompt).toContain("## Clawdbot CLI Quick Reference");
expect(prompt).toContain("clawdbot daemon restart");
expect(prompt).toContain("Do not invent commands");
});
it("lists available tools when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
@@ -61,15 +71,42 @@ describe("buildAgentSystemPrompt", () => {
);
});
it("includes user time when provided", () => {
it("includes user time when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "Monday 2026-01-05 15:26",
userTime: "Monday, January 5th, 2026 — 3:26 PM",
userTimeFormat: "12",
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
expect(prompt).toContain("Time format: 12-hour");
});
it("includes user time when provided (24-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTime: "Monday, January 5th, 2026 — 15:26",
userTimeFormat: "24",
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
expect(prompt).toContain("Time format: 24-hour");
});
it("shows UTC fallback when only timezone is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
userTimeFormat: "24",
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain(
"Time: assume UTC unless stated. User time zone: America/Chicago. Current user time (local, 24-hour): Monday 2026-01-05 15:26 (America/Chicago).",
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
);
});
@@ -198,4 +235,17 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("Current elevated level: on");
});
it("includes reaction guidance when provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
reactionGuidance: {
level: "minimal",
channel: "Telegram",
},
});
expect(prompt).toContain("## Reactions");
expect(prompt).toContain("Reactions are enabled for Telegram in MINIMAL mode.");
});
});
+51 -8
View File
@@ -1,6 +1,7 @@
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { listDeliverableMessageChannels } from "../utils/message-channel.js";
import type { ResolvedTimeFormat } from "./date-time.js";
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
export function buildAgentSystemPrompt(params: {
@@ -15,6 +16,7 @@ export function buildAgentSystemPrompt(params: {
modelAliasLines?: string[];
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string;
heartbeatPrompt?: string;
@@ -43,6 +45,11 @@ export function buildAgentSystemPrompt(params: {
defaultLevel: "on" | "off";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
};
}) {
const coreToolSummaries: Record<string, string> = {
read: "Read file contents",
@@ -217,6 +224,15 @@ export function buildAgentSystemPrompt(params: {
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
"",
"## Clawdbot CLI Quick Reference",
"Clawdbot is controlled via subcommands. Do not invent commands.",
"To manage the Gateway daemon service (start/stop/restart):",
"- clawdbot daemon status",
"- clawdbot daemon start",
"- clawdbot daemon stop",
"- clawdbot daemon restart",
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
"",
...skillsSection,
...memorySection,
hasGateway ? "## Clawdbot Self-Update" : "",
@@ -298,17 +314,21 @@ export function buildAgentSystemPrompt(params: {
ownerLine ? "## User Identity" : "",
ownerLine ?? "",
ownerLine ? "" : "",
...(userTimezone || userTime
? [
"## Current Date & Time",
userTime
? `${userTime} (${userTimezone ?? "unknown"})`
: `Time zone: ${userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
]
: []),
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",
"",
userTimezone || userTime
? `Time: assume UTC unless stated. User time zone: ${
userTimezone ?? "unknown"
}. Current user time (local, 24-hour): ${userTime ?? "unknown"} (${
userTimezone ?? "unknown"
}).`
: "",
userTimezone || userTime ? "" : "",
"## Reply Tags",
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
"- [[reply_to_current]] replies to the triggering message.",
@@ -342,6 +362,29 @@ export function buildAgentSystemPrompt(params: {
if (extraSystemPrompt) {
lines.push("## Group Chat Context", extraSystemPrompt, "");
}
if (params.reactionGuidance) {
const { level, channel } = params.reactionGuidance;
const guidanceText =
level === "minimal"
? [
`Reactions are enabled for ${channel} in MINIMAL mode.`,
"React ONLY when truly relevant:",
"- Acknowledge important user requests or confirmations",
"- Express genuine sentiment (humor, appreciation) sparingly",
"- Avoid reacting to routine messages or your own replies",
"Guideline: at most 1 reaction per 5-10 exchanges.",
].join("\n")
: [
`Reactions are enabled for ${channel} in EXTENSIVE mode.`,
"Feel free to react liberally:",
"- Acknowledge messages with appropriate emojis",
"- Express sentiment and personality through reactions",
"- React to interesting content, humor, or notable events",
"- Use reactions to confirm understanding or agreement",
"Guideline: react whenever it feels natural.",
].join("\n");
lines.push("## Reactions", guidanceText, "");
}
if (reasoningHint) {
lines.push("## Reasoning Format", reasoningHint, "");
}
+37 -34
View File
@@ -27,32 +27,7 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.js";
function formatDiscordTimestamp(ts?: string | null): string | undefined {
if (!ts) return undefined;
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return undefined;
const yyyy = String(date.getFullYear()).padStart(4, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const min = String(date.getMinutes()).padStart(2, "0");
// getTimezoneOffset() is minutes *behind* UTC. Flip sign to get ISO offset.
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffsetMinutes = Math.abs(offsetMinutes);
const offsetH = String(Math.floor(absOffsetMinutes / 60)).padStart(2, "0");
const offsetM = String(absOffsetMinutes % 60).padStart(2, "0");
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const tzSuffix = tz ? `{${tz}}` : "";
// Compact ISO-like *local* timestamp with minutes precision.
// Example: 2025-01-02T03:04-08:00{America/Los_Angeles}
return `${yyyy}-${mm}-${dd}T${hh}:${min}${sign}${offsetH}:${offsetM}${tzSuffix}`;
}
import { withNormalizedTimestamp } from "../date-time.js";
function parseDiscordMessageLink(link: string) {
const normalized = link.trim();
@@ -76,6 +51,13 @@ export async function handleDiscordMessagingAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
const normalizeMessage = (message: unknown) => {
if (!message || typeof message !== "object") return message;
return withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { timestamp?: unknown }).timestamp,
);
};
switch (action) {
case "react": {
if (!isActionEnabled("reactions")) {
@@ -189,7 +171,13 @@ export async function handleDiscordMessagingAction(
);
}
const message = await fetchMessageDiscord(channelId, messageId);
return jsonResult({ ok: true, message, guildId, channelId, messageId });
return jsonResult({
ok: true,
message: normalizeMessage(message),
guildId,
channelId,
messageId,
});
}
case "readMessages": {
if (!isActionEnabled("messages")) {
@@ -207,11 +195,10 @@ export async function handleDiscordMessagingAction(
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
});
const formattedMessages = messages.map((message) => ({
...message,
timestamp: formatDiscordTimestamp(message.timestamp) ?? message.timestamp,
}));
return jsonResult({ ok: true, messages: formattedMessages });
return jsonResult({
ok: true,
messages: messages.map((message) => normalizeMessage(message)),
});
}
case "sendMessage": {
if (!isActionEnabled("messages")) {
@@ -357,7 +344,7 @@ export async function handleDiscordMessagingAction(
required: true,
});
const pins = await listPinsDiscord(channelId);
return jsonResult({ ok: true, pins });
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
}
case "searchMessages": {
if (!isActionEnabled("search")) {
@@ -386,7 +373,23 @@ export async function handleDiscordMessagingAction(
authorIds: authorIdList.length ? authorIdList : undefined,
limit,
});
return jsonResult({ ok: true, results });
if (!results || typeof results !== "object") {
return jsonResult({ ok: true, results });
}
const resultsRecord = results as Record<string, unknown>;
const messages = resultsRecord.messages;
const normalizedMessages = Array.isArray(messages)
? messages.map((group) =>
Array.isArray(group) ? group.map((msg) => normalizeMessage(msg)) : group,
)
: messages;
return jsonResult({
ok: true,
results: {
...resultsRecord,
messages: normalizedMessages,
},
});
}
default:
throw new Error(`Unknown action: ${action}`);
+76
View File
@@ -17,6 +17,7 @@ const editChannelDiscord = vi.fn(async () => ({
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
@@ -42,6 +43,7 @@ vi.mock("../../discord/send.js", () => ({
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchMessageDiscord: (...args: unknown[]) => fetchMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
@@ -134,6 +136,80 @@ describe("handleDiscordMessagingAction", () => {
),
).rejects.toThrow(/Discord reactions are disabled/);
});
it("adds normalized timestamps to readMessages payloads", async () => {
readMessagesDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T10:00:00.000Z" }]);
const result = await handleDiscordMessagingAction(
"readMessages",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T10:00:00.000Z");
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to fetchMessage payloads", async () => {
fetchMessageDiscord.mockResolvedValueOnce({
id: "1",
timestamp: "2026-01-15T11:00:00.000Z",
});
const result = await handleDiscordMessagingAction(
"fetchMessage",
{ guildId: "G1", channelId: "C1", messageId: "M1" },
enableAllActions,
);
const payload = result.details as { message?: { timestampMs?: number; timestampUtc?: string } };
const expectedMs = Date.parse("2026-01-15T11:00:00.000Z");
expect(payload.message?.timestampMs).toBe(expectedMs);
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to listPins payloads", async () => {
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
const result = await handleDiscordMessagingAction(
"listPins",
{ channelId: "C1" },
enableAllActions,
);
const payload = result.details as {
pins: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Date.parse("2026-01-15T12:00:00.000Z");
expect(payload.pins[0].timestampMs).toBe(expectedMs);
expect(payload.pins[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to searchMessages payloads", async () => {
searchMessagesDiscord.mockResolvedValueOnce({
total_results: 1,
messages: [[{ id: "1", timestamp: "2026-01-15T13:00:00.000Z" }]],
});
const result = await handleDiscordMessagingAction(
"searchMessages",
{ guildId: "G1", content: "hi" },
enableAllActions,
);
const payload = result.details as {
results?: { messages?: Array<Array<{ timestampMs?: number; timestampUtc?: string }>> };
};
const expectedMs = Date.parse("2026-01-15T13:00:00.000Z");
expect(payload.results?.messages?.[0]?.[0]?.timestampMs).toBe(expectedMs);
expect(payload.results?.messages?.[0]?.[0]?.timestampUtc).toBe(
new Date(expectedMs).toISOString(),
);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
+36
View File
@@ -325,4 +325,40 @@ describe("handleSlackAction", () => {
threadTs: "1111111111.111111",
});
});
it("adds normalized timestamps to readMessages payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockResolvedValueOnce({
messages: [{ ts: "1735689600.456", text: "hi" }],
hasMore: false,
});
const result = await handleSlackAction({ action: "readMessages", channelId: "C1" }, cfg);
const payload = result.details as {
messages: Array<{ timestampMs?: number; timestampUtc?: string }>;
};
const expectedMs = Math.round(1735689600.456 * 1000);
expect(payload.messages[0].timestampMs).toBe(expectedMs);
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
});
it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([
{
type: "message",
message: { ts: "1735689600.789", text: "pinned" },
},
]);
const result = await handleSlackAction({ action: "listPins", channelId: "C1" }, cfg);
const payload = result.details as {
pins: Array<{ message?: { timestampMs?: number; timestampUtc?: string } }>;
};
const expectedMs = Math.round(1735689600.789 * 1000);
expect(payload.pins[0].message?.timestampMs).toBe(expectedMs);
expect(payload.pins[0].message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
});
});
+18 -2
View File
@@ -17,6 +17,7 @@ import {
sendSlackMessage,
unpinSlackMessage,
} from "../../slack/actions.js";
import { withNormalizedTimestamp } from "../date-time.js";
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
const messagingActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]);
@@ -197,7 +198,13 @@ export async function handleSlackAction(
before: before ?? undefined,
after: after ?? undefined,
});
return jsonResult({ ok: true, ...result });
const messages = result.messages.map((message) =>
withNormalizedTimestamp(
message as Record<string, unknown>,
(message as { ts?: unknown }).ts,
),
);
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
}
default:
break;
@@ -234,7 +241,16 @@ export async function handleSlackAction(
const pins = accountOpts
? await listSlackPins(channelId, accountOpts)
: await listSlackPins(channelId);
return jsonResult({ ok: true, pins });
const normalizedPins = pins.map((pin) => {
const message = pin.message
? withNormalizedTimestamp(
pin.message as Record<string, unknown>,
(pin.message as { ts?: unknown }).ts,
)
: pin.message;
return message ? { ...pin, message } : pin;
});
return jsonResult({ ok: true, pins: normalizedPins });
}
if (action === "memberInfo") {
+66 -7
View File
@@ -33,9 +33,30 @@ describe("handleTelegramAction", () => {
}
});
it("adds reactions", async () => {
it("adds reactions when reactionLevel is minimal", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("adds reactions when reactionLevel is extensive", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -56,7 +77,7 @@ describe("handleTelegramAction", () => {
it("removes reactions on empty emoji", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -77,7 +98,7 @@ describe("handleTelegramAction", () => {
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as ClawdbotConfig;
await handleTelegramAction(
{
@@ -97,10 +118,48 @@ describe("handleTelegramAction", () => {
);
});
it("respects reaction gating", async () => {
it("blocks reactions when reactionLevel is off", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "off" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="off"/);
});
it("blocks reactions when reactionLevel is ack (default)", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "ack" } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
),
).rejects.toThrow(/Telegram agent reactions disabled.*reactionLevel="ack"/);
});
it("also respects legacy actions.reactions gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { reactions: false } },
telegram: {
botToken: "tok",
reactionLevel: "minimal",
actions: { reactions: false },
},
},
} as ClawdbotConfig;
await expect(
@@ -113,7 +172,7 @@ describe("handleTelegramAction", () => {
},
cfg,
),
).rejects.toThrow(/Telegram reactions are disabled/);
).rejects.toThrow(/Telegram reactions are disabled via actions.reactions/);
});
it("sends a text message", async () => {
+14 -1
View File
@@ -1,6 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
import {
deleteMessageTelegram,
reactMessageTelegram,
@@ -82,8 +83,20 @@ export async function handleTelegramAction(
const isActionEnabled = createActionGate(cfg.channels?.telegram?.actions);
if (action === "react") {
// Check reaction level first
const reactionLevelInfo = resolveTelegramReactionLevel({
cfg,
accountId: accountId ?? undefined,
});
if (!reactionLevelInfo.agentReactionsEnabled) {
throw new Error(
`Telegram agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
`Set channels.telegram.reactionLevel to "minimal" or "extensive" to enable.`,
);
}
// Also check the existing action gate for backward compatibility
if (!isActionEnabled("reactions")) {
throw new Error("Telegram reactions are disabled.");
throw new Error("Telegram reactions are disabled via actions.reactions.");
}
const chatId = readStringOrNumberParam(params, "chatId", {
required: true,
+13 -1
View File
@@ -4,7 +4,19 @@ export type CommandArgsFormatter = (values: CommandArgValues) => string | undefi
function normalizeArgValue(value: unknown): string | undefined {
if (value == null) return undefined;
const text = typeof value === "string" ? value.trim() : String(value).trim();
let text: string;
if (typeof value === "string") {
text = value.trim();
} else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
text = String(value).trim();
} else if (typeof value === "symbol") {
text = value.toString().trim();
} else if (typeof value === "function") {
text = value.toString().trim();
} else {
// Objects and arrays
text = JSON.stringify(value);
}
return text ? text : undefined;
}
+6 -1
View File
@@ -137,7 +137,12 @@ function formatPositionalArgs(
for (const definition of definitions) {
const value = values[definition.name];
if (value == null) continue;
const rendered = typeof value === "string" ? value.trim() : String(value);
let rendered: string;
if (typeof value === "string") {
rendered = value.trim();
} else {
rendered = String(value);
}
if (!rendered) continue;
parts.push(rendered);
if (definition.captureRemaining) break;
+2 -1
View File
@@ -28,7 +28,8 @@ export type CommandArgMenuSpec = {
title?: string;
};
export type CommandArgValues = Record<string, unknown>;
export type CommandArgValue = string | number | boolean | bigint;
export type CommandArgValues = Record<string, CommandArgValue>;
export type CommandArgs = {
raw?: string;
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { createInboundDebouncer } from "./inbound-debounce.js";
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { InboundDebounceByProvider } from "../config/types.messages.js";
const resolveMs = (value: unknown): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
return Math.max(0, Math.trunc(value));
};
const resolveChannelOverride = (params: {
byChannel?: InboundDebounceByProvider;
channel: string;
}): number | undefined => {
if (!params.byChannel) return undefined;
const channelKey = params.channel as keyof InboundDebounceByProvider;
return resolveMs(params.byChannel[channelKey]);
};
export function resolveInboundDebounceMs(params: {
cfg: ClawdbotConfig;
channel: string;
overrideMs?: number;
}): number {
const inbound = params.cfg.messages?.inbound;
const override = resolveMs(params.overrideMs);
const byChannel = resolveChannelOverride({
byChannel: inbound?.byChannel,
channel: params.channel,
});
const base = resolveMs(inbound?.debounceMs);
return override ?? byChannel ?? base ?? 0;
}
type DebounceBuffer<T> = {
items: T[];
timeout: ReturnType<typeof setTimeout> | null;
};
export function createInboundDebouncer<T>(params: {
debounceMs: number;
buildKey: (item: T) => string | null | undefined;
shouldDebounce?: (item: T) => boolean;
onFlush: (items: T[]) => Promise<void>;
onError?: (err: unknown, items: T[]) => void;
}) {
const buffers = new Map<string, DebounceBuffer<T>>();
const debounceMs = Math.max(0, Math.trunc(params.debounceMs));
const flushBuffer = async (key: string, buffer: DebounceBuffer<T>) => {
buffers.delete(key);
if (buffer.timeout) {
clearTimeout(buffer.timeout);
buffer.timeout = null;
}
if (buffer.items.length === 0) return;
try {
await params.onFlush(buffer.items);
} catch (err) {
params.onError?.(err, buffer.items);
}
};
const flushKey = async (key: string) => {
const buffer = buffers.get(key);
if (!buffer) return;
await flushBuffer(key, buffer);
};
const scheduleFlush = (key: string, buffer: DebounceBuffer<T>) => {
if (buffer.timeout) clearTimeout(buffer.timeout);
buffer.timeout = setTimeout(() => {
void flushBuffer(key, buffer);
}, debounceMs);
buffer.timeout.unref?.();
};
const enqueue = async (item: T) => {
const key = params.buildKey(item);
const canDebounce = debounceMs > 0 && (params.shouldDebounce?.(item) ?? true);
if (!canDebounce || !key) {
if (key && buffers.has(key)) {
await flushKey(key);
}
await params.onFlush([item]);
return;
}
const existing = buffers.get(key);
if (existing) {
existing.items.push(item);
scheduleFlush(key, existing);
return;
}
const buffer: DebounceBuffer<T> = { items: [item], timeout: null };
buffers.set(key, buffer);
scheduleFlush(key, buffer);
};
return { enqueue, flushKey };
}
@@ -85,8 +85,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -116,8 +116,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -166,9 +166,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("claude-opus-4-5 — anthropic");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("MiniMax-M2.1 — minimax");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -213,7 +213,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(text).toContain("Pick: /model <#> or /model <provider/model>");
expect(text).toContain("gpt-4.1-mini — openai");
expect(text).toContain("openai/gpt-4.1-mini");
expect(text).not.toContain("claude-sonnet-4-1");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
@@ -1,4 +1,3 @@
import fs from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
@@ -96,7 +95,7 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("shows a quick /model picker grouped by model with providers", async () => {
it("shows a quick /model picker listing provider/model pairs", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -116,13 +115,103 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
expect(normalized).toContain("1) claude-opus-4-5 — anthropic, openrouter");
expect(normalized).toContain("3) gpt-5.2 — openai, openai-codex");
// Each provider/model combo is listed separately for clear selection
expect(normalized).toContain("anthropic/claude-opus-4-5");
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
expect(normalized).toContain("openai/gpt-5.2");
expect(normalized).toContain("openai-codex/gpt-5.2");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
});
});
it("orders provider/model pairs by provider preference", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: "telegram:slash:111",
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
expect(openaiIndex).toBeGreaterThanOrEqual(0);
expect(codexIndex).toBeGreaterThanOrEqual(0);
expect(anthropicIndex).toBeLessThan(openrouterIndex);
expect(openaiIndex).toBeLessThan(codexIndex);
});
});
it("selects the exact provider/model pair for openrouter by index", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const list = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
},
{},
cfg,
);
const listText = Array.isArray(list) ? list[0]?.text : list?.text;
const lines = normalizeTestText(listText ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const targetLine = lines.find((line) =>
line.includes("openrouter/anthropic/claude-opus-4-5"),
);
expect(targetLine).toBeDefined();
const match = targetLine?.match(/^(\d+)\)/);
expect(match?.[1]).toBeDefined();
const index = Number.parseInt(match?.[1] ?? "", 10);
expect(Number.isFinite(index)).toBe(true);
const res = await getReplyFromConfig(
{
Body: `/model ${index}`,
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
},
{},
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openrouter/anthropic/claude-opus-4-5",
);
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter");
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
});
});
it("rejects invalid /model <#> selections", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
@@ -152,27 +241,12 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("prefers the current provider when selecting /model <#>", async () => {
it("selects exact provider/model combo by index via /model <#>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
await fs.writeFile(
cfg.session.store,
JSON.stringify(
{
[sessionKey]: {
sessionId: "session-openrouter",
updatedAt: Date.now(),
providerOverride: "openrouter",
modelOverride: "anthropic/claude-opus-4-5",
},
},
null,
2,
),
);
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig(
{
Body: "/model 1",
@@ -188,13 +262,13 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model set to openrouter/anthropic/claude-opus-4-5",
);
// Selecting the default model shows "reset to default" instead of "set to"
expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5");
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBe("openrouter");
expect(store[sessionKey]?.modelOverride).toBe("anthropic/claude-opus-4-5");
// When selecting the default, overrides are cleared
expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects a model by index via /model <#>", async () => {
@@ -1,4 +1,4 @@
import { normalizeProviderId } from "../../agents/model-selection.js";
import { type ModelRef, normalizeProviderId } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
export type ModelPickerCatalogEntry = {
@@ -7,11 +7,7 @@ export type ModelPickerCatalogEntry = {
name?: string;
};
export type ModelPickerItem = {
model: string;
providers: string[];
providerModels: Record<string, string>;
};
export type ModelPickerItem = ModelRef;
const MODEL_PICK_PROVIDER_PREFERENCE = [
"anthropic",
@@ -31,68 +27,43 @@ const MODEL_PICK_PROVIDER_PREFERENCE = [
"lmstudio",
] as const;
function normalizeModelFamilyId(id: string): string {
const trimmed = id.trim();
if (!trimmed) return trimmed;
const parts = trimmed.split("/").filter(Boolean);
return parts.length > 0 ? (parts[parts.length - 1] ?? trimmed) : trimmed;
}
const PROVIDER_RANK = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
function sortProvidersForPicker(providers: string[]): string[] {
const pref = new Map<string, number>(
MODEL_PICK_PROVIDER_PREFERENCE.map((provider, idx) => [provider, idx]),
);
return providers.sort((a, b) => {
const pa = pref.get(a);
const pb = pref.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
});
function compareProvidersForPicker(a: string, b: string): number {
const pa = PROVIDER_RANK.get(a);
const pb = PROVIDER_RANK.get(b);
if (pa !== undefined && pb !== undefined) return pa - pb;
if (pa !== undefined) return -1;
if (pb !== undefined) return 1;
return a.localeCompare(b);
}
export function buildModelPickerItems(catalog: ModelPickerCatalogEntry[]): ModelPickerItem[] {
const byModel = new Map<string, { providerModels: Record<string, string> }>();
const seen = new Set<string>();
const out: ModelPickerItem[] = [];
for (const entry of catalog) {
const provider = normalizeProviderId(entry.provider);
const model = normalizeModelFamilyId(entry.id);
const model = entry.id?.trim();
if (!provider || !model) continue;
const existing = byModel.get(model);
if (existing) {
existing.providerModels[provider] = entry.id;
continue;
}
byModel.set(model, { providerModels: { [provider]: entry.id } });
}
const out: ModelPickerItem[] = [];
for (const [model, data] of byModel.entries()) {
const providers = sortProvidersForPicker(Object.keys(data.providerModels));
out.push({ model, providers, providerModels: data.providerModels });
}
out.sort((a, b) => a.model.toLowerCase().localeCompare(b.model.toLowerCase()));
return out;
}
export function pickProviderForModel(params: {
item: ModelPickerItem;
preferredProvider?: string;
}): { provider: string; model: string } | null {
const preferred = params.preferredProvider
? normalizeProviderId(params.preferredProvider)
: undefined;
if (preferred && params.item.providerModels[preferred]) {
return {
provider: preferred,
model: params.item.providerModels[preferred],
};
const key = `${provider}/${model}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ model, provider });
}
const first = params.item.providers[0];
if (!first) return null;
return {
provider: first,
model: params.item.providerModels[first] ?? params.item.model,
};
// Sort by provider preference first, then by model name
out.sort((a, b) => {
const providerOrder = compareProvidersForPicker(a.provider, b.provider);
if (providerOrder !== 0) return providerOrder;
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
});
return out;
}
export function resolveProviderEndpointLabel(
@@ -18,7 +18,6 @@ import {
import {
buildModelPickerItems,
type ModelPickerCatalogEntry,
pickProviderForModel,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
@@ -126,7 +125,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
const current = `${params.provider}/${params.model}`;
const lines: string[] = [`Current: ${current}`, "Pick: /model <#> or /model <provider/model>"];
for (const [idx, item] of items.entries()) {
lines.push(`${idx + 1}) ${item.model} — ${item.providers.join(", ")}`);
lines.push(`${idx + 1}) ${item.provider}/${item.model}`);
}
lines.push("", "More: /model status");
return { text: lines.join("\n") };
@@ -236,22 +235,13 @@ export function resolveModelSelectionFromDirective(params: {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const picked = pickProviderForModel({
item,
preferredProvider: params.provider,
});
if (!picked) {
return {
errorText: `Invalid model selection "${raw}". Use /model to list.`,
};
}
const key = `${picked.provider}/${picked.model}`;
const key = `${item.provider}/${item.model}`;
const aliases = params.aliasIndex.byKey.get(key);
const alias = aliases && aliases.length > 0 ? aliases[0] : undefined;
modelSelection = {
provider: picked.provider,
model: picked.model,
isDefault: picked.provider === params.defaultProvider && picked.model === params.defaultModel,
provider: item.provider,
model: item.model,
isDefault: item.provider === params.defaultProvider && item.model === params.defaultModel,
...(alias ? { alias } : {}),
};
} else {
+2 -10
View File
@@ -5,7 +5,7 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events", async () => {
it("adds a UTC timestamp to queued system events", async () => {
vi.useFakeTimers();
const timestamp = new Date("2026-01-12T20:19:17");
vi.setSystemTime(timestamp);
@@ -20,15 +20,7 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
const expectedTimestamp = timestamp.toLocaleString("en-US", {
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
+11 -10
View File
@@ -25,16 +25,17 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const formatSystemEventTimestamp = (ts: number) =>
new Date(ts).toLocaleString("en-US", {
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const formatSystemEventTimestamp = (ts: number) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
const sec = String(date.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey);
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { applyTemplate, type TemplateContext } from "./templating.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
+31 -1
View File
@@ -24,6 +24,9 @@ export type MsgContext = {
AccountId?: string;
ParentSessionKey?: string;
MessageSid?: string;
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
@@ -78,11 +81,38 @@ export type TemplateContext = MsgContext & {
IsNewSession?: string;
};
function formatTemplateValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "symbol" || typeof value === "function") {
return value.toString();
}
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) return [];
if (typeof entry === "string") return [entry];
if (typeof entry === "number" || typeof entry === "boolean" || typeof entry === "bigint") {
return [String(entry)];
}
return [];
})
.join(",");
}
if (typeof value === "object") {
return "";
}
return "";
}
// Simple {{Placeholder}} interpolation using inbound message context.
export function applyTemplate(str: string | undefined, ctx: TemplateContext) {
if (!str) return "";
return str.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
const value = ctx[key as keyof TemplateContext];
return value == null ? "" : String(value);
return formatTemplateValue(value);
});
}
+4 -3
View File
@@ -46,10 +46,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -85,7 +84,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
port,
token: opts.token || cfg.gateway?.auth?.token || process.env.CLAWDBOT_GATEWAY_TOKEN,
launchdLabel:
process.platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined,
process.platform === "darwin"
? resolveGatewayLaunchAgentLabel(process.env.CLAWDBOT_PROFILE)
: undefined,
});
try {
+6 -9
View File
@@ -21,10 +21,9 @@ export async function runDaemonUninstall() {
export async function runDaemonStart() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -38,7 +37,7 @@ export async function runDaemonStart() {
return;
}
try {
await service.restart({ profile, stdout: process.stdout });
await service.restart({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway start failed: ${String(err)}`);
for (const hint of renderGatewayServiceStartHints()) {
@@ -50,10 +49,9 @@ export async function runDaemonStart() {
export async function runDaemonStop() {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -64,7 +62,7 @@ export async function runDaemonStop() {
return;
}
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
defaultRuntime.error(`Gateway stop failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -78,10 +76,9 @@ export async function runDaemonStop() {
*/
export async function runDaemonRestart(): Promise<boolean> {
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
defaultRuntime.error(`Gateway service check failed: ${String(err)}`);
defaultRuntime.exit(1);
@@ -95,7 +92,7 @@ export async function runDaemonRestart(): Promise<boolean> {
return false;
}
try {
await service.restart({ profile, stdout: process.stdout });
await service.restart({ env: process.env, stdout: process.stdout });
return true;
} catch (err) {
defaultRuntime.error(`Gateway restart failed: ${String(err)}`);
+1 -1
View File
@@ -112,7 +112,7 @@ export async function gatherDaemonStatus(
): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
+2 -2
View File
@@ -262,12 +262,12 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
if (legacyServices.length > 0 || extraServices.length > 0) {
defaultRuntime.error(
errorText(
"Recommendation: run a single gateway per machine. One gateway supports multiple agents.",
"Recommendation: run a single gateway per machine for most setups. One gateway supports multiple agents (see docs: /gateway#multiple-gateways-same-host).",
),
);
defaultRuntime.error(
errorText(
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
"If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
),
);
spacer();
+1 -1
View File
@@ -89,7 +89,7 @@ export async function maybeExplainGatewayServiceStop() {
const service = resolveGatewayService();
let loaded: boolean | null = null;
try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = null;
}
+2 -2
View File
@@ -27,7 +27,7 @@ export async function maybeInstallDaemon(params: {
daemonRuntime?: GatewayDaemonRuntime;
}) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
let shouldCheckLinger = false;
let shouldInstall = true;
let daemonRuntime = params.daemonRuntime ?? DEFAULT_GATEWAY_DAEMON_RUNTIME;
@@ -49,7 +49,7 @@ export async function maybeInstallDaemon(params: {
async (progress) => {
progress.setLabel("Restarting Gateway daemon…");
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
progress.setLabel("Gateway daemon restarted.");
+3 -3
View File
@@ -37,7 +37,7 @@ export async function maybeRepairGatewayDaemon(params: {
if (params.healthOk) return;
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
let serviceRuntime: Awaited<ReturnType<typeof service.readRuntime>> | undefined;
if (loaded) {
serviceRuntime = await service.readRuntime(process.env).catch(() => undefined);
@@ -129,7 +129,7 @@ export async function maybeRepairGatewayDaemon(params: {
});
if (start) {
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
await sleep(1500);
@@ -151,7 +151,7 @@ export async function maybeRepairGatewayDaemon(params: {
});
if (restart) {
await service.restart({
profile: process.env.CLAWDBOT_PROFILE,
env: process.env,
stdout: process.stdout,
});
await sleep(1500);
+3 -3
View File
@@ -89,7 +89,7 @@ export async function maybeMigrateLegacyGatewayService(
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
note(`Clawdbot ${service.label} already ${service.loadedText}.`, "Gateway");
return;
@@ -280,9 +280,9 @@ export async function maybeScanExtraGatewayServices(options: DoctorOptions) {
note(
[
"Recommendation: run a single gateway per machine.",
"Recommendation: run a single gateway per machine for most setups.",
"One gateway supports multiple agents.",
"If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
"If you need multiple gateways (e.g., a rescue bot on the same host), isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).",
].join("\n"),
"Gateway recommendation",
);
+1 -1
View File
@@ -209,7 +209,7 @@ export async function doctorCommand(
const service = resolveGatewayService();
let loaded = false;
try {
loaded = await service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE });
loaded = await service.isLoaded({ env: process.env });
} catch {
loaded = false;
}
+1 -1
View File
@@ -183,7 +183,7 @@ export async function gatewayStatusCommand(
warnings.push({
code: "multiple_gateways",
message:
"Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.",
"Unconventional setup: multiple reachable gateways detected. Usually one gateway per network is recommended unless you intentionally run isolated profiles, like a rescue bot (see docs: /gateway#multiple-gateways-same-host).",
targetIds: reachable.map((p) => p.target.id),
});
}
+2 -3
View File
@@ -38,17 +38,16 @@ const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
async function stopGatewayIfRunning(runtime: RuntimeEnv) {
if (isNixMode) return;
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return;
}
if (!loaded) return;
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}
+1 -1
View File
@@ -133,7 +133,7 @@ export async function statusAllCommand(
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
+1 -1
View File
@@ -10,7 +10,7 @@ export async function getDaemonStatusSummary(): Promise<{
try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ profile: process.env.CLAWDBOT_PROFILE }).catch(() => false),
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
service.readCommand(process.env).catch(() => null),
]);
+2 -3
View File
@@ -55,10 +55,9 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return false;
}
const service = resolveGatewayService();
const profile = process.env.CLAWDBOT_PROFILE;
let loaded = false;
try {
loaded = await service.isLoaded({ profile });
loaded = await service.isLoaded({ env: process.env });
} catch (err) {
runtime.error(`Gateway service check failed: ${String(err)}`);
return false;
@@ -68,7 +67,7 @@ async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
return true;
}
try {
await service.stop({ profile, stdout: process.stdout });
await service.stop({ env: process.env, stdout: process.stdout });
} catch (err) {
runtime.error(`Gateway stop failed: ${String(err)}`);
}
+6
View File
@@ -168,6 +168,7 @@ const FIELD_LABELS: Record<string, string> = {
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
"talk.apiKey": "Talk API Key",
"channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram",
@@ -189,6 +190,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.discord.dm.policy": "Discord DM Policy",
@@ -327,6 +329,8 @@ const FIELD_HELP: Record<string, string> = {
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
"messages.inbound.debounceMs":
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
"channels.telegram.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
"channels.telegram.streamMode":
@@ -348,6 +352,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.whatsapp.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
"channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
"channels.whatsapp.debounceMs":
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
"channels.signal.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy":
+2
View File
@@ -103,6 +103,8 @@ export type AgentDefaultsConfig = {
bootstrapMaxChars?: number;
/** Optional IANA timezone for the user (used in system prompt; defaults to host timezone). */
userTimezone?: string;
/** Time format in system prompt: auto (OS preference), 12-hour, or 24-hour. */
timeFormat?: "auto" | "12" | "24";
/** Optional display-only context window override (used for % in status UIs). */
contextTokens?: number;
/** Optional CLI backends for text-only fallback (claude-cli, etc.). */
+18
View File
@@ -17,6 +17,22 @@ export type QueueConfig = {
drop?: QueueDropPolicy;
};
export type InboundDebounceByProvider = {
whatsapp?: number;
telegram?: number;
discord?: number;
slack?: number;
signal?: number;
imessage?: number;
msteams?: number;
webchat?: number;
};
export type InboundDebounceConfig = {
debounceMs?: number;
byChannel?: InboundDebounceByProvider;
};
export type BroadcastStrategy = "parallel" | "sequential";
export type BroadcastConfig = {
@@ -64,6 +80,8 @@ export type MessagesConfig = {
responsePrefix?: string;
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */
inbound?: InboundDebounceConfig;
/** Emoji reaction used to acknowledge inbound messages (empty disables). */
ackReaction?: string;
/** When to send ack reactions. Default: "group-mentions". */
+15
View File
@@ -76,6 +76,21 @@ export type TelegramAccountConfig = {
webhookPath?: string;
/** Per-action tool gating (default: true for all). */
actions?: TelegramActionConfig;
/**
* Controls which user reactions trigger notifications:
* - "off" (default): ignore all reactions
* - "own": notify when users react to bot messages
* - "all": notify agent of all reactions
*/
reactionNotifications?: "off" | "own" | "all";
/**
* Controls agent's reaction capability:
* - "off": agent cannot react
* - "ack" (default): bot sends acknowledgment reactions (👀 while processing)
* - "minimal": agent can react sparingly (guideline: 1 per 5-10 exchanges)
* - "extensive": agent can react liberally when appropriate
*/
reactionLevel?: "off" | "ack" | "minimal" | "extensive";
};
export type TelegramTopicConfig = {
+4
View File
@@ -75,6 +75,8 @@ export type WhatsAppConfig = {
*/
group?: "always" | "mentions" | "never";
};
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
};
export type WhatsAppAccountConfig = {
@@ -131,4 +133,6 @@ export type WhatsAppAccountConfig = {
*/
group?: "always" | "mentions" | "never";
};
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
debounceMs?: number;
};
+1
View File
@@ -41,6 +41,7 @@ export const AgentDefaultsSchema = z
skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),
timeFormat: z.union([z.literal("auto"), z.literal("12"), z.literal("24")]).optional(),
contextTokens: z.number().int().positive().optional(),
cliBackends: z.record(z.string(), CliBackendSchema).optional(),
memorySearch: MemorySearchSchema,
+20
View File
@@ -188,6 +188,19 @@ export const QueueModeBySurfaceSchema = z
})
.optional();
export const DebounceMsBySurfaceSchema = z
.object({
whatsapp: z.number().int().nonnegative().optional(),
telegram: z.number().int().nonnegative().optional(),
discord: z.number().int().nonnegative().optional(),
slack: z.number().int().nonnegative().optional(),
signal: z.number().int().nonnegative().optional(),
imessage: z.number().int().nonnegative().optional(),
msteams: z.number().int().nonnegative().optional(),
webchat: z.number().int().nonnegative().optional(),
})
.optional();
export const QueueSchema = z
.object({
mode: QueueModeSchema.optional(),
@@ -198,6 +211,13 @@ export const QueueSchema = z
})
.optional();
export const InboundDebounceSchema = z
.object({
debounceMs: z.number().int().nonnegative().optional(),
byChannel: DebounceMsBySurfaceSchema,
})
.optional();
export const TranscribeAudioSchema = z
.object({
command: z.array(z.string()).superRefine((value, ctx) => {
+4
View File
@@ -63,8 +63,12 @@ export const TelegramAccountSchemaBase = z.object({
actions: z
.object({
reactions: z.boolean().optional(),
sendMessage: z.boolean().optional(),
deleteMessage: z.boolean().optional(),
})
.optional(),
reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
});
export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((value, ctx) => {
@@ -46,6 +46,7 @@ export const WhatsAppAccountSchema = z
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
})
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
@@ -101,6 +102,7 @@ export const WhatsAppConfigSchema = z
group: z.enum(["always", "mentions", "never"]).optional().default("mentions"),
})
.optional(),
debounceMs: z.number().int().nonnegative().optional().default(0),
})
.superRefine((value, ctx) => {
if (value.dmPolicy !== "open") return;
+7 -1
View File
@@ -1,6 +1,11 @@
import { z } from "zod";
import { GroupChatSchema, NativeCommandsSettingSchema, QueueSchema } from "./zod-schema.core.js";
import {
GroupChatSchema,
InboundDebounceSchema,
NativeCommandsSettingSchema,
QueueSchema,
} from "./zod-schema.core.js";
export const SessionSchema = z
.object({
@@ -54,6 +59,7 @@ export const MessagesSchema = z
responsePrefix: z.string().optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
inbound: InboundDebounceSchema,
ackReaction: z.string().optional(),
ackReactionScope: z.enum(["group-mentions", "group-all", "direct", "all"]).optional(),
removeAckAfterReply: z.boolean().optional(),
+10
View File
@@ -98,6 +98,11 @@ describe("resolveGatewaySystemdServiceName", () => {
const result = resolveGatewaySystemdServiceName("");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
it("returns default service name for whitespace-only profile", () => {
const result = resolveGatewaySystemdServiceName(" ");
expect(result).toBe(GATEWAY_SYSTEMD_SERVICE_NAME);
});
});
describe("resolveGatewayWindowsTaskName", () => {
@@ -141,6 +146,11 @@ describe("resolveGatewayWindowsTaskName", () => {
const result = resolveGatewayWindowsTaskName("");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
it("returns default task name for whitespace-only profile", () => {
const result = resolveGatewayWindowsTaskName(" ");
expect(result).toBe(GATEWAY_WINDOWS_TASK_NAME);
});
});
describe("formatGatewayServiceDescription", () => {
+77 -1
View File
@@ -5,7 +5,7 @@ import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { installLaunchAgent, parseLaunchctlPrint } from "./launchd.js";
import { installLaunchAgent, parseLaunchctlPrint, resolveLaunchAgentPlistPath } from "./launchd.js";
describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => {
@@ -108,3 +108,79 @@ describe("launchd install", () => {
}
});
});
describe("resolveLaunchAgentPlistPath", () => {
it("uses default label when CLAWDBOT_PROFILE is default", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("uses default label when CLAWDBOT_PROFILE is unset", () => {
const env = { HOME: "/Users/test" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("uses profile-specific label when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.jbphoenix.plist",
);
});
it("prefers CLAWDBOT_LAUNCHD_LABEL over CLAWDBOT_PROFILE", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_PROFILE: "jbphoenix",
CLAWDBOT_LAUNCHD_LABEL: "com.custom.label",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.custom.label.plist",
);
});
it("trims whitespace from CLAWDBOT_LAUNCHD_LABEL", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_LAUNCHD_LABEL: " com.custom.label ",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.custom.label.plist",
);
});
it("ignores empty CLAWDBOT_LAUNCHD_LABEL and falls back to profile", () => {
const env = {
HOME: "/Users/test",
CLAWDBOT_PROFILE: "myprofile",
CLAWDBOT_LAUNCHD_LABEL: " ",
};
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.gateway.plist",
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { HOME: "/Users/test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveLaunchAgentPlistPath(env)).toBe(
"/Users/test/Library/LaunchAgents/com.clawdbot.myprofile.plist",
);
});
});
+7 -15
View File
@@ -24,13 +24,10 @@ const formatLine = (label: string, value: string) => {
return `${colorize(rich, theme.muted, `${label}:`)} ${colorize(rich, theme.command, value)}`;
};
function resolveLaunchAgentLabel(params?: {
env?: Record<string, string | undefined>;
profile?: string;
}): string {
const envLabel = params?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim();
function resolveLaunchAgentLabel(args?: { env?: Record<string, string | undefined> }): string {
const envLabel = args?.env?.CLAWDBOT_LAUNCHD_LABEL?.trim();
if (envLabel) return envLabel;
return resolveGatewayLaunchAgentLabel(params?.profile);
return resolveGatewayLaunchAgentLabel(args?.env?.CLAWDBOT_PROFILE);
}
function resolveHomeDir(env: Record<string, string | undefined>): string {
const home = env.HOME?.trim() || env.USERPROFILE?.trim();
@@ -181,12 +178,11 @@ export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
return info;
}
export async function isLaunchAgentLoaded(params?: {
export async function isLaunchAgentLoaded(args: {
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<boolean> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel(params);
const label = resolveLaunchAgentLabel({ env: args.env });
const res = await execLaunchctl(["print", `${domain}/${label}`]);
return res.code === 0;
}
@@ -343,14 +339,12 @@ function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: numbe
export async function stopLaunchAgent({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env, profile });
const label = resolveLaunchAgentLabel({ env });
const res = await execLaunchctl(["bootout", `${domain}/${label}`]);
if (res.code !== 0 && !isLaunchctlNotLoaded(res)) {
throw new Error(`launchctl bootout failed: ${res.stderr || res.stdout}`.trim());
@@ -425,14 +419,12 @@ export async function installLaunchAgent({
export async function restartLaunchAgent({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
const domain = resolveGuiDomain();
const label = resolveLaunchAgentLabel({ env, profile });
const label = resolveLaunchAgentLabel({ env });
const res = await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]);
if (res.code !== 0) {
throw new Error(`launchctl kickstart failed: ${res.stderr || res.stdout}`.trim());
+223 -1
View File
@@ -1,6 +1,10 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseSchtasksQuery } from "./schtasks.js";
import { parseSchtasksQuery, readScheduledTaskCommand, resolveTaskScriptPath } from "./schtasks.js";
describe("schtasks runtime parsing", () => {
it("parses status and last run info", () => {
@@ -16,4 +20,222 @@ describe("schtasks runtime parsing", () => {
lastRunResult: "0x0",
});
});
it("parses running status", () => {
const output = [
"TaskName: \\Clawdbot Gateway",
"Status: Running",
"Last Run Time: 1/8/2026 1:23:45 AM",
"Last Run Result: 0x0",
].join("\r\n");
expect(parseSchtasksQuery(output)).toEqual({
status: "Running",
lastRunTime: "1/8/2026 1:23:45 AM",
lastRunResult: "0x0",
});
});
});
describe("resolveTaskScriptPath", () => {
it("uses default path when CLAWDBOT_PROFILE is default", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "default" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("uses default path when CLAWDBOT_PROFILE is unset", () => {
const env = { USERPROFILE: "C:\\Users\\test" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("uses profile-specific path when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot-jbphoenix", "gateway.cmd"),
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "Default" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot", "gateway.cmd"),
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { USERPROFILE: "C:\\Users\\test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveTaskScriptPath(env)).toBe(
path.join("C:\\Users\\test", ".clawdbot-myprofile", "gateway.cmd"),
);
});
it("falls back to HOME when USERPROFILE is not set", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" };
expect(resolveTaskScriptPath(env)).toBe(path.join("/home/test", ".clawdbot", "gateway.cmd"));
});
});
describe("readScheduledTaskCommand", () => {
it("parses basic command script", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "node gateway.js --port 18789"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js", "--port", "18789"],
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with working directory", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "cd /d C:\\Projects\\clawdbot", "node gateway.js"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
workingDirectory: "C:\\Projects\\clawdbot",
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with environment variables", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "set NODE_ENV=production", "set PORT=18789", "node gateway.js"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js"],
environment: {
NODE_ENV: "production",
PORT: "18789",
},
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses script with quoted arguments containing spaces", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
// Use forward slashes which work in Windows cmd and avoid escape parsing issues
await fs.writeFile(
scriptPath,
["@echo off", '"C:/Program Files/Node/node.exe" gateway.js'].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["C:/Program Files/Node/node.exe", "gateway.js"],
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("returns null when script does not exist", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("returns null when script has no command", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
["@echo off", "rem This is just a comment"].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toBeNull();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("parses full script with all components", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-schtasks-test-"));
try {
const scriptPath = path.join(tmpDir, ".clawdbot", "gateway.cmd");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(
scriptPath,
[
"@echo off",
"rem Clawdbot Gateway",
"cd /d C:\\Projects\\clawdbot",
"set NODE_ENV=production",
"set CLAWDBOT_PORT=18789",
"node gateway.js --verbose",
].join("\r\n"),
"utf8",
);
const env = { USERPROFILE: tmpDir, CLAWDBOT_PROFILE: "default" };
const result = await readScheduledTaskCommand(env);
expect(result).toEqual({
programArguments: ["node", "gateway.js", "--verbose"],
workingDirectory: "C:\\Projects\\clawdbot",
environment: {
NODE_ENV: "production",
CLAWDBOT_PORT: "18789",
},
});
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+11 -9
View File
@@ -21,7 +21,7 @@ function resolveHomeDir(env: Record<string, string | undefined>): string {
return home;
}
function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
export function resolveTaskScriptPath(env: Record<string, string | undefined>): string {
const home = resolveHomeDir(env);
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix = profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
@@ -274,13 +274,13 @@ function isTaskNotRunning(res: { stdout: string; stderr: string; code: number })
export async function stopScheduledTask({
stdout,
profile,
env,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
const res = await execSchtasks(["/End", "/TN", taskName]);
if (res.code !== 0 && !isTaskNotRunning(res)) {
throw new Error(`schtasks end failed: ${res.stderr || res.stdout}`.trim());
@@ -290,13 +290,13 @@ export async function stopScheduledTask({
export async function restartScheduledTask({
stdout,
profile,
env,
}: {
stdout: NodeJS.WritableStream;
profile?: string;
env?: Record<string, string | undefined>;
}): Promise<void> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(env?.CLAWDBOT_PROFILE);
await execSchtasks(["/End", "/TN", taskName]);
const res = await execSchtasks(["/Run", "/TN", taskName]);
if (res.code !== 0) {
@@ -305,9 +305,11 @@ export async function restartScheduledTask({
stdout.write(`${formatLine("Restarted Scheduled Task", taskName)}\n`);
}
export async function isScheduledTaskInstalled(profile?: string): Promise<boolean> {
export async function isScheduledTaskInstalled(args: {
env?: Record<string, string | undefined>;
}): Promise<boolean> {
await assertSchtasksAvailable();
const taskName = resolveGatewayWindowsTaskName(profile);
const taskName = resolveGatewayWindowsTaskName(args.env?.CLAWDBOT_PROFILE);
const res = await execSchtasks(["/Query", "/TN", taskName]);
return res.code === 0;
}
+6 -15
View File
@@ -46,18 +46,13 @@ export type GatewayService = {
}) => Promise<void>;
stop: (args: {
env?: Record<string, string | undefined>;
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
restart: (args: {
env?: Record<string, string | undefined>;
profile?: string;
stdout: NodeJS.WritableStream;
}) => Promise<void>;
isLoaded: (args: {
env?: Record<string, string | undefined>;
profile?: string;
}) => Promise<boolean>;
isLoaded: (args: { env?: Record<string, string | undefined> }) => Promise<boolean>;
readCommand: (env: Record<string, string | undefined>) => Promise<{
programArguments: string[];
workingDirectory?: string;
@@ -82,18 +77,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopLaunchAgent({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartLaunchAgent({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isLaunchAgentLoaded({ profile: args.profile, env: args.env }),
isLoaded: async (args) => isLaunchAgentLoaded(args),
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
};
@@ -113,18 +106,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopSystemdService({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartSystemdService({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isSystemdServiceEnabled({ profile: args.profile, env: args.env }),
isLoaded: async (args) => isSystemdServiceEnabled(args),
readCommand: readSystemdServiceExecStart,
readRuntime: async (env) => await readSystemdServiceRuntime(env),
};
@@ -144,16 +135,16 @@ export function resolveGatewayService(): GatewayService {
stop: async (args) => {
await stopScheduledTask({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
restart: async (args) => {
await restartScheduledTask({
stdout: args.stdout,
profile: args.profile,
env: args.env,
});
},
isLoaded: async (args) => isScheduledTaskInstalled(args.profile),
isLoaded: async (args) => isScheduledTaskInstalled(args),
readCommand: readScheduledTaskCommand,
readRuntime: async (env) => await readScheduledTaskRuntime(env),
};
+76 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseSystemdShow } from "./systemd.js";
import { parseSystemdShow, resolveSystemdUserUnitPath } from "./systemd.js";
describe("systemd runtime parsing", () => {
it("parses active state details", () => {
@@ -19,3 +19,78 @@ describe("systemd runtime parsing", () => {
});
});
});
describe("resolveSystemdUserUnitPath", () => {
it("uses default service name when CLAWDBOT_PROFILE is default", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "default" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("uses default service name when CLAWDBOT_PROFILE is unset", () => {
const env = { HOME: "/home/test" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("uses profile-specific service name when CLAWDBOT_PROFILE is set to a custom value", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "jbphoenix" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway-jbphoenix.service",
);
});
it("prefers CLAWDBOT_SYSTEMD_UNIT over CLAWDBOT_PROFILE", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_PROFILE: "jbphoenix",
CLAWDBOT_SYSTEMD_UNIT: "custom-unit",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("handles CLAWDBOT_SYSTEMD_UNIT with .service suffix", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_SYSTEMD_UNIT: "custom-unit.service",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("trims whitespace from CLAWDBOT_SYSTEMD_UNIT", () => {
const env = {
HOME: "/home/test",
CLAWDBOT_SYSTEMD_UNIT: " custom-unit ",
};
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/custom-unit.service",
);
});
it("handles case-insensitive 'Default' profile", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "Default" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("handles case-insensitive 'DEFAULT' profile", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: "DEFAULT" };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway.service",
);
});
it("trims whitespace from CLAWDBOT_PROFILE", () => {
const env = { HOME: "/home/test", CLAWDBOT_PROFILE: " myprofile " };
expect(resolveSystemdUserUnitPath(env)).toBe(
"/home/test/.config/systemd/user/clawdbot-gateway-myprofile.service",
);
});
});
+4 -17
View File
@@ -50,14 +50,6 @@ function resolveSystemdServiceName(env: Record<string, string | undefined>): str
return resolveGatewaySystemdServiceName(env.CLAWDBOT_PROFILE);
}
function resolveSystemdServiceNameFromParams(params?: {
env?: Record<string, string | undefined>;
profile?: string;
}): string {
if (params?.env) return resolveSystemdServiceName(params.env);
return resolveGatewaySystemdServiceName(params?.profile);
}
function resolveSystemdUnitPath(env: Record<string, string | undefined>): string {
return resolveSystemdUnitPathForName(env, resolveSystemdServiceName(env));
}
@@ -268,14 +260,12 @@ export async function uninstallSystemdService({
export async function stopSystemdService({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams({ env, profile });
const serviceName = resolveSystemdServiceName(env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "stop", unitName]);
if (res.code !== 0) {
@@ -287,14 +277,12 @@ export async function stopSystemdService({
export async function restartSystemdService({
stdout,
env,
profile,
}: {
stdout: NodeJS.WritableStream;
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<void> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams({ env, profile });
const serviceName = resolveSystemdServiceName(env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "restart", unitName]);
if (res.code !== 0) {
@@ -303,12 +291,11 @@ export async function restartSystemdService({
stdout.write(`${formatLine("Restarted systemd service", unitName)}\n`);
}
export async function isSystemdServiceEnabled(params?: {
export async function isSystemdServiceEnabled(args: {
env?: Record<string, string | undefined>;
profile?: string;
}): Promise<boolean> {
await assertSystemdAvailable();
const serviceName = resolveSystemdServiceNameFromParams(params);
const serviceName = resolveSystemdServiceName(args.env ?? {});
const unitName = `${serviceName}.service`;
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;
+17 -1
View File
@@ -215,6 +215,7 @@ describe("discord groupPolicy gating", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "open",
guildAllowlisted: false,
channelAllowlistConfigured: false,
channelAllowed: false,
}),
@@ -225,26 +226,40 @@ describe("discord groupPolicy gating", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "disabled",
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: true,
}),
).toBe(false);
});
it("blocks allowlist when no channel allowlist configured", () => {
it("blocks allowlist when guild is not allowlisted", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
guildAllowlisted: false,
channelAllowlistConfigured: false,
channelAllowed: true,
}),
).toBe(false);
});
it("allows allowlist when guild allowlisted but no channel allowlist", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
guildAllowlisted: true,
channelAllowlistConfigured: false,
channelAllowed: true,
}),
).toBe(true);
});
it("allows allowlist when channel is allowed", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: true,
}),
@@ -255,6 +270,7 @@ describe("discord groupPolicy gating", () => {
expect(
isDiscordGroupAllowedByPolicy({
groupPolicy: "allowlist",
guildAllowlisted: true,
channelAllowlistConfigured: true,
channelAllowed: false,
}),
+4 -2
View File
@@ -197,13 +197,15 @@ export function resolveDiscordShouldRequireMention(params: {
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean;
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
}): boolean {
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
const { groupPolicy, guildAllowlisted, channelAllowlistConfigured, channelAllowed } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (!channelAllowlistConfigured) return false;
if (!guildAllowlisted) return false;
if (!channelAllowlistConfigured) return true;
return channelAllowed;
}
@@ -260,6 +260,7 @@ export async function preflightDiscordMessage(
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
+85 -5
View File
@@ -1,11 +1,19 @@
import type { Client } from "@buape/carbon";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import type { ReplyToMode } from "../../config/config.js";
import { danger } from "../../globals.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordMessageHandler } from "./listeners.js";
import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import { processDiscordMessage } from "./message-handler.process.js";
import { resolveDiscordMessageText } from "./message-utils.js";
type LoadedConfig = ReturnType<typeof import("../../config/config.js").loadConfig>;
type DiscordConfig = NonNullable<
@@ -32,18 +40,90 @@ export function createDiscordMessageHandler(params: {
}): DiscordMessageHandler {
const groupPolicy = params.discordConfig?.groupPolicy ?? "open";
const ackReactionScope = params.cfg.messages?.ackReactionScope ?? "group-mentions";
const debounceMs = resolveInboundDebounceMs({ cfg: params.cfg, channel: "discord" });
return async (data, client) => {
try {
const debouncer = createInboundDebouncer<{ data: DiscordMessageEvent; client: Client }>({
debounceMs,
buildKey: (entry) => {
const message = entry.data.message;
const authorId = entry.data.author?.id;
if (!message || !authorId) return null;
const channelId = message.channelId;
if (!channelId) return null;
return `discord:${params.accountId}:${channelId}:${authorId}`;
},
shouldDebounce: (entry) => {
const message = entry.data.message;
if (!message) return false;
if (message.attachments && message.attachments.length > 0) return false;
const baseText = resolveDiscordMessageText(message, { includeForwarded: false });
if (!baseText.trim()) return false;
return !hasControlCommand(baseText, params.cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
const ctx = await preflightDiscordMessage({
...params,
ackReactionScope,
groupPolicy,
data: last.data,
client: last.client,
});
if (!ctx) return;
await processDiscordMessage(ctx);
return;
}
const combinedBaseText = entries
.map((entry) => resolveDiscordMessageText(entry.data.message, { includeForwarded: false }))
.filter(Boolean)
.join("\n");
const syntheticMessage = {
...last.data.message,
content: combinedBaseText,
attachments: [],
message_snapshots: (last.data.message as { message_snapshots?: unknown }).message_snapshots,
messageSnapshots: (last.data.message as { messageSnapshots?: unknown }).messageSnapshots,
rawData: {
...(last.data.message as { rawData?: Record<string, unknown> }).rawData,
},
};
const syntheticData: DiscordMessageEvent = {
...last.data,
message: syntheticMessage,
};
const ctx = await preflightDiscordMessage({
...params,
ackReactionScope,
groupPolicy,
data,
client,
data: syntheticData,
client: last.client,
});
if (!ctx) return;
if (entries.length > 1) {
const ids = entries.map((entry) => entry.data.message?.id).filter(Boolean) as string[];
if (ids.length > 0) {
const ctxBatch = ctx as typeof ctx & {
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
};
ctxBatch.MessageSids = ids;
ctxBatch.MessageSidFirst = ids[0];
ctxBatch.MessageSidLast = ids[ids.length - 1];
}
}
await processDiscordMessage(ctx);
},
onError: (err) => {
params.runtime.error?.(danger(`discord debounce flush failed: ${String(err)}`));
},
});
return async (data, client) => {
try {
await debouncer.enqueue({ data, client });
} catch (err) {
params.runtime.error?.(danger(`handler failed: ${String(err)}`));
}
+2 -1
View File
@@ -118,7 +118,7 @@ function readDiscordCommandArgs(
if (!definitions || definitions.length === 0) return undefined;
const values: CommandArgValues = {};
for (const definition of definitions) {
let value: unknown;
let value: string | number | boolean | null | undefined;
if (definition.type === "number") {
value = interaction.options.getNumber(definition.name);
} else if (definition.type === "boolean") {
@@ -448,6 +448,7 @@ async function dispatchDiscordCommandInteraction(params: {
const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: discordConfig?.groupPolicy ?? "open",
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
});
+52 -4
View File
@@ -10,6 +10,10 @@ import {
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
@@ -73,11 +77,48 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const includeAttachments = opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes = (opts.mediaMaxMb ?? imessageCfg.mediaMaxMb ?? 16) * 1024 * 1024;
const handleMessage = async (raw: unknown) => {
const params = raw as { message?: IMessagePayload | null };
const message = params?.message ?? null;
if (!message) return;
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "imessage" });
const inboundDebouncer = createInboundDebouncer<{ message: IMessagePayload }>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const sender = entry.message.sender?.trim();
if (!sender) return null;
const conversationId =
entry.message.chat_id != null
? `chat:${entry.message.chat_id}`
: (entry.message.chat_guid ?? entry.message.chat_identifier ?? "unknown");
return `imessage:${accountInfo.accountId}:${conversationId}:${sender}`;
},
shouldDebounce: (entry) => {
const text = entry.message.text?.trim() ?? "";
if (!text) return false;
if (entry.message.attachments && entry.message.attachments.length > 0) return false;
return !hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await handleMessageNow(last.message);
return;
}
const combinedText = entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const syntheticMessage: IMessagePayload = {
...last.message,
text: combinedText,
attachments: null,
};
await handleMessageNow(syntheticMessage);
},
onError: (err) => {
runtime.error?.(`imessage debounce flush failed: ${String(err)}`);
},
});
async function handleMessageNow(message: IMessagePayload) {
const senderRaw = message.sender ?? "";
const sender = senderRaw.trim();
if (!sender) return;
@@ -403,6 +444,13 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
if (isGroup && historyKey && historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: groupHistories, historyKey });
}
}
const handleMessage = async (raw: unknown) => {
const params = raw as { message?: IMessagePayload | null };
const message = params?.message ?? null;
if (!message) return;
await inboundDebouncer.enqueue({ message });
};
const client = await createIMessageRpcClient({
+3 -1
View File
@@ -32,7 +32,9 @@ export function buildPortHints(listeners: PortListener[], port: number): string[
hints.push("Another process is listening on this port.");
}
if (listeners.length > 1) {
hints.push("Multiple listeners detected; ensure only one gateway/tunnel.");
hints.push(
"Multiple listeners detected; ensure only one gateway/tunnel per port unless intentionally running isolated profiles.",
);
}
return hints;
}
+61
View File
@@ -14,6 +14,7 @@ const createFetchMock = () =>
describe("embedding provider remote overrides", () => {
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
vi.unstubAllGlobals();
});
@@ -107,3 +108,63 @@ describe("embedding provider remote overrides", () => {
expect(headers.Authorization).toBe("Bearer provider-key");
});
});
describe("embedding provider local fallback", () => {
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
vi.unstubAllGlobals();
vi.doUnmock("./node-llama.js");
});
it("falls back to openai when node-llama-cpp is missing", async () => {
vi.doMock("./node-llama.js", () => ({
importNodeLlamaCpp: async () => {
throw Object.assign(new Error("Cannot find package 'node-llama-cpp'"), {
code: "ERR_MODULE_NOT_FOUND",
});
},
}));
const fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
const { createEmbeddingProvider } = await import("./embeddings.js");
const authModule = await import("../agents/model-auth.js");
vi.mocked(authModule.resolveApiKeyForProvider).mockResolvedValue({
apiKey: "provider-key",
});
const result = await createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "text-embedding-3-small",
fallback: "openai",
});
expect(result.provider.id).toBe("openai");
expect(result.fallbackFrom).toBe("local");
expect(result.fallbackReason).toContain("node-llama-cpp");
});
it("throws a helpful error when local is requested and fallback is none", async () => {
vi.doMock("./node-llama.js", () => ({
importNodeLlamaCpp: async () => {
throw Object.assign(new Error("Cannot find package 'node-llama-cpp'"), {
code: "ERR_MODULE_NOT_FOUND",
});
},
}));
const { createEmbeddingProvider } = await import("./embeddings.js");
await expect(
createEmbeddingProvider({
config: {} as never,
provider: "local",
model: "text-embedding-3-small",
fallback: "none",
}),
).rejects.toThrow(/optional dependency node-llama-cpp/i);
});
});
+23 -5
View File
@@ -1,6 +1,7 @@
import type { Llama, LlamaEmbeddingContext, LlamaModel } from "node-llama-cpp";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import type { ClawdbotConfig } from "../config/config.js";
import { importNodeLlamaCpp } from "./node-llama.js";
export type EmbeddingProvider = {
id: string;
@@ -105,7 +106,7 @@ async function createLocalEmbeddingProvider(
const modelCacheDir = options.local?.modelCacheDir?.trim();
// Lazy-load node-llama-cpp to keep startup light unless local is enabled.
const { getLlama, resolveModelFile, LlamaLogLevel } = await import("node-llama-cpp");
const { getLlama, resolveModelFile, LlamaLogLevel } = await importNodeLlamaCpp();
let llama: Llama | null = null;
let embeddingModel: LlamaModel | null = null;
@@ -181,15 +182,32 @@ function formatError(err: unknown): string {
return String(err);
}
function isNodeLlamaCppMissing(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const code = (err as Error & { code?: unknown }).code;
if (code === "ERR_MODULE_NOT_FOUND") {
return err.message.includes("node-llama-cpp");
}
return false;
}
function formatLocalSetupError(err: unknown): string {
const detail = formatError(err);
const missing = isNodeLlamaCppMissing(err);
return [
"Local embeddings unavailable.",
detail ? `Reason: ${detail}` : undefined,
missing
? "Reason: optional dependency node-llama-cpp is missing (or failed to install)."
: detail
? `Reason: ${detail}`
: undefined,
missing && detail ? `Detail: ${detail}` : null,
"To enable local embeddings:",
"1) pnpm approve-builds",
"2) select node-llama-cpp",
"3) pnpm rebuild node-llama-cpp",
"1) Use Node 22 LTS (recommended for installs/updates)",
missing
? "2) Reinstall Clawdbot (this should install node-llama-cpp): npm i -g clawdbot@latest"
: null,
"3) If you use pnpm: pnpm approve-builds (select node-llama-cpp), then pnpm rebuild node-llama-cpp",
'Or set agents.defaults.memorySearch.provider = "openai" (remote).',
]
.filter(Boolean)
+3
View File
@@ -0,0 +1,3 @@
export async function importNodeLlamaCpp() {
return import("node-llama-cpp");
}
+79 -8
View File
@@ -1,4 +1,9 @@
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
@@ -61,14 +66,22 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
DEFAULT_GROUP_HISTORY_LIMIT,
);
const conversationHistories = new Map<string, HistoryEntry[]>();
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "msteams" });
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
type MSTeamsDebounceEntry = {
context: MSTeamsTurnContext;
rawText: string;
text: string;
attachments: MSTeamsAttachmentLike[];
wasMentioned: boolean;
};
const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => {
const context = params.context;
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const rawText = params.rawText;
const text = params.text;
const attachments = params.attachments;
const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments);
const rawBody = text || attachmentPlaceholder;
const from = activity.from;
@@ -288,7 +301,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
});
if (!isDirectMessage) {
const mentioned = wasMSTeamsBotMentioned(activity);
const mentioned = params.wasMentioned;
if (requireMention && !mentioned) {
log.debug("skipping message (mention required)", {
teamId,
@@ -366,7 +379,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const,
MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || wasMSTeamsBotMentioned(activity),
WasMentioned: isDirectMessage || params.wasMentioned,
CommandAuthorized: true,
OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo,
@@ -433,4 +446,62 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}
}
};
const inboundDebouncer = createInboundDebouncer<MSTeamsDebounceEntry>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const conversationId = normalizeMSTeamsConversationId(
entry.context.activity.conversation?.id ?? "",
);
const senderId =
entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? "";
if (!senderId || !conversationId) return null;
return `msteams:${appId}:${conversationId}:${senderId}`;
},
shouldDebounce: (entry) => {
if (!entry.text.trim()) return false;
if (entry.attachments.length > 0) return false;
return !hasControlCommand(entry.text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await handleTeamsMessageNow(last);
return;
}
const combinedText = entries
.map((entry) => entry.text)
.filter(Boolean)
.join("\n");
if (!combinedText.trim()) return;
const combinedRawText = entries
.map((entry) => entry.rawText)
.filter(Boolean)
.join("\n");
const wasMentioned = entries.some((entry) => entry.wasMentioned);
await handleTeamsMessageNow({
context: last.context,
rawText: combinedRawText,
text: combinedText,
attachments: [],
wasMentioned,
});
},
onError: (err) => {
runtime.error?.(danger(`msteams debounce flush failed: ${String(err)}`));
},
});
return async function handleTeamsMessage(context: MSTeamsTurnContext) {
const activity = context.activity;
const rawText = activity.text?.trim() ?? "";
const text = stripMSTeamsMentionTags(rawText);
const attachments = Array.isArray(activity.attachments)
? (activity.attachments as unknown as MSTeamsAttachmentLike[])
: [];
const wasMentioned = wasMSTeamsBotMentioned(activity);
await inboundDebouncer.enqueue({ context, rawText, text, attachments, wasMentioned });
};
}
+222 -139
View File
@@ -7,7 +7,12 @@ import {
extractShortModelName,
type ResponsePrefixContext,
} from "../../auto-reply/reply/response-prefix-template.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { formatAgentEnvelope } from "../../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
import { buildHistoryContextFromMap, clearHistoryEntries } from "../../auto-reply/reply/history.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
@@ -36,6 +41,207 @@ import { sendMessageSignal } from "../send.js";
import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js";
export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const inboundDebounceMs = resolveInboundDebounceMs({ cfg: deps.cfg, channel: "signal" });
type SignalInboundEntry = {
senderName: string;
senderDisplay: string;
senderRecipient: string;
senderPeerId: string;
groupId?: string;
groupName?: string;
isGroup: boolean;
bodyText: string;
timestamp?: number;
messageId?: string;
mediaPath?: string;
mediaType?: string;
commandAuthorized: boolean;
};
async function handleSignalInboundMessage(entry: SignalInboundEntry) {
const fromLabel = entry.isGroup
? `${entry.groupName ?? "Signal Group"} id:${entry.groupId}`
: `${entry.senderName} id:${entry.senderDisplay}`;
const body = formatAgentEnvelope({
channel: "Signal",
from: fromLabel,
timestamp: entry.timestamp ?? undefined,
body: entry.bodyText,
});
let combinedBody = body;
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: deps.groupHistories,
historyKey,
limit: deps.historyLimit,
entry: {
sender: entry.senderName,
body: entry.bodyText,
timestamp: entry.timestamp ?? undefined,
messageId: entry.messageId,
},
currentMessage: combinedBody,
formatEntry: (historyEntry) =>
formatAgentEnvelope({
channel: "Signal",
from: fromLabel,
timestamp: historyEntry.timestamp,
body: `${historyEntry.sender}: ${historyEntry.body}${
historyEntry.messageId ? ` [id:${historyEntry.messageId}]` : ""
}`,
}),
});
}
const route = resolveAgentRoute({
cfg: deps.cfg,
channel: "signal",
accountId: deps.accountId,
peer: {
kind: entry.isGroup ? "group" : "dm",
id: entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId,
},
});
const signalTo = entry.isGroup ? `group:${entry.groupId}` : `signal:${entry.senderRecipient}`;
const ctxPayload = {
Body: combinedBody,
RawBody: entry.bodyText,
CommandBody: entry.bodyText,
From: entry.isGroup
? `group:${entry.groupId ?? "unknown"}`
: `signal:${entry.senderRecipient}`,
To: signalTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: entry.isGroup ? "group" : "direct",
GroupSubject: entry.isGroup ? (entry.groupName ?? undefined) : undefined,
SenderName: entry.senderName,
SenderId: entry.senderDisplay,
Provider: "signal" as const,
Surface: "signal" as const,
MessageSid: entry.messageId,
Timestamp: entry.timestamp ?? undefined,
MediaPath: entry.mediaPath,
MediaType: entry.mediaType,
MediaUrl: entry.mediaPath,
CommandAuthorized: entry.commandAuthorized,
OriginatingChannel: "signal" as const,
OriginatingTo: signalTo,
};
if (!entry.isGroup) {
const sessionCfg = deps.cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "signal",
to: entry.senderRecipient,
accountId: route.accountId,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n");
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
let didSendReply = false;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(deps.cfg, route.agentId),
};
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
deliver: async (payload) => {
await deps.deliverReplies({
replies: [payload],
target: ctxPayload.To,
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
runtime: deps.runtime,
maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit,
});
didSendReply = true;
},
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
});
const { queuedFinal } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected: (ctx) => {
// Mutate the object directly instead of reassigning to ensure the closure sees updates
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
});
if (!queuedFinal) {
if (entry.isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
return;
}
if (entry.isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
}
const inboundDebouncer = createInboundDebouncer<SignalInboundEntry>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const conversationId = entry.isGroup ? (entry.groupId ?? "unknown") : entry.senderPeerId;
if (!conversationId || !entry.senderPeerId) return null;
return `signal:${deps.accountId}:${conversationId}:${entry.senderPeerId}`;
},
shouldDebounce: (entry) => {
if (!entry.bodyText.trim()) return false;
if (entry.mediaPath || entry.mediaType) return false;
return !hasControlCommand(entry.bodyText, deps.cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await handleSignalInboundMessage(last);
return;
}
const combinedText = entries
.map((entry) => entry.bodyText)
.filter(Boolean)
.join("\\n");
if (!combinedText.trim()) return;
await handleSignalInboundMessage({
...last,
bodyText: combinedText,
mediaPath: undefined,
mediaType: undefined,
});
},
onError: (err) => {
deps.runtime.error?.(`signal debounce flush failed: ${String(err)}`);
},
});
return async (event: { event?: string; data?: string }) => {
if (event.event !== "receive" || !event.data) return;
@@ -230,146 +436,23 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
if (!bodyText) return;
const fromLabel = isGroup
? `${groupName ?? "Signal Group"} id:${groupId}`
: `${envelope.sourceName ?? senderDisplay} id:${senderDisplay}`;
const body = formatAgentEnvelope({
channel: "Signal",
from: fromLabel,
const senderName = envelope.sourceName ?? senderDisplay;
const messageId =
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
await inboundDebouncer.enqueue({
senderName,
senderDisplay,
senderRecipient,
senderPeerId,
groupId,
groupName,
isGroup,
bodyText,
timestamp: envelope.timestamp ?? undefined,
body: bodyText,
messageId,
mediaPath,
mediaType,
commandAuthorized,
});
let combinedBody = body;
const historyKey = isGroup ? String(groupId ?? "unknown") : undefined;
if (isGroup && historyKey && deps.historyLimit > 0) {
combinedBody = buildHistoryContextFromMap({
historyMap: deps.groupHistories,
historyKey,
limit: deps.historyLimit,
entry: {
sender: envelope.sourceName ?? senderDisplay,
body: bodyText,
timestamp: envelope.timestamp ?? undefined,
messageId:
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined,
},
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
channel: "Signal",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
}),
});
}
const route = resolveAgentRoute({
cfg: deps.cfg,
channel: "signal",
accountId: deps.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: isGroup ? (groupId ?? "unknown") : senderPeerId,
},
});
const signalTo = isGroup ? `group:${groupId}` : `signal:${senderRecipient}`;
const ctxPayload = {
Body: combinedBody,
RawBody: bodyText,
CommandBody: bodyText,
From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${senderRecipient}`,
To: signalTo,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
SenderName: envelope.sourceName ?? senderDisplay,
SenderId: senderDisplay,
Provider: "signal" as const,
Surface: "signal" as const,
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
Timestamp: envelope.timestamp ?? undefined,
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "signal" as const,
OriginatingTo: signalTo,
};
if (!isGroup) {
const sessionCfg = deps.cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
channel: "signal",
to: senderRecipient,
accountId: route.accountId,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
let didSendReply = false;
// Create mutable context for response prefix template interpolation
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(deps.cfg, route.agentId),
};
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
deliver: async (payload) => {
await deps.deliverReplies({
replies: [payload],
target: ctxPayload.To,
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
runtime: deps.runtime,
maxBytes: deps.mediaMaxBytes,
textLimit: deps.textLimit,
});
didSendReply = true;
},
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
});
const { queuedFinal } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
replyOptions: {
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected: (ctx) => {
// Mutate the object directly instead of reassigning to ensure the closure sees updates
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
});
if (!queuedFinal) {
if (isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
return;
}
if (isGroup && historyKey && deps.historyLimit > 0 && didSendReply) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
}
};
}
+66 -3
View File
@@ -1,3 +1,8 @@
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMessageEvent } from "../types.js";
import type { SlackMonitorContext } from "./context.js";
@@ -14,6 +19,66 @@ export function createSlackMessageHandler(params: {
account: ResolvedSlackAccount;
}): SlackMessageHandler {
const { ctx, account } = params;
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
const debouncer = createInboundDebouncer<{
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}>({
debounceMs,
buildKey: (entry) => {
const senderId = entry.message.user ?? entry.message.bot_id;
if (!senderId) return null;
const threadKey = entry.message.thread_ts
? `${entry.message.channel}:${entry.message.thread_ts}`
: entry.message.channel;
return `slack:${ctx.accountId}:${threadKey}:${senderId}`;
},
shouldDebounce: (entry) => {
const text = entry.message.text ?? "";
if (!text.trim()) return false;
if (entry.message.files && entry.message.files.length > 0) return false;
return !hasControlCommand(text, ctx.cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
const combinedText =
entries.length === 1
? (last.message.text ?? "")
: entries
.map((entry) => entry.message.text ?? "")
.filter(Boolean)
.join("\n");
const combinedMentioned = entries.some((entry) => Boolean(entry.opts.wasMentioned));
const syntheticMessage: SlackMessageEvent = {
...last.message,
text: combinedText,
};
const prepared = await prepareSlackMessage({
ctx,
account,
message: syntheticMessage,
opts: {
...last.opts,
wasMentioned: combinedMentioned || last.opts.wasMentioned,
},
});
if (!prepared) return;
if (entries.length > 1) {
const ids = entries.map((entry) => entry.message.ts).filter(Boolean) as string[];
if (ids.length > 0) {
prepared.ctxPayload.MessageSids = ids;
prepared.ctxPayload.MessageSidFirst = ids[0];
prepared.ctxPayload.MessageSidLast = ids[ids.length - 1];
}
}
await dispatchPreparedSlackMessage(prepared);
},
onError: (err) => {
ctx.runtime.error?.(`slack inbound debounce flush failed: ${String(err)}`);
},
});
return async (message, opts) => {
if (opts.source === "message" && message.type !== "message") return;
@@ -26,8 +91,6 @@ export function createSlackMessageHandler(params: {
return;
}
if (ctx.markMessageSeen(message.channel, message.ts)) return;
const prepared = await prepareSlackMessage({ ctx, account, message, opts });
if (!prepared) return;
await dispatchPreparedSlackMessage(prepared);
await debouncer.enqueue({ message, opts });
};
}
+11
View File
@@ -0,0 +1,11 @@
import { API_CONSTANTS } from "grammy";
type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[];
if (!updates.includes("message_reaction")) {
updates.push("message_reaction");
}
return updates;
}
+74 -1
View File
@@ -1,4 +1,9 @@
// @ts-nocheck
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../auto-reply/inbound-debounce.js";
import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { danger, logVerbose, warn } from "../globals.js";
@@ -43,6 +48,61 @@ export const registerTelegramHandlers = ({
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
let textFragmentProcessing: Promise<void> = Promise.resolve();
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
type TelegramDebounceEntry = {
ctx: unknown;
msg: TelegramMessage;
allMedia: Array<{ path: string; contentType?: string }>;
storeAllowFrom: string[];
debounceKey: string | null;
botUsername?: string;
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
buildKey: (entry) => entry.debounceKey,
shouldDebounce: (entry) => {
if (entry.allMedia.length > 0) return false;
const text = entry.msg.text ?? entry.msg.caption ?? "";
if (!text.trim()) return false;
return !hasControlCommand(text, cfg, { botUsername: entry.botUsername });
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await processMessage(last.ctx, last.allMedia, last.storeAllowFrom);
return;
}
const combinedText = entries
.map((entry) => entry.msg.text ?? entry.msg.caption ?? "")
.filter(Boolean)
.join("\n");
if (!combinedText.trim()) return;
const first = entries[0];
const baseCtx = first.ctx as { me?: unknown; getFile?: unknown } & Record<string, unknown>;
const getFile =
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
const syntheticMessage: TelegramMessage = {
...first.msg,
text: combinedText,
caption: undefined,
caption_entities: undefined,
entities: undefined,
date: last.msg.date ?? first.msg.date,
};
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
await processMessage(
{ message: syntheticMessage, me: baseCtx.me, getFile },
[],
first.storeAllowFrom,
messageIdOverride ? { messageIdOverride } : undefined,
);
},
onError: (err) => {
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
},
});
const processMediaGroup = async (entry: MediaGroupEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
@@ -403,7 +463,20 @@ export const registerTelegramHandlers = ({
throw mediaErr;
}
const allMedia = media ? [{ path: media.path, contentType: media.contentType }] : [];
await processMessage(ctx, allMedia, storeAllowFrom);
const senderId = msg.from?.id ? String(msg.from.id) : "";
const conversationKey =
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
const debounceKey = senderId
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}`
: null;
await inboundDebouncer.enqueue({
ctx,
msg,
allMedia,
storeAllowFrom,
debounceKey,
botUsername: ctx.me?.username,
});
} catch (err) {
runtime.error?.(danger(`handler failed: ${String(err)}`));
}
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more