Compare commits

..
6 changed files with 133 additions and 52 deletions

No files matched your search

+2 -1
View File
@@ -7,6 +7,7 @@
- Agent runtime: update pi-mono dependencies to 0.31.1 (agent-core split).
- Dependencies: bump to latest compatible versions (TypeBox, grammY, Zod, Rolldown, oxlint-tsgolint).
- Tests: cover read tool image metadata + text output.
- Hooks: treat null transforms as handled skips (204) and let explicit mappings override presets (#117) — thanks @jverdi.
### Breaking
- Skills config schema moved under `skills.*`:
@@ -53,6 +54,7 @@
- Skills: add tmux-first coding-agent skill + `requires.anyBins` gate for multi-CLI setup (thanks @sreekaransrinath).
### Fixes
- Gog calendar: format date ranges as RFC 3339 with timezone to satisfy Google Calendar API (thanks @jayhickey).
- Chat UI: keep the chat scrolled to the latest message after switching sessions.
- Auto-reply: stream completed reply blocks as soon as they finish (configurable default + break); skip empty tool-only blocks unless verbose.
- Providers: make outbound text chunk limits configurable via `*.textChunkLimit` (defaults remain 4000/Discord 2000).
@@ -107,7 +109,6 @@
- Cron: prevent `every` schedules without an anchor from firing in a tight loop (thanks @jamesgroat).
- Docs: add manual OAuth setup for remote/headless deployments (#67) — thanks @wstock
- Docs/agent tools: clarify that browser `wait` should be avoided by default and used only in exceptional cases.
- Docs: clarify self-chat mode and group mention gating config (#111) — thanks @rafaelreis-r.
- Browser tools: `upload` supports auto-click refs, direct `inputRef`/`element` file inputs, and emits input/change after `setFiles` so JS-heavy sites pick up attachments.
- Browser tools: harden CDP readiness (HTTP + WS), retry CDP connects, and auto-restart the clawd browser when the socket handshake stalls.
- Browser CLI: add `clawdis browser reset-profile` to move the clawd profile to Trash when it gets wedged.
+65 -49
View File
@@ -10,11 +10,11 @@ CLAWDIS reads an optional **JSON5** config from `~/.clawdis/clawdis.json` (comme
If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
- control group mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
- tune group mention behavior (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`)
- customize message prefixes (`messages`)
- set the agent's workspace (`agent.workspace`)
- set the agent’s workspace (`agent.workspace`)
- tune the embedded agent (`agent`) and session behavior (`session`)
- set the agent's identity (`identity`)
- set the agent’s identity (`identity`)
## Minimal config (recommended starting point)
@@ -25,26 +25,6 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
}
```
## Self-chat mode (recommended for group control)
To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):
```json5
{
agent: { workspace: "~/clawd" },
whatsapp: {
// Allowlist is DMs only; including your own number enables self-chat mode.
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } }
},
routing: {
groupChat: {
mentionPatterns: ["@clawd", "reisponde"]
}
}
}
```
## Common options
### `identity`
@@ -98,8 +78,7 @@ Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).
### `whatsapp.allowFrom`
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (DMs only).
If empty, the default allowlist is your own WhatsApp number (self-chat mode).
Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies.
```json5
{
@@ -110,13 +89,24 @@ If empty, the default allowlist is your own WhatsApp number (self-chat mode).
}
```
### `whatsapp.groups`
Per-group mention gating for WhatsApp groups. Default group config lives at `whatsapp.groups."*"`.
```json5
{
whatsapp: {
groups: {
"*": { requireMention: true },
"123@g.us": { requireMention: false } // group JID
}
}
}
```
### `routing.groupChat`
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
Group mention patterns + history handling shared across surfaces (WhatsApp/iMessage/Telegram/Discord).
```json5
{
@@ -128,26 +118,8 @@ Group messages default to **require mention** (either metadata mention or regex
}
}
```
Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`).
To respond **only** to specific text triggers (ignoring native @-mentions):
```json5
{
whatsapp: {
// Include your own number to enable self-chat mode (ignore native @-mentions).
allowFrom: ["+15555550123"],
groups: { "*": { requireMention: true } }
},
routing: {
groupChat: {
// Only these text patterns will trigger responses
mentionPatterns: ["reisponde", "@clawd"]
}
}
}
```
### `routing.queue`
Controls how inbound messages behave when an agent run is already active.
@@ -200,7 +172,12 @@ Set `telegram.enabled: false` to disable automatic startup.
telegram: {
enabled: true,
botToken: "your-bot-token",
requireMention: true,
textChunkLimit: 4000, // optional outbound chunk size (chars)
replyToMode: "off",
groups: {
"*": { requireMention: true },
"123456789": { requireMention: false } // group chat id
},
allowFrom: ["123456789"],
mediaMaxMb: 5,
proxy: "socks5://localhost:9050",
@@ -210,6 +187,8 @@ Set `telegram.enabled: false` to disable automatic startup.
}
}
```
Mention gating precedence (most specific wins): `telegram.groups.<chatId>.requireMention` → `telegram.groups."*".requireMention` → default `true`.
Reply threading is controlled via `telegram.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
### `discord` (bot transport)
@@ -220,8 +199,10 @@ Configure the Discord bot by setting the bot token and optional gating:
discord: {
enabled: true,
token: "your-bot-token",
textChunkLimit: 2000, // optional outbound chunk size (chars)
mediaMaxMb: 8, // clamp inbound media size
enableReactions: true, // allow agent-triggered reactions
replyToMode: "off", // off | first | all
slashCommand: { // user-installed app slash commands
enabled: true,
name: "clawd",
@@ -235,6 +216,7 @@ Configure the Discord bot by setting the bot token and optional gating:
groupChannels: ["clawd-dm"] // optional group DM allowlist
},
guilds: {
"*": { requireMention: true }, // default per-guild mention gating
"123456789012345678": { // guild id (preferred) or slug
slug: "friends-of-clawd",
requireMention: false, // per-guild default
@@ -251,7 +233,23 @@ Configure the Discord bot by setting the bot token and optional gating:
```
Clawdis starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
Reply threading is controlled via `discord.replyToMode` (`off` | `first` | `all`) and reply tags in the model output.
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Use `discord.guilds."*"` for default per-guild settings.
### `signal` (signal-cli JSON-RPC)
Clawdis can send/receive Signal via `signal-cli` (daemon or existing HTTP URL).
```json5
{
signal: {
enabled: true,
textChunkLimit: 4000, // optional outbound chunk size (chars)
mediaMaxMb: 8
}
}
```
### `imessage` (imsg CLI)
@@ -263,7 +261,12 @@ Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.
enabled: true,
cliPath: "imsg",
dbPath: "~/Library/Messages/chat.db",
textChunkLimit: 4000, // optional outbound chunk size (chars)
allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
groups: {
"*": { requireMention: true },
"123": { requireMention: false } // chat_id for the group
},
includeAttachments: false,
mediaMaxMb: 16,
service: "auto",
@@ -276,6 +279,7 @@ Notes:
- Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
- Group mention gating lives in `imessage.groups` (default at `imessage.groups."*"`).
### `agent.workspace`
@@ -292,6 +296,7 @@ Default: `~/clawd`.
### `messages`
Controls inbound/outbound prefixes and timestamps.
Outbound text chunking is configured per provider via `*.textChunkLimit` (e.g. `whatsapp.textChunkLimit`, `telegram.textChunkLimit`).
```json5
{
@@ -346,6 +351,8 @@ Controls the embedded agent runtime (model/thinking/verbose/timeouts).
},
thinkingDefault: "low",
verboseDefault: "off",
blockStreamingDefault: "on",
blockStreamingBreak: "text_end",
timeoutSeconds: 600,
mediaMaxMb: 5,
heartbeat: {
@@ -370,6 +377,14 @@ deprecation fallback.
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.
`agent.blockStreamingDefault` controls whether completed assistant blocks are
sent immediately (default: `on`). Set to `off` to only deliver the final
consolidated reply.
`agent.blockStreamingBreak` controls what “block” means:
- `text_end` (default): end of each assistant text content block (before tool calls)
- `message_end`: end of the whole assistant message (may wait across tools)
`agent.heartbeat` configures periodic heartbeat runs:
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
`0m` to disable.
@@ -686,6 +701,7 @@ Endpoints:
Mapping notes:
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
- `hooks.mappings` are evaluated before presets; first match wins.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.
+1
View File
@@ -89,6 +89,7 @@ code transforms.
Mapping options (summary):
- `hooks.presets: ["gmail"]` enables the built-in Gmail mapping.
- `hooks.mappings` lets you define `match`, `action`, and templates in config.
- `hooks.mappings` are evaluated before presets; first match wins.
- `hooks.transformsDir` + `transform.module` loads a JS/TS module for custom logic.
- Use `match.source` to keep a generic ingest endpoint (payload-driven routing).
- TS transforms require a TS loader (e.g. `tsx`) or precompiled `.js` at runtime.
+55
View File
@@ -74,6 +74,61 @@ describe("hooks mapping", () => {
}
});
it("treats null transform as a handled skip", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-hooks-skip-"));
const modPath = path.join(dir, "transform.mjs");
fs.writeFileSync(modPath, "export default () => null;");
const mappings = resolveHookMappings({
transformsDir: dir,
mappings: [
{
match: { path: "skip" },
action: "agent",
transform: { module: "transform.mjs" },
},
],
});
const result = await applyHookMappings(mappings, {
payload: {},
headers: {},
url: new URL("http://127.0.0.1:18789/hooks/skip"),
path: "skip",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action).toBeNull();
expect("skipped" in result).toBe(true);
}
});
it("prefers explicit mappings over presets", async () => {
const mappings = resolveHookMappings({
presets: ["gmail"],
mappings: [
{
id: "override",
match: { path: "gmail" },
action: "agent",
messageTemplate: "Override subject: {{messages[0].subject}}",
},
],
});
const result = await applyHookMappings(mappings, {
payload: { messages: [{ subject: "Hello" }] },
headers: {},
url: baseUrl,
path: "gmail",
});
expect(result?.ok).toBe(true);
if (result?.ok) {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Override subject: Hello");
}
});
it("rejects missing message", async () => {
const mappings = resolveHookMappings({
mappings: [{ match: { path: "noop" }, action: "agent" }],
+5 -2
View File
@@ -70,6 +70,7 @@ export type HookAction =
export type HookMappingResult =
| { ok: true; action: HookAction }
| { ok: true; action: null; skipped: true }
| { ok: false; error: string };
const hookPresetMappings: Record<string, HookMappingConfig[]> = {
@@ -113,11 +114,11 @@ export function resolveHookMappings(
): HookMappingResolved[] {
const presets = hooks?.presets ?? [];
const mappings: HookMappingConfig[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
for (const preset of presets) {
const presetMappings = hookPresetMappings[preset];
if (presetMappings) mappings.push(...presetMappings);
}
if (hooks?.mappings) mappings.push(...hooks.mappings);
if (mappings.length === 0) return [];
const configDir = path.dirname(CONFIG_PATH_CLAWDIS);
@@ -145,7 +146,9 @@ export async function applyHookMappings(
if (mapping.transform) {
const transform = await loadTransform(mapping.transform);
override = await transform(ctx);
if (override === null) return null;
if (override === null) {
return { ok: true, action: null, skipped: true };
}
}
const merged = mergeAction(base.action, override, mapping.action);
+5
View File
@@ -1706,6 +1706,11 @@ export async function startGatewayServer(
sendJson(res, 400, { ok: false, error: mapped.error });
return true;
}
if (mapped.action === null) {
res.statusCode = 204;
res.end();
return true;
}
if (mapped.action.kind === "wake") {
dispatchWakeHook({
text: mapped.action.text,