Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e089ec9a0 | ||
|
|
fd3c76cad3 | ||
|
|
8b3e3a4b96 | ||
|
|
e0e39a6d52 | ||
|
|
bd7443b39b | ||
|
|
93bef830ce | ||
|
|
2dfbd1c1f6 | ||
|
|
1d9f230be4 | ||
|
|
9bf295da48 | ||
|
|
eebd750781 | ||
|
|
aa11300175 | ||
|
|
7b40d1b261 | ||
|
|
2c10c601a8 | ||
|
|
578ac9f1a9 |
No files matched your search
@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
|
||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
|
||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
@@ -27,6 +28,7 @@ Docs: https://docs.clawd.bot
|
||||
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
|
||||
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
|
||||
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
|
||||
- Compaction: harden oversized summarization handling and gate UI compaction status by verbose level. (#1466) Thanks @dlauer.
|
||||
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
|
||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||
@@ -34,6 +36,7 @@ Docs: https://docs.clawd.bot
|
||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
||||
- Agents: make tool summaries more readable and only show optional params when set.
|
||||
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
|
||||
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.
|
||||
- CLI: prefer `~` for home paths in output.
|
||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||
|
||||
+69
-1
@@ -304,7 +304,8 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
|
||||
"policy": "pairing",
|
||||
"allowFrom": ["U123", "U456", "*"],
|
||||
"groupEnabled": false,
|
||||
"groupChannels": ["G123"]
|
||||
"groupChannels": ["G123"],
|
||||
"replyToMode": "all"
|
||||
},
|
||||
"channels": {
|
||||
"C123": { "allow": true, "requireMention": true },
|
||||
@@ -361,6 +362,73 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
|
||||
|
||||
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
|
||||
|
||||
### Per-chat-type threading
|
||||
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off", // default for channels
|
||||
replyToModeByChatType: {
|
||||
direct: "all", // DMs always thread
|
||||
group: "first" // group DMs/MPIM thread first reply
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported chat types:
|
||||
- `direct`: 1:1 DMs (Slack `im`)
|
||||
- `group`: group DMs / MPIMs (Slack `mpim`)
|
||||
- `channel`: standard channels (public/private)
|
||||
|
||||
Precedence:
|
||||
1) `replyToModeByChatType.<chatType>`
|
||||
2) `replyToMode`
|
||||
3) Provider default (`off`)
|
||||
|
||||
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
|
||||
|
||||
Examples:
|
||||
|
||||
Thread DMs only:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Thread group DMs but keep channels in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { group: "first" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Make channels thread, keep DMs in the root:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
replyToModeByChatType: { direct: "off", group: "off" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual threading tags
|
||||
For fine-grained control, use these tags in agent responses:
|
||||
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
|
||||
|
||||
@@ -129,7 +129,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
// Optional CLI fallback (Whisper binary):
|
||||
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
],
|
||||
|
||||
@@ -1865,7 +1865,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- Each `models[]` entry:
|
||||
- Provider entry (`type: "provider"` or omitted):
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
|
||||
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `model`: model id override (required for image; defaults to `gpt-4o-mini-transcribe`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
|
||||
- `profile` / `preferredProfile`: auth profile selection.
|
||||
- CLI entry (`type: "cli"`):
|
||||
- `command`: executable to run.
|
||||
@@ -1890,7 +1890,7 @@ Example:
|
||||
rules: [{ action: "allow", match: { chatType: "direct" } }]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
|
||||
]
|
||||
},
|
||||
|
||||
+19
-3
@@ -6,7 +6,7 @@ read_when:
|
||||
# Audio / Voice Notes — 2026-01-17
|
||||
|
||||
## What works
|
||||
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot:
|
||||
- **Media understanding (audio)**: If audio understanding is enabled (or auto‑detected), Clawdbot:
|
||||
1) Locates the first audio attachment (local path or URL) and downloads it if needed.
|
||||
2) Enforces `maxBytes` before sending to each model entry.
|
||||
3) Runs the first eligible model entry in order (provider or CLI).
|
||||
@@ -15,6 +15,21 @@ read_when:
|
||||
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
|
||||
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
|
||||
|
||||
## Auto-detection (default)
|
||||
If you **don’t configure models** and `tools.media.audio.enabled` is **not** set to `false`,
|
||||
Clawdbot auto-detects in this order and stops at the first working option:
|
||||
|
||||
1) **Local CLIs** (if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (from `whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys** (OpenAI → Groq → Deepgram → Google)
|
||||
|
||||
To disable auto-detection, set `tools.media.audio.enabled: false`.
|
||||
To customize, set `tools.media.audio.models`.
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Config examples
|
||||
|
||||
### Provider + CLI fallback (OpenAI + Whisper CLI)
|
||||
@@ -26,7 +41,7 @@ read_when:
|
||||
enabled: true,
|
||||
maxBytes: 20971520,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
@@ -54,7 +69,7 @@ read_when:
|
||||
]
|
||||
},
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" }
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -83,6 +98,7 @@ read_when:
|
||||
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
|
||||
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
|
||||
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
|
||||
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
|
||||
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
|
||||
- Transcript is available to templates as `{{Transcript}}`.
|
||||
- CLI stdout is capped (5MB); keep CLI output concise.
|
||||
|
||||
@@ -6,7 +6,7 @@ read_when:
|
||||
---
|
||||
# Media Understanding (Inbound) — 2026-01-17
|
||||
|
||||
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
|
||||
Clawdbot can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual.
|
||||
|
||||
## Goals
|
||||
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
|
||||
@@ -88,6 +88,11 @@ Each `models[]` entry can be **provider** or **CLI**:
|
||||
}
|
||||
```
|
||||
|
||||
CLI templates can also use:
|
||||
- `{{MediaDir}}` (directory containing the media file)
|
||||
- `{{OutputDir}}` (scratch dir created for this run)
|
||||
- `{{OutputBase}}` (scratch file base path, no extension)
|
||||
|
||||
## Defaults and limits
|
||||
Recommended defaults:
|
||||
- `maxChars`: **500** for image/video (short, command‑friendly)
|
||||
@@ -104,17 +109,22 @@ Rules:
|
||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
### Auto-enable audio (when keys exist)
|
||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||
even when you haven’t listed models explicitly.
|
||||
### Auto-detect media understanding (default)
|
||||
If `tools.media.<capability>.enabled` is **not** set to `false` and you haven’t
|
||||
configured models, Clawdbot auto-detects in this order and **stops at the first
|
||||
working option**:
|
||||
|
||||
Providers checked (in order):
|
||||
1) OpenAI
|
||||
2) Groq
|
||||
3) Deepgram
|
||||
1) **Local CLIs** (audio only; if installed)
|
||||
- `sherpa-onnx-offline` (requires `SHERPA_ONNX_MODEL_DIR` with encoder/decoder/joiner/tokens)
|
||||
- `whisper-cli` (`whisper-cpp`; uses `WHISPER_CPP_MODEL` or the bundled tiny model)
|
||||
- `whisper` (Python CLI; downloads models automatically)
|
||||
2) **Gemini CLI** (`gemini`) using `read_many_files`
|
||||
3) **Provider keys**
|
||||
- Audio: OpenAI → Groq → Deepgram → Google
|
||||
- Image: OpenAI → Anthropic → Google → MiniMax
|
||||
- Video: Google
|
||||
|
||||
To disable this behavior, set:
|
||||
To disable auto-detection, set:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
@@ -126,6 +136,7 @@ To disable this behavior, set:
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
|
||||
|
||||
## Capabilities (optional)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
@@ -142,7 +153,7 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Capability | Provider integration | Notes |
|
||||
|------------|----------------------|-------|
|
||||
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
|
||||
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
|
||||
| Audio | OpenAI, Groq, Deepgram, Google | Provider transcription (Whisper/Deepgram/Gemini). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
@@ -151,8 +162,8 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper` binary.
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
@@ -209,7 +220,7 @@ When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
|
||||
audio: {
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "openai", model: "whisper-1" },
|
||||
{ provider: "openai", model: "gpt-4o-mini-transcribe" },
|
||||
{
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("./credentials.js");
|
||||
} = await import("../credentials.js");
|
||||
|
||||
const cached = loadMatrixCredentials(env);
|
||||
const cachedCredentials =
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { MatrixClient } from "matrix-bot-sdk";
|
||||
import { EventType } from "./types.js";
|
||||
|
||||
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
|
||||
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
|
||||
});
|
||||
|
||||
describe("resolveMatrixRoomId", () => {
|
||||
it("uses m.direct when available", async () => {
|
||||
const userId = "@user:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockResolvedValue({
|
||||
[userId]: ["!room:example.org"],
|
||||
}),
|
||||
getJoinedRooms: vi.fn(),
|
||||
getJoinedRoomMembers: vi.fn(),
|
||||
setAccountData: vi.fn(),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const roomId = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(roomId).toBe("!room:example.org");
|
||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to joined rooms and persists m.direct", async () => {
|
||||
const userId = "@fallback:example.org";
|
||||
const roomId = "!room:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
]),
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalledWith(
|
||||
EventType.Direct,
|
||||
expect.objectContaining({ [userId]: [roomId] }),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues when a room member lookup fails", async () => {
|
||||
const userId = "@continue:example.org";
|
||||
const roomId = "!good:example.org";
|
||||
const setAccountData = vi.fn().mockResolvedValue(undefined);
|
||||
const getJoinedRoomMembers = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(["@bot:example.org", userId]);
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
|
||||
getJoinedRoomMembers,
|
||||
setAccountData,
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
expect(setAccountData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows larger rooms when no 1:1 match exists", async () => {
|
||||
const userId = "@group:example.org";
|
||||
const roomId = "!group:example.org";
|
||||
const client = {
|
||||
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
|
||||
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue([
|
||||
"@bot:example.org",
|
||||
userId,
|
||||
"@extra:example.org",
|
||||
]),
|
||||
setAccountData: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MatrixClient;
|
||||
|
||||
const resolved = await resolveMatrixRoomId(client, userId);
|
||||
|
||||
expect(resolved).toBe(roomId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeThreadId", () => {
|
||||
it("returns null for empty thread ids", () => {
|
||||
expect(normalizeThreadId(" ")).toBeNull();
|
||||
expect(normalizeThreadId("$thread")).toBe("$thread");
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,100 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
|
||||
const directRoomCache = new Map<string, string>();
|
||||
|
||||
async function persistDirectRoom(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
roomId: string,
|
||||
): Promise<void> {
|
||||
let directContent: MatrixDirectAccountData | null = null;
|
||||
try {
|
||||
directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
} catch {
|
||||
// Ignore fetch errors and fall back to an empty map.
|
||||
}
|
||||
const existing =
|
||||
directContent && !Array.isArray(directContent) ? directContent : {};
|
||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||
if (current[0] === roomId) return;
|
||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||
try {
|
||||
await client.setAccountData(EventType.Direct, {
|
||||
...existing,
|
||||
[userId]: next,
|
||||
});
|
||||
} catch {
|
||||
// Ignore persistence errors.
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectRoomId(
|
||||
client: MatrixClient,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed.startsWith("@")) {
|
||||
throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
|
||||
throw new Error(
|
||||
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
|
||||
);
|
||||
}
|
||||
// matrix-bot-sdk: use getAccountData to retrieve m.direct
|
||||
|
||||
const cached = directRoomCache.get(trimmed);
|
||||
if (cached) return cached;
|
||||
|
||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||
try {
|
||||
const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list.length > 0) return list[0];
|
||||
const directContent = (await client.getAccountData(
|
||||
EventType.Direct,
|
||||
)) as MatrixDirectAccountData | null;
|
||||
const list = Array.isArray(directContent?.[trimmed])
|
||||
? directContent[trimmed]
|
||||
: [];
|
||||
if (list.length > 0) {
|
||||
directRoomCache.set(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, try fetching from server
|
||||
// Ignore and fall back.
|
||||
}
|
||||
throw new Error(
|
||||
`No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
|
||||
);
|
||||
|
||||
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user.
|
||||
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle.
|
||||
let fallbackRoom: string | null = null;
|
||||
try {
|
||||
const rooms = await client.getJoinedRooms();
|
||||
for (const roomId of rooms) {
|
||||
let members: string[];
|
||||
try {
|
||||
members = await client.getJoinedRoomMembers(roomId);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!members.includes(trimmed)) continue;
|
||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||
if (members.length === 2) {
|
||||
directRoomCache.set(trimmed, roomId);
|
||||
await persistDirectRoom(client, trimmed, roomId);
|
||||
return roomId;
|
||||
}
|
||||
if (!fallbackRoom) {
|
||||
fallbackRoom = roomId;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back.
|
||||
}
|
||||
|
||||
if (fallbackRoom) {
|
||||
directRoomCache.set(trimmed, fallbackRoom);
|
||||
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||
return fallbackRoom;
|
||||
}
|
||||
|
||||
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
|
||||
}
|
||||
|
||||
export async function resolveMatrixRoomId(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
readStringParam,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
@@ -162,8 +163,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ TRASH
|
||||
local needle="$1"
|
||||
local timeout_s="${2:-45}"
|
||||
local needle_compact
|
||||
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
|
||||
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")"
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
@@ -61,17 +61,12 @@ TRASH
|
||||
let text = \"\";
|
||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||
if (text.length > 20000) text = text.slice(-20000);
|
||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
||||
const haystack = sanitize(text);
|
||||
const safeNeedle = sanitize(needle);
|
||||
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
||||
let escaped = \"\";
|
||||
for (const ch of safeNeedle) {
|
||||
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
|
||||
}
|
||||
const pattern = escaped.split(\"\").join(\".*\");
|
||||
const re = new RegExp(pattern, \"i\");
|
||||
process.exit(re.test(haystack) ? 0 : 1);
|
||||
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\");
|
||||
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\");
|
||||
const haystack = compact(text);
|
||||
const compactNeedle = compact(needle);
|
||||
if (!compactNeedle) process.exit(1);
|
||||
process.exit(haystack.includes(compactNeedle) ? 0 : 1);
|
||||
"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -260,9 +255,11 @@ TRASH
|
||||
|
||||
send_skills_flow() {
|
||||
# Select skills section and skip optional installs.
|
||||
send $'"'"'\r'"'"' 1.2
|
||||
wait_for_log "Where will the Gateway run?" 60 || true
|
||||
send $'"'"'\r'"'"' 0.6
|
||||
# Configure skills now? -> No
|
||||
send $'"'"'n\r'"'"' 1.5
|
||||
wait_for_log "Configure skills now?" 60 || true
|
||||
send $'"'"'n\r'"'"' 0.8
|
||||
send "" 1.0
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
|
||||
import type { TSchema } from "@sinclair/typebox";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
@@ -184,10 +186,28 @@ export function logToolSchemasForGoogle(params: { tools: AgentTool[]; provider:
|
||||
}
|
||||
}
|
||||
|
||||
// Event emitter for unhandled compaction failures that escape try-catch blocks.
|
||||
// Listeners can use this to trigger session recovery with retry.
|
||||
const compactionFailureEmitter = new EventEmitter();
|
||||
|
||||
export type CompactionFailureListener = (reason: string) => void;
|
||||
|
||||
/**
|
||||
* Register a listener for unhandled compaction failures.
|
||||
* Called when auto-compaction fails in a way that escapes the normal try-catch,
|
||||
* e.g., when the summarization request itself exceeds the model's token limit.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function onUnhandledCompactionFailure(cb: CompactionFailureListener): () => void {
|
||||
compactionFailureEmitter.on("failure", cb);
|
||||
return () => compactionFailureEmitter.off("failure", cb);
|
||||
}
|
||||
|
||||
registerUnhandledRejectionHandler((reason) => {
|
||||
const message = describeUnknownError(reason);
|
||||
if (!isCompactionFailureError(message)) return false;
|
||||
log.error(`Auto-compaction failed (unhandled): ${message}`);
|
||||
compactionFailureEmitter.emit("failure", message);
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,15 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { __testing } from "./compaction-safeguard.js";
|
||||
|
||||
const { collectToolFailures, formatToolFailuresSection } = __testing;
|
||||
const {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} = __testing;
|
||||
|
||||
describe("compaction-safeguard tool failures", () => {
|
||||
it("formats tool failures with meta and summary", () => {
|
||||
@@ -96,3 +104,107 @@ describe("compaction-safeguard tool failures", () => {
|
||||
expect(section).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAdaptiveChunkRatio", () => {
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
|
||||
it("returns BASE_CHUNK_RATIO for normal messages", () => {
|
||||
// Small messages: 1000 tokens each, well under 10% of context
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(1000), timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "y".repeat(1000) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("reduces ratio when average message > 10% of context", () => {
|
||||
// Large messages: ~50K tokens each (25% of context)
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(50_000 * 4), timestamp: Date.now() },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "y".repeat(50_000 * 4) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeLessThan(BASE_CHUNK_RATIO);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("respects MIN_CHUNK_RATIO floor", () => {
|
||||
// Very large messages that would push ratio below minimum
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(150_000 * 4), timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("handles empty message array", () => {
|
||||
const ratio = computeAdaptiveChunkRatio([], CONTEXT_WINDOW);
|
||||
expect(ratio).toBe(BASE_CHUNK_RATIO);
|
||||
});
|
||||
|
||||
it("handles single huge message", () => {
|
||||
// Single massive message
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "x".repeat(180_000 * 4), timestamp: Date.now() },
|
||||
];
|
||||
|
||||
const ratio = computeAdaptiveChunkRatio(messages, CONTEXT_WINDOW);
|
||||
expect(ratio).toBeGreaterThanOrEqual(MIN_CHUNK_RATIO);
|
||||
expect(ratio).toBeLessThanOrEqual(BASE_CHUNK_RATIO);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOversizedForSummary", () => {
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
|
||||
it("returns false for small messages", () => {
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "Hello, world!",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for messages > 50% of context", () => {
|
||||
// Message with ~120K tokens (60% of 200K context)
|
||||
// After safety margin (1.2x), effective is 144K which is > 100K (50%)
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(120_000 * 4),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
expect(isOversizedForSummary(msg, CONTEXT_WINDOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("applies safety margin", () => {
|
||||
// Message at exactly 50% of context before margin
|
||||
// After SAFETY_MARGIN (1.2), it becomes 60% which is > 50%
|
||||
const halfContextChars = (CONTEXT_WINDOW * 0.5) / SAFETY_MARGIN;
|
||||
const msg: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(Math.floor(halfContextChars * 4)),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// With safety margin applied, this should be at the boundary
|
||||
// The function checks if tokens * SAFETY_MARGIN > contextWindow * 0.5
|
||||
const isOversized = isOversizedForSummary(msg, CONTEXT_WINDOW);
|
||||
// Due to token estimation, this could be either true or false at the boundary
|
||||
expect(typeof isOversized).toBe("boolean");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@ import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
|
||||
const MAX_CHUNK_RATIO = 0.4;
|
||||
const BASE_CHUNK_RATIO = 0.4;
|
||||
const MIN_CHUNK_RATIO = 0.15;
|
||||
const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
|
||||
const FALLBACK_SUMMARY =
|
||||
"Summary unavailable due to context limits. Older messages were truncated.";
|
||||
const TURN_PREFIX_INSTRUCTIONS =
|
||||
@@ -13,6 +15,15 @@ const TURN_PREFIX_INSTRUCTIONS =
|
||||
const MAX_TOOL_FAILURES = 8;
|
||||
const MAX_TOOL_FAILURE_CHARS = 240;
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
if (name === "AbortError") return true;
|
||||
const message =
|
||||
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
|
||||
return message.includes("aborted");
|
||||
}
|
||||
|
||||
type ToolFailure = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
@@ -160,6 +171,38 @@ function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessag
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute adaptive chunk ratio based on average message size.
|
||||
* When messages are large, we use smaller chunks to avoid exceeding model limits.
|
||||
*/
|
||||
function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
|
||||
if (messages.length === 0) return BASE_CHUNK_RATIO;
|
||||
|
||||
const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
|
||||
const avgTokens = totalTokens / messages.length;
|
||||
|
||||
// Apply safety margin to account for estimation inaccuracy
|
||||
const safeAvgTokens = avgTokens * SAFETY_MARGIN;
|
||||
const avgRatio = safeAvgTokens / contextWindow;
|
||||
|
||||
// If average message is > 10% of context, reduce chunk ratio
|
||||
if (avgRatio > 0.1) {
|
||||
const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
|
||||
return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
|
||||
}
|
||||
|
||||
return BASE_CHUNK_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a single message is too large to summarize.
|
||||
* If single message > 50% of context, it can't be summarized safely.
|
||||
*/
|
||||
function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
|
||||
const tokens = estimateTokens(msg) * SAFETY_MARGIN;
|
||||
return tokens > contextWindow * 0.5;
|
||||
}
|
||||
|
||||
async function summarizeChunks(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
@@ -192,6 +235,84 @@ async function summarizeChunks(params: {
|
||||
return summary ?? "No prior history.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize with progressive fallback for handling oversized messages.
|
||||
* If full summarization fails, tries partial summarization excluding oversized messages.
|
||||
*/
|
||||
async function summarizeWithFallback(params: {
|
||||
messages: AgentMessage[];
|
||||
model: NonNullable<ExtensionContext["model"]>;
|
||||
apiKey: string;
|
||||
signal: AbortSignal;
|
||||
reserveTokens: number;
|
||||
maxChunkTokens: number;
|
||||
contextWindow: number;
|
||||
customInstructions?: string;
|
||||
previousSummary?: string;
|
||||
}): Promise<string> {
|
||||
const { messages, contextWindow } = params;
|
||||
|
||||
if (messages.length === 0) {
|
||||
return params.previousSummary ?? "No prior history.";
|
||||
}
|
||||
|
||||
// Try full summarization first
|
||||
try {
|
||||
return await summarizeChunks(params);
|
||||
} catch (fullError) {
|
||||
if (params.signal.aborted || isAbortError(fullError)) {
|
||||
throw fullError;
|
||||
}
|
||||
console.warn(
|
||||
`Full summarization failed, trying partial: ${
|
||||
fullError instanceof Error ? fullError.message : String(fullError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback 1: Summarize only small messages, note oversized ones
|
||||
const smallMessages: AgentMessage[] = [];
|
||||
const oversizedNotes: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (isOversizedForSummary(msg, contextWindow)) {
|
||||
const role = (msg as { role?: string }).role ?? "message";
|
||||
const tokens = estimateTokens(msg);
|
||||
oversizedNotes.push(
|
||||
`[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
|
||||
);
|
||||
} else {
|
||||
smallMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (smallMessages.length > 0) {
|
||||
try {
|
||||
const partialSummary = await summarizeChunks({
|
||||
...params,
|
||||
messages: smallMessages,
|
||||
});
|
||||
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
|
||||
return partialSummary + notes;
|
||||
} catch (partialError) {
|
||||
if (params.signal.aborted || isAbortError(partialError)) {
|
||||
throw partialError;
|
||||
}
|
||||
console.warn(
|
||||
`Partial summarization also failed: ${
|
||||
partialError instanceof Error ? partialError.message : String(partialError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: Just note what was there
|
||||
return (
|
||||
`Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
|
||||
`Summary unavailable due to size limits.`
|
||||
);
|
||||
}
|
||||
|
||||
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
api.on("session_before_compact", async (event, ctx) => {
|
||||
const { preparation, customInstructions, signal } = event;
|
||||
@@ -233,29 +354,35 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
1,
|
||||
Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
|
||||
);
|
||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * MAX_CHUNK_RATIO));
|
||||
|
||||
// Use adaptive chunk ratio based on message sizes
|
||||
const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
|
||||
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
||||
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
|
||||
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
|
||||
|
||||
const historySummary = await summarizeChunks({
|
||||
const historySummary = await summarizeWithFallback({
|
||||
messages: preparation.messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions,
|
||||
previousSummary: preparation.previousSummary,
|
||||
});
|
||||
|
||||
let summary = historySummary;
|
||||
if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
|
||||
const prefixSummary = await summarizeChunks({
|
||||
const prefixSummary = await summarizeWithFallback({
|
||||
messages: preparation.turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
reserveTokens,
|
||||
maxChunkTokens,
|
||||
contextWindow: contextWindowTokens,
|
||||
customInstructions: TURN_PREFIX_INSTRUCTIONS,
|
||||
});
|
||||
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
|
||||
@@ -293,4 +420,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
export const __testing = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
computeAdaptiveChunkRatio,
|
||||
isOversizedForSummary,
|
||||
BASE_CHUNK_RATIO,
|
||||
MIN_CHUNK_RATIO,
|
||||
SAFETY_MARGIN,
|
||||
} as const;
|
||||
@@ -136,6 +136,7 @@ export async function runReplyAgent(params: {
|
||||
followupRun.run.config,
|
||||
replyToChannel,
|
||||
sessionCtx.AccountId,
|
||||
sessionCtx.ChatType,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
|
||||
const cfg = followupRun.run.config;
|
||||
|
||||
@@ -204,6 +204,7 @@ export function createFollowupRunner(params: {
|
||||
queued.run.config,
|
||||
replyToChannel,
|
||||
queued.originatingAccountId,
|
||||
queued.originatingChatType,
|
||||
);
|
||||
|
||||
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
|
||||
|
||||
@@ -358,6 +358,7 @@ export async function runPreparedReply(
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
originatingAccountId: ctx.AccountId,
|
||||
originatingThreadId: ctx.MessageThreadId,
|
||||
originatingChatType: ctx.ChatType,
|
||||
run: {
|
||||
agentId,
|
||||
agentDir,
|
||||
|
||||
@@ -39,6 +39,8 @@ export type FollowupRun = {
|
||||
originatingAccountId?: string;
|
||||
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
|
||||
originatingThreadId?: string | number;
|
||||
/** Chat type for context-aware threading (e.g., DM vs channel). */
|
||||
originatingChatType?: string;
|
||||
run: {
|
||||
agentId: string;
|
||||
agentDir: string;
|
||||
|
||||
@@ -31,6 +31,46 @@ describe("resolveReplyToMode", () => {
|
||||
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for Slack when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all", group: "first" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level replyToMode when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
|
||||
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createReplyToModeFilter", () => {
|
||||
|
||||
@@ -9,12 +9,14 @@ export function resolveReplyToMode(
|
||||
cfg: ClawdbotConfig,
|
||||
channel?: OriginatingChannelType,
|
||||
accountId?: string | null,
|
||||
chatType?: string | null,
|
||||
): ReplyToMode {
|
||||
const provider = normalizeChannelId(channel);
|
||||
if (!provider) return "all";
|
||||
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
|
||||
cfg,
|
||||
accountId,
|
||||
chatType,
|
||||
});
|
||||
return resolved ?? "all";
|
||||
}
|
||||
|
||||
@@ -60,9 +60,12 @@ export type MsgContext = {
|
||||
MediaPath?: string;
|
||||
MediaUrl?: string;
|
||||
MediaType?: string;
|
||||
MediaDir?: string;
|
||||
MediaPaths?: string[];
|
||||
MediaUrls?: string[];
|
||||
MediaTypes?: string[];
|
||||
OutputDir?: string;
|
||||
OutputBase?: string;
|
||||
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
|
||||
MediaRemoteHost?: string;
|
||||
Transcript?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../signal/accounts.js";
|
||||
import { resolveSlackAccount } from "../slack/accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js";
|
||||
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||
import { resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) =>
|
||||
resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
|
||||
allowTagsWhenOff: true,
|
||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||
},
|
||||
|
||||
@@ -198,6 +198,7 @@ export type ChannelThreadingAdapter = {
|
||||
resolveReplyToMode?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
chatType?: string | null;
|
||||
}) => "off" | "first" | "all";
|
||||
allowTagsWhenOff?: boolean;
|
||||
buildToolContext?: (params: {
|
||||
|
||||
@@ -17,6 +17,8 @@ export type SlackDmConfig = {
|
||||
groupEnabled?: boolean;
|
||||
/** Optional allowlist for group DM channels (ids or slugs). */
|
||||
groupChannels?: Array<string | number>;
|
||||
/** @deprecated Prefer channels.slack.replyToModeByChatType.direct. */
|
||||
replyToMode?: ReplyToMode;
|
||||
};
|
||||
|
||||
export type SlackChannelConfig = {
|
||||
@@ -117,6 +119,11 @@ export type SlackAccountConfig = {
|
||||
reactionAllowlist?: Array<string | number>;
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
/**
|
||||
* Optional per-chat-type reply threading overrides.
|
||||
* Example: { direct: "all", group: "first", channel: "off" }.
|
||||
*/
|
||||
replyToModeByChatType?: Partial<Record<"direct" | "group" | "channel", ReplyToMode>>;
|
||||
/** Thread session behavior. */
|
||||
thread?: SlackThreadConfig;
|
||||
actions?: SlackActionConfig;
|
||||
|
||||
@@ -248,6 +248,7 @@ export const SlackDmSchema = z
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupEnabled: z.boolean().optional(),
|
||||
groupChannels: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -280,6 +281,14 @@ export const SlackThreadSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const SlackReplyToModeByChatTypeSchema = z
|
||||
.object({
|
||||
direct: ReplyToModeSchema.optional(),
|
||||
group: ReplyToModeSchema.optional(),
|
||||
channel: ReplyToModeSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SlackAccountSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
@@ -307,6 +316,7 @@ export const SlackAccountSchema = z
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
replyToMode: ReplyToModeSchema.optional(),
|
||||
replyToModeByChatType: SlackReplyToModeByChatTypeSchema.optional(),
|
||||
thread: SlackThreadSchema.optional(),
|
||||
actions: z
|
||||
.object({
|
||||
|
||||
@@ -7,7 +7,8 @@ import { normalizeAgentId } from "../routing/session-key.js";
|
||||
const MAX_ASSISTANT_NAME = 50;
|
||||
const MAX_ASSISTANT_AVATAR = 200;
|
||||
|
||||
export const DEFAULT_ASSISTANT_IDENTITY = {
|
||||
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
|
||||
agentId: "main",
|
||||
name: "Assistant",
|
||||
avatar: "A",
|
||||
};
|
||||
|
||||
@@ -173,7 +173,7 @@ export function createAgentEventHandler({
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
||||
const shouldEmitVerboseEvents = (runId: string, sessionKey?: string) => {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
|
||||
if (runVerbose) return runVerbose === "on";
|
||||
@@ -198,7 +198,10 @@ export function createAgentEventHandler({
|
||||
// Include sessionKey so Control UI can filter tool streams per session.
|
||||
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
|
||||
const last = agentRunSeq.get(evt.runId) ?? 0;
|
||||
if (evt.stream === "tool" && !shouldEmitToolEvents(evt.runId, sessionKey)) {
|
||||
if (
|
||||
(evt.stream === "tool" || evt.stream === "compaction") &&
|
||||
!shouldEmitVerboseEvents(evt.runId, sessionKey)
|
||||
) {
|
||||
agentRunSeq.set(evt.runId, evt.seq);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,22 @@ describe("gateway server agent", () => {
|
||||
{
|
||||
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
|
||||
|
||||
const compactionEvtP = onceMessage(
|
||||
ws,
|
||||
(o) =>
|
||||
o.type === "event" &&
|
||||
o.event === "agent" &&
|
||||
o.payload?.runId === "run-tool-off" &&
|
||||
o.payload?.stream === "compaction",
|
||||
1000,
|
||||
);
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-off",
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "run-tool-off",
|
||||
stream: "tool",
|
||||
@@ -98,6 +114,8 @@ describe("gateway server agent", () => {
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.stream).toBe("assistant");
|
||||
|
||||
await expect(compactionEvtP).rejects.toThrow("timeout");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DEFAULT_PROMPT: Record<MediaUnderstandingCapability, string> = {
|
||||
export const DEFAULT_VIDEO_MAX_BASE64_BYTES = 70 * MB;
|
||||
export const DEFAULT_AUDIO_MODELS: Record<string, string> = {
|
||||
groq: "whisper-large-v3-turbo",
|
||||
openai: "whisper-1",
|
||||
openai: "gpt-4o-mini-transcribe",
|
||||
deepgram: "nova-3",
|
||||
};
|
||||
export const CLI_OUTPUT_MAX_BUFFER = 5 * MB;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js";
|
||||
import { normalizeGoogleModelId } from "../../../agents/models-config.providers.js";
|
||||
import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js";
|
||||
|
||||
export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview";
|
||||
const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio.";
|
||||
|
||||
function resolveModel(model?: string): string {
|
||||
const trimmed = model?.trim();
|
||||
if (!trimmed) return DEFAULT_GOOGLE_AUDIO_MODEL;
|
||||
return normalizeGoogleModelId(trimmed);
|
||||
}
|
||||
|
||||
function resolvePrompt(prompt?: string): string {
|
||||
const trimmed = prompt?.trim();
|
||||
return trimmed || DEFAULT_GOOGLE_AUDIO_PROMPT;
|
||||
}
|
||||
|
||||
export async function transcribeGeminiAudio(
|
||||
params: AudioTranscriptionRequest,
|
||||
): Promise<AudioTranscriptionResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_GOOGLE_AUDIO_BASE_URL);
|
||||
const model = resolveModel(params.model);
|
||||
const url = `${baseUrl}/models/${model}:generateContent`;
|
||||
|
||||
const headers = new Headers(params.headers);
|
||||
if (!headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (!headers.has("x-goog-api-key")) {
|
||||
headers.set("x-goog-api-key", params.apiKey);
|
||||
}
|
||||
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: resolvePrompt(params.prompt) },
|
||||
{
|
||||
inline_data: {
|
||||
mime_type: params.mime ?? "audio/wav",
|
||||
data: params.buffer.toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
params.timeoutMs,
|
||||
fetchFn,
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorResponse(res);
|
||||
const suffix = detail ? `: ${detail}` : "";
|
||||
throw new Error(`Audio transcription failed (HTTP ${res.status})${suffix}`);
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as {
|
||||
candidates?: Array<{
|
||||
content?: { parts?: Array<{ text?: string }> };
|
||||
}>;
|
||||
};
|
||||
const parts = payload.candidates?.[0]?.content?.parts ?? [];
|
||||
const text = parts
|
||||
.map((part) => part?.text?.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (!text) {
|
||||
throw new Error("Audio transcription response missing text");
|
||||
}
|
||||
return { text, model };
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { MediaUnderstandingProvider } from "../../types.js";
|
||||
import { describeImageWithModel } from "../image.js";
|
||||
import { transcribeGeminiAudio } from "./audio.js";
|
||||
import { describeGeminiVideo } from "./video.js";
|
||||
|
||||
export const googleProvider: MediaUnderstandingProvider = {
|
||||
id: "google",
|
||||
capabilities: ["image", "audio", "video"],
|
||||
describeImage: describeImageWithModel,
|
||||
transcribeAudio: transcribeGeminiAudio,
|
||||
describeVideo: describeGeminiVideo,
|
||||
};
|
||||
@@ -59,7 +59,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(result.model).toBe("whisper-1");
|
||||
expect(result.model).toBe("gpt-4o-mini-transcribe");
|
||||
expect(result.text).toBe("hello");
|
||||
expect(seenUrl).toBe("https://api.example.com/v1/audio/transcriptions");
|
||||
expect(seenInit?.method).toBe("POST");
|
||||
@@ -71,7 +71,7 @@ describe("transcribeOpenAiCompatibleAudio", () => {
|
||||
|
||||
const form = seenInit?.body as FormData;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
expect(form.get("model")).toBe("whisper-1");
|
||||
expect(form.get("model")).toBe("gpt-4o-mini-transcribe");
|
||||
expect(form.get("language")).toBe("en");
|
||||
expect(form.get("prompt")).toBe("hello");
|
||||
const file = form.get("file") as Blob | { type?: string; name?: string } | null;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../
|
||||
import { fetchWithTimeout, normalizeBaseUrl, readErrorResponse } from "../shared.js";
|
||||
|
||||
export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1";
|
||||
const DEFAULT_OPENAI_AUDIO_MODEL = "whisper-1";
|
||||
const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe";
|
||||
|
||||
function resolveModel(model?: string): string {
|
||||
const trimmed = model?.trim();
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
|
||||
describe("runCapability auto audio entries", () => {
|
||||
it("uses provider keys to auto-enable audio transcription", async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
@@ -54,15 +56,18 @@ describe("runCapability auto audio entries", () => {
|
||||
providerRegistry,
|
||||
});
|
||||
expect(result.outputs[0]?.text).toBe("ok");
|
||||
expect(seenModel).toBe("whisper-1");
|
||||
expect(seenModel).toBe("gpt-4o-mini-transcribe");
|
||||
expect(result.decision.outcome).toBe("success");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
await cache.cleanup();
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("skips auto audio when disabled", async () => {
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = "/usr/bin:/bin";
|
||||
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
|
||||
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
|
||||
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
|
||||
@@ -107,6 +112,7 @@ describe("runCapability auto audio entries", () => {
|
||||
expect(result.outputs).toHaveLength(0);
|
||||
expect(result.decision.outcome).toBe("disabled");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
await cache.cleanup();
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { MsgContext } from "../auto-reply/templating.js";
|
||||
import { applyTemplate } from "../auto-reply/templating.js";
|
||||
@@ -16,9 +21,9 @@ import {
|
||||
} from "./defaults.js";
|
||||
import { isMediaUnderstandingSkipError, MediaUnderstandingSkipError } from "./errors.js";
|
||||
import {
|
||||
resolveEntriesWithActiveFallback,
|
||||
resolveMaxBytes,
|
||||
resolveMaxChars,
|
||||
resolveModelEntries,
|
||||
resolvePrompt,
|
||||
resolveScopeDecision,
|
||||
resolveTimeoutMs,
|
||||
@@ -39,7 +44,15 @@ import {
|
||||
import { describeImageWithModel } from "./providers/image.js";
|
||||
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
|
||||
|
||||
const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
|
||||
const AUTO_AUDIO_KEY_PROVIDERS = ["openai", "groq", "deepgram", "google"] as const;
|
||||
const AUTO_IMAGE_KEY_PROVIDERS = ["openai", "anthropic", "google", "minimax"] as const;
|
||||
const AUTO_VIDEO_KEY_PROVIDERS = ["google"] as const;
|
||||
const DEFAULT_IMAGE_MODELS: Record<string, string> = {
|
||||
openai: "gpt-5-mini",
|
||||
anthropic: "claude-opus-4-5",
|
||||
google: "gemini-3-flash-preview",
|
||||
minimax: "MiniMax-VL-01",
|
||||
};
|
||||
|
||||
export type ActiveMediaModel = {
|
||||
provider: string;
|
||||
@@ -67,27 +80,363 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
|
||||
return new MediaAttachmentCache(attachments);
|
||||
}
|
||||
|
||||
async function resolveAutoAudioEntries(params: {
|
||||
const binaryCache = new Map<string, Promise<string | null>>();
|
||||
const geminiProbeCache = new Map<string, Promise<boolean>>();
|
||||
|
||||
function expandHomeDir(value: string): string {
|
||||
if (!value.startsWith("~")) return value;
|
||||
const home = os.homedir();
|
||||
if (value === "~") return home;
|
||||
if (value.startsWith("~/")) return path.join(home, value.slice(2));
|
||||
return value;
|
||||
}
|
||||
|
||||
function hasPathSeparator(value: string): boolean {
|
||||
return value.includes("/") || value.includes("\\");
|
||||
}
|
||||
|
||||
function candidateBinaryNames(name: string): string[] {
|
||||
if (process.platform !== "win32") return [name];
|
||||
const ext = path.extname(name);
|
||||
if (ext) return [name];
|
||||
const pathext = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
||||
.split(";")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.map((item) => (item.startsWith(".") ? item : `.${item}`));
|
||||
const unique = Array.from(new Set(pathext));
|
||||
return [name, ...unique.map((item) => `${name}${item}`)];
|
||||
}
|
||||
|
||||
async function isExecutable(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
if (process.platform === "win32") return true;
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findBinary(name: string): Promise<string | null> {
|
||||
const cached = binaryCache.get(name);
|
||||
if (cached) return cached;
|
||||
const resolved = (async () => {
|
||||
const direct = expandHomeDir(name.trim());
|
||||
if (direct && hasPathSeparator(direct)) {
|
||||
for (const candidate of candidateBinaryNames(direct)) {
|
||||
if (await isExecutable(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const searchName = name.trim();
|
||||
if (!searchName) return null;
|
||||
const pathEntries = (process.env.PATH ?? "").split(path.delimiter);
|
||||
const candidates = candidateBinaryNames(searchName);
|
||||
for (const entryRaw of pathEntries) {
|
||||
const entry = expandHomeDir(entryRaw.trim().replace(/^"(.*)"$/, "$1"));
|
||||
if (!entry) continue;
|
||||
for (const candidate of candidates) {
|
||||
const fullPath = path.join(entry, candidate);
|
||||
if (await isExecutable(fullPath)) return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
binaryCache.set(name, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function hasBinary(name: string): Promise<boolean> {
|
||||
return Boolean(await findBinary(name));
|
||||
}
|
||||
|
||||
async function fileExists(filePath?: string | null): Promise<boolean> {
|
||||
if (!filePath) return false;
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLastJsonObject(raw: string): unknown {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.lastIndexOf("{");
|
||||
if (start === -1) return null;
|
||||
const slice = trimmed.slice(start);
|
||||
try {
|
||||
return JSON.parse(slice);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractGeminiResponse(raw: string): string | null {
|
||||
const payload = extractLastJsonObject(raw);
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const response = (payload as { response?: unknown }).response;
|
||||
if (typeof response !== "string") return null;
|
||||
const trimmed = response.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function extractSherpaOnnxText(raw: string): string | null {
|
||||
const tryParse = (value: string): string | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const head = trimmed[0];
|
||||
if (head !== "{" && head !== '"') return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (typeof parsed === "string") {
|
||||
return tryParse(parsed);
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const text = (parsed as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
};
|
||||
|
||||
const direct = tryParse(raw);
|
||||
if (direct) return direct;
|
||||
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const parsed = tryParse(lines[i] ?? "");
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function probeGeminiCli(): Promise<boolean> {
|
||||
const cached = geminiProbeCache.get("gemini");
|
||||
if (cached) return cached;
|
||||
const resolved = (async () => {
|
||||
if (!(await hasBinary("gemini"))) return false;
|
||||
try {
|
||||
const { stdout } = await runExec("gemini", ["--output-format", "json", "ok"], {
|
||||
timeoutMs: 8000,
|
||||
});
|
||||
return Boolean(extractGeminiResponse(stdout) ?? stdout.toLowerCase().includes("ok"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
geminiProbeCache.set("gemini", resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function resolveLocalWhisperCppEntry(): Promise<MediaUnderstandingModelConfig | null> {
|
||||
if (!(await hasBinary("whisper-cli"))) return null;
|
||||
const envModel = process.env.WHISPER_CPP_MODEL?.trim();
|
||||
const defaultModel = "/opt/homebrew/share/whisper-cpp/for-tests-ggml-tiny.bin";
|
||||
const modelPath = envModel && (await fileExists(envModel)) ? envModel : defaultModel;
|
||||
if (!(await fileExists(modelPath))) return null;
|
||||
return {
|
||||
type: "cli",
|
||||
command: "whisper-cli",
|
||||
args: ["-m", modelPath, "-otxt", "-of", "{{OutputBase}}", "-np", "-nt", "{{MediaPath}}"],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLocalWhisperEntry(): Promise<MediaUnderstandingModelConfig | null> {
|
||||
if (!(await hasBinary("whisper"))) return null;
|
||||
return {
|
||||
type: "cli",
|
||||
command: "whisper",
|
||||
args: [
|
||||
"--model",
|
||||
"turbo",
|
||||
"--output_format",
|
||||
"txt",
|
||||
"--output_dir",
|
||||
"{{OutputDir}}",
|
||||
"--verbose",
|
||||
"False",
|
||||
"{{MediaPath}}",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveSherpaOnnxEntry(): Promise<MediaUnderstandingModelConfig | null> {
|
||||
if (!(await hasBinary("sherpa-onnx-offline"))) return null;
|
||||
const modelDir = process.env.SHERPA_ONNX_MODEL_DIR?.trim();
|
||||
if (!modelDir) return null;
|
||||
const tokens = path.join(modelDir, "tokens.txt");
|
||||
const encoder = path.join(modelDir, "encoder.onnx");
|
||||
const decoder = path.join(modelDir, "decoder.onnx");
|
||||
const joiner = path.join(modelDir, "joiner.onnx");
|
||||
if (!(await fileExists(tokens))) return null;
|
||||
if (!(await fileExists(encoder))) return null;
|
||||
if (!(await fileExists(decoder))) return null;
|
||||
if (!(await fileExists(joiner))) return null;
|
||||
return {
|
||||
type: "cli",
|
||||
command: "sherpa-onnx-offline",
|
||||
args: [
|
||||
`--tokens=${tokens}`,
|
||||
`--encoder=${encoder}`,
|
||||
`--decoder=${decoder}`,
|
||||
`--joiner=${joiner}`,
|
||||
"{{MediaPath}}",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveLocalAudioEntry(): Promise<MediaUnderstandingModelConfig | null> {
|
||||
const sherpa = await resolveSherpaOnnxEntry();
|
||||
if (sherpa) return sherpa;
|
||||
const whisperCpp = await resolveLocalWhisperCppEntry();
|
||||
if (whisperCpp) return whisperCpp;
|
||||
return await resolveLocalWhisperEntry();
|
||||
}
|
||||
|
||||
async function resolveGeminiCliEntry(
|
||||
_capability: MediaUnderstandingCapability,
|
||||
): Promise<MediaUnderstandingModelConfig | null> {
|
||||
if (!(await probeGeminiCli())) return null;
|
||||
return {
|
||||
type: "cli",
|
||||
command: "gemini",
|
||||
args: [
|
||||
"--output-format",
|
||||
"json",
|
||||
"--allowed-tools",
|
||||
"read_many_files",
|
||||
"--include-directories",
|
||||
"{{MediaDir}}",
|
||||
"{{Prompt}}",
|
||||
"Use read_many_files to read {{MediaPath}} and respond with only the text output.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveKeyEntry(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
providerRegistry: ProviderRegistry;
|
||||
}): Promise<MediaUnderstandingModelConfig[]> {
|
||||
const entries: MediaUnderstandingModelConfig[] = [];
|
||||
for (const providerId of AUTO_AUDIO_PROVIDERS) {
|
||||
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
|
||||
if (!provider?.transcribeAudio) continue;
|
||||
capability: MediaUnderstandingCapability;
|
||||
activeModel?: ActiveMediaModel;
|
||||
}): Promise<MediaUnderstandingModelConfig | null> {
|
||||
const { cfg, agentDir, providerRegistry, capability } = params;
|
||||
const checkProvider = async (
|
||||
providerId: string,
|
||||
model?: string,
|
||||
): Promise<MediaUnderstandingModelConfig | null> => {
|
||||
const provider = getMediaUnderstandingProvider(providerId, providerRegistry);
|
||||
if (!provider) return null;
|
||||
if (capability === "audio" && !provider.transcribeAudio) return null;
|
||||
if (capability === "image" && !provider.describeImage) return null;
|
||||
if (capability === "video" && !provider.describeVideo) return null;
|
||||
try {
|
||||
await resolveApiKeyForProvider({
|
||||
provider: providerId,
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
entries.push({ type: "provider", provider: providerId });
|
||||
await resolveApiKeyForProvider({ provider: providerId, cfg, agentDir });
|
||||
return { type: "provider" as const, provider: providerId, model };
|
||||
} catch {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (capability === "image") {
|
||||
const activeProvider = params.activeModel?.provider?.trim();
|
||||
if (activeProvider) {
|
||||
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
|
||||
if (activeEntry) return activeEntry;
|
||||
}
|
||||
for (const providerId of AUTO_IMAGE_KEY_PROVIDERS) {
|
||||
const model = DEFAULT_IMAGE_MODELS[providerId];
|
||||
const entry = await checkProvider(providerId, model);
|
||||
if (entry) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return entries;
|
||||
|
||||
if (capability === "video") {
|
||||
const activeProvider = params.activeModel?.provider?.trim();
|
||||
if (activeProvider) {
|
||||
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
|
||||
if (activeEntry) return activeEntry;
|
||||
}
|
||||
for (const providerId of AUTO_VIDEO_KEY_PROVIDERS) {
|
||||
const entry = await checkProvider(providerId, undefined);
|
||||
if (entry) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProvider = params.activeModel?.provider?.trim();
|
||||
if (activeProvider) {
|
||||
const activeEntry = await checkProvider(activeProvider, params.activeModel?.model);
|
||||
if (activeEntry) return activeEntry;
|
||||
}
|
||||
for (const providerId of AUTO_AUDIO_KEY_PROVIDERS) {
|
||||
const entry = await checkProvider(providerId, undefined);
|
||||
if (entry) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveAutoEntries(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
providerRegistry: ProviderRegistry;
|
||||
capability: MediaUnderstandingCapability;
|
||||
activeModel?: ActiveMediaModel;
|
||||
}): Promise<MediaUnderstandingModelConfig[]> {
|
||||
const activeEntry = await resolveActiveModelEntry(params);
|
||||
if (activeEntry) return [activeEntry];
|
||||
if (params.capability === "audio") {
|
||||
const localAudio = await resolveLocalAudioEntry();
|
||||
if (localAudio) return [localAudio];
|
||||
}
|
||||
const gemini = await resolveGeminiCliEntry(params.capability);
|
||||
if (gemini) return [gemini];
|
||||
const keys = await resolveKeyEntry(params);
|
||||
if (keys) return [keys];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function resolveActiveModelEntry(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentDir?: string;
|
||||
providerRegistry: ProviderRegistry;
|
||||
capability: MediaUnderstandingCapability;
|
||||
activeModel?: ActiveMediaModel;
|
||||
}): Promise<MediaUnderstandingModelConfig | null> {
|
||||
const activeProviderRaw = params.activeModel?.provider?.trim();
|
||||
if (!activeProviderRaw) return null;
|
||||
const providerId = normalizeMediaProviderId(activeProviderRaw);
|
||||
if (!providerId) return null;
|
||||
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
|
||||
if (!provider) return null;
|
||||
if (params.capability === "audio" && !provider.transcribeAudio) return null;
|
||||
if (params.capability === "image" && !provider.describeImage) return null;
|
||||
if (params.capability === "video" && !provider.describeVideo) return null;
|
||||
try {
|
||||
await resolveApiKeyForProvider({
|
||||
provider: providerId,
|
||||
cfg: params.cfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "provider",
|
||||
provider: providerId,
|
||||
model: params.activeModel?.model,
|
||||
};
|
||||
}
|
||||
|
||||
function trimOutput(text: string, maxChars?: number): string {
|
||||
@@ -96,6 +445,74 @@ function trimOutput(text: string, maxChars?: number): string {
|
||||
return trimmed.slice(0, maxChars).trim();
|
||||
}
|
||||
|
||||
function commandBase(command: string): string {
|
||||
return path.parse(command).name;
|
||||
}
|
||||
|
||||
function findArgValue(args: string[], keys: string[]): string | undefined {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
if (keys.includes(args[i] ?? "")) {
|
||||
const value = args[i + 1];
|
||||
if (value) return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasArg(args: string[], keys: string[]): boolean {
|
||||
return args.some((arg) => keys.includes(arg));
|
||||
}
|
||||
|
||||
function resolveWhisperOutputPath(args: string[], mediaPath: string): string | null {
|
||||
const outputDir = findArgValue(args, ["--output_dir", "-o"]);
|
||||
const outputFormat = findArgValue(args, ["--output_format"]);
|
||||
if (!outputDir || !outputFormat) return null;
|
||||
const formats = outputFormat.split(",").map((value) => value.trim());
|
||||
if (!formats.includes("txt")) return null;
|
||||
const base = path.parse(mediaPath).name;
|
||||
return path.join(outputDir, `${base}.txt`);
|
||||
}
|
||||
|
||||
function resolveWhisperCppOutputPath(args: string[]): string | null {
|
||||
if (!hasArg(args, ["-otxt", "--output-txt"])) return null;
|
||||
const outputBase = findArgValue(args, ["-of", "--output-file"]);
|
||||
if (!outputBase) return null;
|
||||
return `${outputBase}.txt`;
|
||||
}
|
||||
|
||||
async function resolveCliOutput(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
stdout: string;
|
||||
mediaPath: string;
|
||||
}): Promise<string> {
|
||||
const commandId = commandBase(params.command);
|
||||
const fileOutput =
|
||||
commandId === "whisper-cli"
|
||||
? resolveWhisperCppOutputPath(params.args)
|
||||
: commandId === "whisper"
|
||||
? resolveWhisperOutputPath(params.args, params.mediaPath)
|
||||
: null;
|
||||
if (fileOutput && (await fileExists(fileOutput))) {
|
||||
try {
|
||||
const content = await fs.readFile(fileOutput, "utf8");
|
||||
if (content.trim()) return content.trim();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (commandId === "gemini") {
|
||||
const response = extractGeminiResponse(params.stdout);
|
||||
if (response) return response;
|
||||
}
|
||||
|
||||
if (commandId === "sherpa-onnx-offline") {
|
||||
const response = extractSherpaOnnxText(params.stdout);
|
||||
if (response) return response;
|
||||
}
|
||||
|
||||
return params.stdout.trim();
|
||||
}
|
||||
|
||||
type ProviderQuery = Record<string, string | number | boolean>;
|
||||
|
||||
function normalizeProviderQuery(
|
||||
@@ -422,32 +839,48 @@ async function runCliEntry(params: {
|
||||
maxBytes,
|
||||
timeoutMs,
|
||||
});
|
||||
const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-cli-"));
|
||||
const mediaPath = pathResult.path;
|
||||
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
|
||||
|
||||
const templCtx: MsgContext = {
|
||||
...ctx,
|
||||
MediaPath: pathResult.path,
|
||||
MediaPath: mediaPath,
|
||||
MediaDir: path.dirname(mediaPath),
|
||||
OutputDir: outputDir,
|
||||
OutputBase: outputBase,
|
||||
Prompt: prompt,
|
||||
MaxChars: maxChars,
|
||||
};
|
||||
const argv = [command, ...args].map((part, index) =>
|
||||
index === 0 ? part : applyTemplate(part, templCtx),
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
|
||||
try {
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`Media understanding via CLI: ${argv.join(" ")}`);
|
||||
}
|
||||
const { stdout } = await runExec(argv[0], argv.slice(1), {
|
||||
timeoutMs,
|
||||
maxBuffer: CLI_OUTPUT_MAX_BUFFER,
|
||||
});
|
||||
const resolved = await resolveCliOutput({
|
||||
command,
|
||||
args: argv.slice(1),
|
||||
stdout,
|
||||
mediaPath,
|
||||
});
|
||||
const text = trimOutput(resolved, maxChars);
|
||||
if (!text) return null;
|
||||
return {
|
||||
kind: capability === "audio" ? "audio.transcription" : `${capability}.description`,
|
||||
attachmentIndex: params.attachmentIndex,
|
||||
text,
|
||||
provider: "cli",
|
||||
model: command,
|
||||
};
|
||||
} finally {
|
||||
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
const { stdout } = await runExec(argv[0], argv.slice(1), {
|
||||
timeoutMs,
|
||||
maxBuffer: CLI_OUTPUT_MAX_BUFFER,
|
||||
});
|
||||
const text = trimOutput(stdout, maxChars);
|
||||
if (!text) return null;
|
||||
return {
|
||||
kind: capability === "audio" ? "audio.transcription" : `${capability}.description`,
|
||||
attachmentIndex: params.attachmentIndex,
|
||||
text,
|
||||
provider: "cli",
|
||||
model: command,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAttachmentEntries(params: {
|
||||
@@ -581,19 +1014,20 @@ export async function runCapability(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const entries = resolveEntriesWithActiveFallback({
|
||||
const entries = resolveModelEntries({
|
||||
cfg,
|
||||
capability,
|
||||
config,
|
||||
providerRegistry: params.providerRegistry,
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
let resolvedEntries = entries;
|
||||
if (resolvedEntries.length === 0 && capability === "audio") {
|
||||
resolvedEntries = await resolveAutoAudioEntries({
|
||||
if (resolvedEntries.length === 0) {
|
||||
resolvedEntries = await resolveAutoEntries({
|
||||
cfg,
|
||||
agentDir: params.agentDir,
|
||||
providerRegistry: params.providerRegistry,
|
||||
capability,
|
||||
activeModel: params.activeModel,
|
||||
});
|
||||
}
|
||||
if (resolvedEntries.length === 0) {
|
||||
|
||||
@@ -230,6 +230,7 @@ export {
|
||||
listSlackAccountIds,
|
||||
resolveDefaultSlackAccountId,
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
type ResolvedSlackAccount,
|
||||
} from "../slack/accounts.js";
|
||||
export { slackOnboardingAdapter } from "../channels/plugins/onboarding/slack.js";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { SlackAccountConfig } from "../config/types.js";
|
||||
import { normalizeChatType } from "../channels/chat-type.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -20,6 +21,7 @@ export type ResolvedSlackAccount = {
|
||||
reactionNotifications?: SlackAccountConfig["reactionNotifications"];
|
||||
reactionAllowlist?: SlackAccountConfig["reactionAllowlist"];
|
||||
replyToMode?: SlackAccountConfig["replyToMode"];
|
||||
replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"];
|
||||
actions?: SlackAccountConfig["actions"];
|
||||
slashCommand?: SlackAccountConfig["slashCommand"];
|
||||
dm?: SlackAccountConfig["dm"];
|
||||
@@ -95,6 +97,7 @@ export function resolveSlackAccount(params: {
|
||||
reactionNotifications: merged.reactionNotifications,
|
||||
reactionAllowlist: merged.reactionAllowlist,
|
||||
replyToMode: merged.replyToMode,
|
||||
replyToModeByChatType: merged.replyToModeByChatType,
|
||||
actions: merged.actions,
|
||||
slashCommand: merged.slashCommand,
|
||||
dm: merged.dm,
|
||||
@@ -107,3 +110,17 @@ export function listEnabledSlackAccounts(cfg: ClawdbotConfig): ResolvedSlackAcco
|
||||
.map((accountId) => resolveSlackAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
|
||||
export function resolveSlackReplyToMode(
|
||||
account: ResolvedSlackAccount,
|
||||
chatType?: string | null,
|
||||
): "off" | "first" | "all" {
|
||||
const normalized = normalizeChatType(chatType ?? undefined);
|
||||
if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) {
|
||||
return account.replyToModeByChatType[normalized] ?? "off";
|
||||
}
|
||||
if (normalized === "direct" && account.dm?.replyToMode !== undefined) {
|
||||
return account.dm.replyToMode;
|
||||
}
|
||||
return account.replyToMode ?? "off";
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type SlackThreadStarter = {
|
||||
text: string;
|
||||
userId?: string;
|
||||
ts?: string;
|
||||
files?: SlackFile[];
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarter>();
|
||||
@@ -71,7 +72,7 @@ export async function resolveSlackThreadStarter(params: {
|
||||
ts: params.threadTs,
|
||||
limit: 1,
|
||||
inclusive: true,
|
||||
})) as { messages?: Array<{ text?: string; user?: string; ts?: string }> };
|
||||
})) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> };
|
||||
const message = response?.messages?.[0];
|
||||
const text = (message?.text ?? "").trim();
|
||||
if (!message || !text) return null;
|
||||
@@ -79,6 +80,7 @@ export async function resolveSlackThreadStarter(params: {
|
||||
text,
|
||||
userId: message.user,
|
||||
ts: message.ts,
|
||||
files: message.files,
|
||||
};
|
||||
THREAD_STARTER_CACHE.set(cacheKey, starter);
|
||||
return starter;
|
||||
|
||||
@@ -434,6 +434,7 @@ export async function prepareSlackMessage(params: {
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
let threadLabel: string | undefined;
|
||||
let threadStarterMedia: Awaited<ReturnType<typeof resolveSlackMedia>> = null;
|
||||
if (isThreadReply && threadTs) {
|
||||
const starter = await resolveSlackThreadStarter({
|
||||
channelId: message.channel,
|
||||
@@ -453,11 +454,27 @@ export async function prepareSlackMessage(params: {
|
||||
});
|
||||
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
|
||||
threadLabel = `Slack thread ${roomLabel}${snippet ? `: ${snippet}` : ""}`;
|
||||
// If current message has no files but thread starter does, fetch starter's files
|
||||
if (!media && starter.files && starter.files.length > 0) {
|
||||
threadStarterMedia = await resolveSlackMedia({
|
||||
files: starter.files,
|
||||
token: ctx.botToken,
|
||||
maxBytes: ctx.mediaMaxBytes,
|
||||
});
|
||||
if (threadStarterMedia) {
|
||||
logVerbose(
|
||||
`slack: hydrated thread starter file ${threadStarterMedia.placeholder} from root message`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
threadLabel = `Slack thread ${roomLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Use thread starter media if current message has none
|
||||
const effectiveMedia = media ?? threadStarterMedia;
|
||||
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
RawBody: rawBody,
|
||||
@@ -483,9 +500,9 @@ export async function prepareSlackMessage(params: {
|
||||
ThreadLabel: threadLabel,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
MediaType: media?.contentType,
|
||||
MediaUrl: media?.path,
|
||||
MediaPath: effectiveMedia?.path,
|
||||
MediaType: effectiveMedia?.contentType,
|
||||
MediaUrl: effectiveMedia?.path,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
OriginatingChannel: "slack" as const,
|
||||
OriginatingTo: slackTo,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { buildSlackThreadingToolContext } from "./threading-tool-context.js";
|
||||
|
||||
const emptyCfg = {} as ClawdbotConfig;
|
||||
|
||||
describe("buildSlackThreadingToolContext", () => {
|
||||
it("uses top-level replyToMode by default", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: { replyToMode: "first" },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "channel" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("first");
|
||||
});
|
||||
|
||||
it("uses chat-type replyToMode overrides for direct messages when configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("all");
|
||||
});
|
||||
|
||||
it("uses top-level replyToMode for channels when no channel override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
replyToModeByChatType: { direct: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "channel" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("off");
|
||||
});
|
||||
|
||||
it("falls back to top-level when no chat-type override is set", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "first",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("first");
|
||||
});
|
||||
|
||||
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
replyToMode: "off",
|
||||
dm: { replyToMode: "all" },
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("all");
|
||||
});
|
||||
|
||||
it("uses all mode when ThreadLabel is present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: { replyToMode: "off" },
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "channel", ThreadLabel: "some-thread" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("all");
|
||||
});
|
||||
|
||||
it("defaults to off when no replyToMode is configured", () => {
|
||||
const result = buildSlackThreadingToolContext({
|
||||
cfg: emptyCfg,
|
||||
accountId: null,
|
||||
context: { ChatType: "direct" },
|
||||
});
|
||||
expect(result.replyToMode).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
ChannelThreadingToolContext,
|
||||
} from "../channels/plugins/types.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveSlackAccount } from "./accounts.js";
|
||||
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
|
||||
|
||||
export function buildSlackThreadingToolContext(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
@@ -11,11 +11,11 @@ export function buildSlackThreadingToolContext(params: {
|
||||
context: ChannelThreadingContext;
|
||||
hasRepliedRef?: { value: boolean };
|
||||
}): ChannelThreadingToolContext {
|
||||
const configuredReplyToMode =
|
||||
resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).replyToMode ?? "off";
|
||||
const account = resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const configuredReplyToMode = resolveSlackReplyToMode(account, params.context.ChatType);
|
||||
const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode;
|
||||
const threadId = params.context.MessageThreadId ?? params.context.ReplyToId;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../src/config/config.js";
|
||||
import type { MsgContext } from "../src/auto-reply/templating.js";
|
||||
|
||||
const makeTempDir = async (prefix: string) => await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
|
||||
const writeExecutable = async (dir: string, name: string, content: string) => {
|
||||
const filePath = path.join(dir, name);
|
||||
await fs.writeFile(filePath, content, { mode: 0o755 });
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const makeTempMedia = async (ext: string) => {
|
||||
const dir = await makeTempDir("clawdbot-media-e2e-");
|
||||
const filePath = path.join(dir, `sample${ext}`);
|
||||
await fs.writeFile(filePath, "audio");
|
||||
return { dir, filePath };
|
||||
};
|
||||
|
||||
const loadApply = async () => {
|
||||
vi.resetModules();
|
||||
return await import("../src/media-understanding/apply.js");
|
||||
};
|
||||
|
||||
const envSnapshot = () => ({
|
||||
PATH: process.env.PATH,
|
||||
SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR,
|
||||
WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL,
|
||||
});
|
||||
|
||||
const restoreEnv = (snapshot: ReturnType<typeof envSnapshot>) => {
|
||||
process.env.PATH = snapshot.PATH;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR;
|
||||
process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL;
|
||||
};
|
||||
|
||||
describe("media understanding auto-detect (e2e)", () => {
|
||||
let tempPaths: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const p of tempPaths) {
|
||||
await fs.rm(p, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
tempPaths = [];
|
||||
});
|
||||
|
||||
it("uses sherpa-onnx-offline when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-sherpa-");
|
||||
const modelDir = await makeTempDir("clawdbot-sherpa-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
await fs.writeFile(path.join(modelDir, "tokens.txt"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "encoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a");
|
||||
await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"sherpa-onnx-offline",
|
||||
"#!/usr/bin/env bash\n" + 'echo "{\\"text\\":\\"sherpa ok\\"}"\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.SHERPA_ONNX_MODEL_DIR = modelDir;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("sherpa ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses whisper-cli when sherpa is missing", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-whispercpp-");
|
||||
const modelDir = await makeTempDir("clawdbot-whispercpp-model-");
|
||||
tempPaths.push(binDir, modelDir);
|
||||
|
||||
const modelPath = path.join(modelDir, "tiny.bin");
|
||||
await fs.writeFile(modelPath, "model");
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"whisper-cli",
|
||||
"#!/usr/bin/env bash\n" +
|
||||
'out=""\n' +
|
||||
'prev=""\n' +
|
||||
'for arg in "$@"; do\n' +
|
||||
' if [ "$prev" = "-of" ]; then out="$arg"; break; fi\n' +
|
||||
' prev="$arg"\n' +
|
||||
"done\n" +
|
||||
'if [ -n "$out" ]; then echo \'whisper cpp ok\' > "${out}.txt"; fi\n',
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
process.env.WHISPER_CPP_MODEL = modelPath;
|
||||
|
||||
const { filePath } = await makeTempMedia(".wav");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:audio>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "audio/wav",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { audio: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Transcript).toBe("whisper cpp ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses gemini CLI for images when available", async () => {
|
||||
const snapshot = envSnapshot();
|
||||
try {
|
||||
const binDir = await makeTempDir("clawdbot-bin-gemini-");
|
||||
tempPaths.push(binDir);
|
||||
|
||||
await writeExecutable(
|
||||
binDir,
|
||||
"gemini",
|
||||
"#!/usr/bin/env bash\necho '{" + '\\"response\\":\\"gemini ok\\"' + "}'\n",
|
||||
);
|
||||
|
||||
process.env.PATH = `${binDir}:/usr/bin:/bin`;
|
||||
|
||||
const { filePath } = await makeTempMedia(".png");
|
||||
tempPaths.push(path.dirname(filePath));
|
||||
|
||||
const { applyMediaUnderstanding } = await loadApply();
|
||||
const ctx: MsgContext = {
|
||||
Body: "<media:image>",
|
||||
MediaPath: filePath,
|
||||
MediaType: "image/png",
|
||||
};
|
||||
const cfg: ClawdbotConfig = { tools: { media: { image: {} } } };
|
||||
|
||||
await applyMediaUnderstanding({ ctx, cfg });
|
||||
|
||||
expect(ctx.Body).toContain("gemini ok");
|
||||
} finally {
|
||||
restoreEnv(snapshot);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -415,6 +415,51 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.callout.info {
|
||||
border-color: rgba(92, 156, 255, 0.4);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout.success {
|
||||
border-color: rgba(92, 255, 128, 0.4);
|
||||
color: var(--positive, #5cff80);
|
||||
}
|
||||
|
||||
.compaction-indicator {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
animation: compaction-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.compaction-indicator--active {
|
||||
animation: compaction-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.compaction-indicator--complete {
|
||||
animation: compaction-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes compaction-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes compaction-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -145,6 +145,14 @@ export function connectGateway(host: GatewayHost) {
|
||||
}
|
||||
|
||||
export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
try {
|
||||
handleGatewayEventUnsafe(host, evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] handleGatewayEvent error:", evt.event, err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
||||
host.eventLogBuffer = [
|
||||
{ ts: Date.now(), event: evt.event, payload: evt.payload },
|
||||
...host.eventLogBuffer,
|
||||
|
||||
@@ -444,6 +444,7 @@ export function renderApp(state: AppViewState) {
|
||||
showThinking,
|
||||
loading: state.chatLoading,
|
||||
sending: state.chatSending,
|
||||
compactionStatus: state.compactionStatus,
|
||||
assistantAvatarUrl: chatAvatarUrl,
|
||||
messages: state.chatMessages,
|
||||
toolMessages: state.chatToolMessages,
|
||||
|
||||
@@ -138,8 +138,69 @@ export function resetToolStream(host: ToolStreamHost) {
|
||||
flushToolStreamSync(host);
|
||||
}
|
||||
|
||||
export type CompactionStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
retryingAt: number | null;
|
||||
};
|
||||
|
||||
type CompactionHost = ToolStreamHost & {
|
||||
compactionStatus?: CompactionStatus | null;
|
||||
compactionClearTimer?: number | null;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
|
||||
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
|
||||
const data = payload.data ?? {};
|
||||
const phase = typeof data.phase === "string" ? data.phase : "";
|
||||
const willRetry = Boolean(data.willRetry);
|
||||
|
||||
// Clear any existing timer
|
||||
if (host.compactionClearTimer != null) {
|
||||
window.clearTimeout(host.compactionClearTimer);
|
||||
host.compactionClearTimer = null;
|
||||
}
|
||||
|
||||
if (phase === "start") {
|
||||
host.compactionStatus = {
|
||||
active: true,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
retryingAt: null,
|
||||
};
|
||||
} else if (phase === "end") {
|
||||
host.compactionStatus = {
|
||||
active: willRetry,
|
||||
startedAt: host.compactionStatus?.startedAt ?? null,
|
||||
completedAt: willRetry ? null : Date.now(),
|
||||
retryingAt: willRetry ? Date.now() : null,
|
||||
};
|
||||
// Auto-clear the toast after duration
|
||||
host.compactionClearTimer = window.setTimeout(() => {
|
||||
host.compactionStatus = null;
|
||||
host.compactionClearTimer = null;
|
||||
}, COMPACTION_TOAST_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||
if (!payload || payload.stream !== "tool") return;
|
||||
if (!payload) return;
|
||||
|
||||
// Handle compaction events
|
||||
if (payload.stream === "compaction") {
|
||||
const sessionKey =
|
||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||
if (!sessionKey && host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||
if (host.chatRunId && payload.runId !== host.chatRunId) return;
|
||||
if (!host.chatRunId) return;
|
||||
handleCompactionEvent(host as CompactionHost, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.stream !== "tool") return;
|
||||
const sessionKey =
|
||||
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
|
||||
if (sessionKey && sessionKey !== host.sessionKey) return;
|
||||
|
||||
@@ -125,6 +125,7 @@ export class ClawdbotApp extends LitElement {
|
||||
@state() chatStream: string | null = null;
|
||||
@state() chatStreamStartedAt: number | null = null;
|
||||
@state() chatRunId: string | null = null;
|
||||
@state() compactionStatus: import("./app-tool-stream").CompactionStatus | null = null;
|
||||
@state() chatAvatarUrl: string | null = null;
|
||||
@state() chatThinkingLevel: string | null = null;
|
||||
@state() chatQueue: ChatQueueItem[] = [];
|
||||
|
||||
@@ -254,7 +254,11 @@ export class GatewayBrowserClient {
|
||||
}
|
||||
this.lastSeq = seq;
|
||||
}
|
||||
this.opts.onEvent?.(evt);
|
||||
try {
|
||||
this.opts.onEvent?.(evt);
|
||||
} catch (err) {
|
||||
console.error("[gateway] event handler error:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
import { renderMarkdownSidebar } from "./markdown-sidebar";
|
||||
import "../components/resizable-divider";
|
||||
|
||||
export type CompactionIndicatorStatus = {
|
||||
active: boolean;
|
||||
startedAt: number | null;
|
||||
completedAt: number | null;
|
||||
retryingAt: number | null;
|
||||
};
|
||||
|
||||
export type ChatProps = {
|
||||
sessionKey: string;
|
||||
onSessionKeyChange: (next: string) => void;
|
||||
@@ -24,6 +31,7 @@ export type ChatProps = {
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
canAbort?: boolean;
|
||||
compactionStatus?: CompactionIndicatorStatus | null;
|
||||
messages: unknown[];
|
||||
toolMessages: unknown[];
|
||||
stream: string | null;
|
||||
@@ -59,6 +67,46 @@ export type ChatProps = {
|
||||
onChatScroll?: (event: Event) => void;
|
||||
};
|
||||
|
||||
const COMPACTION_TOAST_DURATION_MS = 5000;
|
||||
|
||||
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
|
||||
if (!status) return nothing;
|
||||
|
||||
// Show "compacting..." while active
|
||||
if (status.active) {
|
||||
return html`
|
||||
<div class="callout info compaction-indicator compaction-indicator--active">
|
||||
🧹 Compacting context...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (status.retryingAt) {
|
||||
const elapsed = Date.now() - status.retryingAt;
|
||||
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||
return html`
|
||||
<div class="callout info compaction-indicator compaction-indicator--active">
|
||||
🧹 Retrying compaction...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Show "compaction complete" briefly after completion
|
||||
if (status.completedAt) {
|
||||
const elapsed = Date.now() - status.completedAt;
|
||||
if (elapsed < COMPACTION_TOAST_DURATION_MS) {
|
||||
return html`
|
||||
<div class="callout success compaction-indicator compaction-indicator--complete">
|
||||
🧹 Context compacted
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
export function renderChat(props: ChatProps) {
|
||||
const canCompose = props.connected;
|
||||
const isBusy = props.sending || props.stream !== null;
|
||||
@@ -89,6 +137,8 @@ export function renderChat(props: ChatProps) {
|
||||
? html`<div class="callout danger">${props.error}</div>`
|
||||
: nothing}
|
||||
|
||||
${renderCompactionIndicator(props.compactionStatus)}
|
||||
|
||||
${props.focusMode
|
||||
? html`
|
||||
<button
|
||||
|
||||
Reference in new issue
Block a user