Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1561b1c491 | ||
|
|
57d3c8572f | ||
|
|
c609be185e | ||
|
|
d00f2d9c0c | ||
|
|
8b89980a89 | ||
|
|
634a429c50 | ||
|
|
bf90815b9e | ||
|
|
2b113c4d6c | ||
|
|
7f6a288bd3 | ||
|
|
daf471c450 | ||
|
|
cb78fa46a1 | ||
|
|
316e8b2eb2 | ||
|
|
4de81ed6c4 | ||
|
|
b6fb24f6d2 | ||
|
|
2b1c26f900 | ||
|
|
47ea9356d1 | ||
|
|
f12c1b391f | ||
|
|
05658b6609 | ||
|
|
dfb6630de1 | ||
|
|
eb7656d68c | ||
|
|
0e1dcf9cb4 | ||
|
|
d05c3d0659 | ||
|
|
36292d3fbb | ||
|
|
1ae344d8a6 | ||
|
|
01c43b0b0c | ||
|
|
fc4aa9a683 | ||
|
|
c043e9767f | ||
|
|
8b48299d8f | ||
|
|
0f27cff247 | ||
|
|
d0455f2683 |
No files matched your search
+16
-5
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+175
-60
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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, "");
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
+7
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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();
|
||||
});
|
||||
|
||||
+100
-26
@@ -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 {
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.). */
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function importNodeLlamaCpp() {
|
||||
return import("node-llama-cpp");
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user