mirror of
https://github.com/denberek/obsidian-agent-fleet.git
synced 2026-07-22 07:47:06 +00:00
release: 0.14.0 — Discord channel, channel targeting, reflection visibility, usage tracking, Enter-to-send
This commit is contained in:
parent
f2c4454384
commit
adb5b33ebd
36 changed files with 2094 additions and 175 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
|||
# Changelog
|
||||
|
||||
## 0.14.0 — 2026-06-19
|
||||
|
||||
New features and fixes across channels, memory, and usage tracking.
|
||||
|
||||
**Discord channel**
|
||||
- New Discord channel adapter (Gateway over WebSocket + REST), matching the Slack/Telegram model: `@agent-name` routing, `/agents` slash command, image attachments, allowlist on the authenticated sender, and reconnect/resume. See `DISCORD_SETUP.md`.
|
||||
|
||||
**Per-task & heartbeat channel delivery**
|
||||
- Any scheduled/manual task can post its full output to a channel via `channel:` (broadcast/DM) and `channel_target:` (a specific Discord/Slack channel id or Telegram chat id). Heartbeats gain the same `channel_target`. New `sendToTarget` on all three transports, with task/agent form fields.
|
||||
|
||||
**Reflection visibility**
|
||||
- Memory reflection now shows live in the overview "Active Agents" card and writes a run log to Recent Activity with its full output, so you can see what it consolidated. Fixed duplicate pinned-preference accumulation (reflection now trusts the model's consolidated pins).
|
||||
|
||||
**Comprehensive token & cost tracking**
|
||||
- The dashboard "Tokens Used" and per-agent "Total Tokens" now include chat and channel turns (a new usage ledger), not just tasks/heartbeats. Cost uses the CLI's reported amount with a per-model pricing-table fallback.
|
||||
|
||||
**Chat**
|
||||
- Enter sends a message; Shift+Enter inserts a newline (IME-safe).
|
||||
|
||||
## 0.13.6 — 2026-06-14
|
||||
|
||||
Code-quality cleanup from the community review (all non-blocking). No user-facing behavior changes.
|
||||
|
|
|
|||
101
DISCORD_SETUP.md
Normal file
101
DISCORD_SETUP.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Discord Setup Guide
|
||||
|
||||
Connect your Agent Fleet agents to Discord so you can chat with them from your phone, desktop, or anywhere Discord runs — in servers, channels, threads, or DMs.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, make sure you have:
|
||||
|
||||
- **Agent Fleet plugin** installed and running in Obsidian
|
||||
- **At least one agent** configured in your fleet (the default Fleet Orchestrator works)
|
||||
- **A Discord server** where you have permission to add bots (your own server or admin access)
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Create the Discord Application
|
||||
|
||||
### Step 1 — Create a new application
|
||||
|
||||
1. Go to **https://discord.com/developers/applications**
|
||||
2. Click **New Application**
|
||||
3. **Name:** `Agent Fleet` (or any name you prefer)
|
||||
4. Accept the terms and click **Create**
|
||||
|
||||
### Step 2 — Create the bot and copy its token
|
||||
|
||||
1. In the left sidebar, click **Bot**
|
||||
2. Click **Reset Token** (or **Add Bot** on older UIs) and confirm
|
||||
3. **Copy the token** — this is your **Bot Token**. Save it somewhere safe; you won't see it again without resetting.
|
||||
|
||||
### Step 3 — Enable the Message Content intent (required)
|
||||
|
||||
The bot can't read message text without this privileged intent.
|
||||
|
||||
1. Still on the **Bot** page, scroll to **Privileged Gateway Intents**
|
||||
2. Toggle **Message Content Intent** → ON
|
||||
3. Save changes
|
||||
|
||||
> If you skip this, the bot connects but every message arrives with empty text, and the Gateway may close with error code `4014`. The plugin logs a clear hint in the console when this happens.
|
||||
|
||||
### Step 4 — Invite the bot to your server
|
||||
|
||||
1. In the left sidebar, open **OAuth2 → URL Generator**
|
||||
2. Under **Scopes**, check:
|
||||
- `bot`
|
||||
- `applications.commands` (for the `/agents` slash command)
|
||||
3. Under **Bot Permissions**, check:
|
||||
- **Send Messages**
|
||||
- **Send Messages in Threads**
|
||||
- **Read Message History**
|
||||
- **Use Slash Commands**
|
||||
4. Copy the generated URL at the bottom, open it in your browser, pick your server, and click **Authorize**.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Configure Agent Fleet
|
||||
|
||||
### Step 5 — Add the credential
|
||||
|
||||
1. In Obsidian, open **Settings → Agent Fleet → Channel Credentials**
|
||||
2. Under **Add a channel credential**:
|
||||
- **Reference name:** `discord-creds` (any name; referenced by `credential_ref` in the channel file)
|
||||
- **Type:** `Discord`
|
||||
- **Bot token:** paste the token from Step 2
|
||||
3. Click **Add credential**
|
||||
|
||||
### Step 6 — Create the channel
|
||||
|
||||
1. Open the Agent Fleet dashboard → **Channels** → **New Channel**
|
||||
2. Fill in:
|
||||
- **Name:** e.g. `my-discord`
|
||||
- **Type:** `discord`
|
||||
- **Credential:** `discord-creds`
|
||||
- **Default agent:** the agent to use when no `@agent-name` prefix is given
|
||||
- **Allowed agents:** (optional) the agents reachable via `@agent-name` or `/agents`
|
||||
- **Allowed users:** your numeric Discord user ID(s), one per line. Only listed users can reach the bot. (Enable Developer Mode in Discord → right-click your name → **Copy User ID**.)
|
||||
3. Save. The channel should show **connected** in the dashboard within a few seconds.
|
||||
|
||||
---
|
||||
|
||||
## Using it
|
||||
|
||||
- **Mention or DM the bot.** In a server channel, `@Agent Fleet do the thing` (or just message it in a DM).
|
||||
- **Pick an agent per conversation.** Prefix a message with `@agent-name ...`, or run the **`/agents`** slash command and click a button. The choice sticks for that channel/thread until you change it.
|
||||
- Global slash commands can take up to ~1 hour to appear the first time after the bot joins.
|
||||
- **Threads and channels are isolated.** Each channel or thread keeps its own conversation and agent binding.
|
||||
- **Images.** Attach an image and the agent receives it (saved into your vault).
|
||||
- **Long replies** are automatically split to fit Discord's 2000-character limit.
|
||||
- **Heartbeats.** If an agent's `heartbeatChannel` points at this Discord channel, the heartbeat result is delivered as a DM to the first allowed user.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Fix |
|
||||
|---------|-----|
|
||||
| Channel stuck on **needs-auth** | Check the bot token; ensure the **Message Content intent** is enabled (Step 3). |
|
||||
| Bot replies but ignores your text / empty messages | Message Content intent is off — enable it and reconnect. |
|
||||
| `/agents` command missing | Global commands can take up to ~1h to propagate; ensure the bot was invited with the `applications.commands` scope. |
|
||||
| Bot doesn't respond at all | Confirm your numeric user ID is in **Allowed users**, and the bot has Send Messages / Read Message History in that channel. |
|
||||
28
README.md
28
README.md
|
|
@ -270,6 +270,33 @@ Chat with your agents from Telegram — simpler setup than Slack, no @mention re
|
|||
|
||||
---
|
||||
|
||||
### Discord
|
||||
|
||||
Chat with your agents from Discord — in servers, channels, threads, or DMs.
|
||||
|
||||
> **📖 [Step-by-step Discord setup guide →](DISCORD_SETUP.md)** — complete walkthrough from creating the Discord app to sending your first message.
|
||||
|
||||
**Setup:**
|
||||
1. Create an application at [discord.com/developers](https://discord.com/developers/applications), add a bot, and copy its token
|
||||
2. **Enable the Message Content privileged intent** (required to read messages)
|
||||
3. Invite the bot with the `bot` + `applications.commands` scopes
|
||||
4. Add the token in Settings → Agent Fleet → Channel Credentials (type: Discord)
|
||||
5. Create a channel via the dashboard or as `_fleet/channels/my-discord.md`
|
||||
6. Mention or DM the bot in Discord
|
||||
|
||||
**Features:**
|
||||
- **Gateway over WebSocket** — outbound connection, works behind NAT/firewalls, no public URL; hand-rolled over `ws`, no `discord.js` dependency
|
||||
- **Servers, threads & DMs** — every channel or thread is an isolated conversation with its own agent binding
|
||||
- **Typing indicator** — native typing dots while the agent works (auto-refreshed)
|
||||
- **Button agent picker** — the `/agents` slash command shows interactive buttons to switch agents; `@agent-name` prefix routing also works
|
||||
- **Image attachments** — images are downloaded into your vault and passed to the agent
|
||||
- **Session persistence** — conversations survive Obsidian restarts via the backend's resume (Claude `--resume` / Codex `exec resume`)
|
||||
- **Allowlist** — only approved Discord users (by numeric ID) can reach the bot
|
||||
- **2000-char message splitting** — long replies automatically chunked at paragraph boundaries
|
||||
- **Auto-reconnect & resume** — Gateway RESUME on transient drops with exponential backoff
|
||||
|
||||
---
|
||||
|
||||
### Interactive Chat
|
||||
|
||||
The chat panel is a first-class Obsidian view — dock it in the sidebar, center, or any split.
|
||||
|
|
@ -549,6 +576,7 @@ An autonomous periodic run — what an agent does on a schedule without user inp
|
|||
## Links
|
||||
|
||||
- [Slack Setup Guide](SLACK_SETUP.md)
|
||||
- [Discord Setup Guide](DISCORD_SETUP.md)
|
||||
- [Releases](https://github.com/denberek/obsidian-agent-fleet/releases)
|
||||
- [npm package](https://www.npmjs.com/package/obsidian-agent-fleet)
|
||||
- [Report Issues](https://github.com/denberek/obsidian-agent-fleet/issues)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ These are inherited by all agent processes. Never store tokens in vault files.
|
|||
| Defined in | HEARTBEAT.md in agent folder | _fleet/tasks/<name>.md |
|
||||
| Prompt source | Heartbeat body | Task body |
|
||||
| "Run Now" button | Uses heartbeat instruction | Uses task prompt |
|
||||
| Delivery | Optional Slack channel post | Run log only |
|
||||
| Delivery | Run log + optional channel post (`channel` in HEARTBEAT.md) | Run log + optional channel post (`channel` in task frontmatter) |
|
||||
| Scope | One per agent | Many per agent |
|
||||
| Best for | Autonomous periodic monitoring | Specific scheduled work items |
|
||||
|
||||
|
|
|
|||
|
|
@ -141,6 +141,9 @@ enabled: true
|
|||
schedule: "0 */6 * * *" # Cron expression — every 6 hours
|
||||
notify: true # Show Obsidian notice on completion
|
||||
channel: my-slack # Post results to this channel (optional)
|
||||
channel_target: "" # Specific channel/conversation id within `channel` to post to
|
||||
# (e.g. a Discord channel id). Empty = broadcast as a DM to the
|
||||
# channel's first allowed user. Quote numeric ids to preserve precision.
|
||||
---
|
||||
|
||||
Check the following endpoints for availability and response time:
|
||||
|
|
@ -156,6 +159,8 @@ a one-line "all clear". Use [REMEMBER] to track trends across heartbeats.
|
|||
- `schedule` — cron expression (same format as tasks)
|
||||
- `notify` — show Obsidian notice when heartbeat completes (default true)
|
||||
- `channel` — name of a configured channel to post results to (optional)
|
||||
- `channel_target` — specific channel/conversation id within `channel` to post to (optional;
|
||||
empty broadcasts a DM to the channel's first allowed user). Mirrors a task's `channel_target`.
|
||||
|
||||
## Creating a Channel
|
||||
|
||||
|
|
@ -230,6 +235,12 @@ model: "" # Override agent model for this task only.
|
|||
# Use aliases like "haiku" for cheap/simple tasks,
|
||||
# or leave empty to inherit agent's model.
|
||||
# Resolution order: task.model → agent.model → settings.defaultModel.
|
||||
channel: "" # Post this task's output to a channel (e.g. "my-discord").
|
||||
# Empty = run log only. Lets scheduled tasks deliver to chat,
|
||||
# not just the heartbeat.
|
||||
channel_target: "" # Optional destination id within `channel`: a Discord/Slack
|
||||
# channel id or Telegram chat id. Set = post directly to that
|
||||
# channel; empty = DM the channel's first allowed user.
|
||||
tags:
|
||||
- monitoring
|
||||
---
|
||||
|
|
@ -238,6 +249,23 @@ Task prompt goes here. This is what the agent should do each run.
|
|||
Be specific and clear about expected output.
|
||||
```
|
||||
|
||||
**Channel delivery (`channel` + `channel_target`).** Any task can post its output to a
|
||||
configured channel by setting `channel:` to a channel name. Previously only an agent's
|
||||
heartbeat could post to a channel; now every scheduled/manual task can too. The output
|
||||
is the task run's **full** output text, prefixed with `*<agent> — <task_id>*`.
|
||||
|
||||
Two delivery modes:
|
||||
- **`channel_target` empty** → broadcast (a DM to the channel's first allowed user) —
|
||||
same path the heartbeat uses.
|
||||
- **`channel_target` set** → post directly to that specific channel/conversation. The
|
||||
target is a transport-native id: a Discord/Slack channel id or a Telegram chat id.
|
||||
(Discord: enable Developer Mode → right-click the channel → Copy Channel ID. The bot
|
||||
must have permission to post there.)
|
||||
|
||||
Keep the task prompt's output concise/skimmable if it's going to chat. Heartbeats still
|
||||
use `channel` in HEARTBEAT.md (broadcast only); tasks use `channel`/`channel_target` in
|
||||
their own frontmatter.
|
||||
|
||||
### Task Types
|
||||
- **recurring** — runs on a cron schedule. Requires `schedule` field.
|
||||
- **once** — runs at a specific time. Requires `run_at` field (ISO datetime).
|
||||
|
|
|
|||
194
main.js
194
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "agent-fleet",
|
||||
"name": "Agent Fleet",
|
||||
"version": "0.13.6",
|
||||
"version": "0.14.0",
|
||||
"minAppVersion": "1.11.4",
|
||||
"description": "File-backed AI agents with task scheduling, channels, memory, and MCP — running on Claude Code or OpenAI Codex, all as plain markdown.",
|
||||
"author": "Denis Berekchiyan",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.13.4",
|
||||
"version": "0.13.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.13.4",
|
||||
"version": "0.13.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-agent-fleet",
|
||||
"version": "0.13.6",
|
||||
"description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack), heartbeat, and interactive chat.",
|
||||
"version": "0.14.0",
|
||||
"description": "Obsidian plugin for file-backed AI agents, task scheduling, channels (Slack, Telegram, Discord), heartbeat, and interactive chat.",
|
||||
"license": "MIT",
|
||||
"main": "plugin/main.js",
|
||||
"bin": {
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
"plugin/styles.css",
|
||||
"README.md",
|
||||
"SLACK_SETUP.md",
|
||||
"DISCORD_SETUP.md",
|
||||
"LICENSE",
|
||||
"screenshot.png"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: false,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: false,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: false,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const DEFAULT_SETTINGS: FleetSettings = {
|
|||
defaultFileHashes: {},
|
||||
};
|
||||
|
||||
export const FLEET_SUBFOLDERS = ["agents", "skills", "tasks", "runs", "memory", "channels", "mcp"] as const;
|
||||
export const FLEET_SUBFOLDERS = ["agents", "skills", "tasks", "runs", "memory", "channels", "mcp", "usage"] as const;
|
||||
|
||||
// ─── Memory v2 defaults (see MEMORY_EVOLUTION_DESIGN.md) ───
|
||||
/** Steady-state token budget for an agent's injected working memory. */
|
||||
|
|
|
|||
|
|
@ -545,7 +545,7 @@ These are inherited by all agent processes. Never store tokens in vault files.
|
|||
| Defined in | HEARTBEAT.md in agent folder | _fleet/tasks/<name>.md |
|
||||
| Prompt source | Heartbeat body | Task body |
|
||||
| "Run Now" button | Uses heartbeat instruction | Uses task prompt |
|
||||
| Delivery | Optional Slack channel post | Run log only |
|
||||
| Delivery | Run log + optional channel post (\`channel\` in HEARTBEAT.md) | Run log + optional channel post (\`channel\` in task frontmatter) |
|
||||
| Scope | One per agent | Many per agent |
|
||||
| Best for | Autonomous periodic monitoring | Specific scheduled work items |
|
||||
|
||||
|
|
@ -756,6 +756,9 @@ enabled: true
|
|||
schedule: "0 */6 * * *" # Cron expression — every 6 hours
|
||||
notify: true # Show Obsidian notice on completion
|
||||
channel: my-slack # Post results to this channel (optional)
|
||||
channel_target: "" # Specific channel/conversation id within \`channel\` to post to
|
||||
# (e.g. a Discord channel id). Empty = broadcast as a DM to the
|
||||
# channel's first allowed user. Quote numeric ids to preserve precision.
|
||||
---
|
||||
|
||||
Check the following endpoints for availability and response time:
|
||||
|
|
@ -771,6 +774,8 @@ a one-line "all clear". Use [REMEMBER] to track trends across heartbeats.
|
|||
- \`schedule\` — cron expression (same format as tasks)
|
||||
- \`notify\` — show Obsidian notice when heartbeat completes (default true)
|
||||
- \`channel\` — name of a configured channel to post results to (optional)
|
||||
- \`channel_target\` — specific channel/conversation id within \`channel\` to post to (optional;
|
||||
empty broadcasts a DM to the channel's first allowed user). Mirrors a task's \`channel_target\`.
|
||||
|
||||
## Creating a Channel
|
||||
|
||||
|
|
@ -845,6 +850,12 @@ model: "" # Override agent model for this task only.
|
|||
# Use aliases like "haiku" for cheap/simple tasks,
|
||||
# or leave empty to inherit agent's model.
|
||||
# Resolution order: task.model → agent.model → settings.defaultModel.
|
||||
channel: "" # Post this task's output to a channel (e.g. "my-discord").
|
||||
# Empty = run log only. Lets scheduled tasks deliver to chat,
|
||||
# not just the heartbeat.
|
||||
channel_target: "" # Optional destination id within \`channel\`: a Discord/Slack
|
||||
# channel id or Telegram chat id. Set = post directly to that
|
||||
# channel; empty = DM the channel's first allowed user.
|
||||
tags:
|
||||
- monitoring
|
||||
---
|
||||
|
|
@ -853,6 +864,23 @@ Task prompt goes here. This is what the agent should do each run.
|
|||
Be specific and clear about expected output.
|
||||
\`\`\`
|
||||
|
||||
**Channel delivery (\`channel\` + \`channel_target\`).** Any task can post its output to a
|
||||
configured channel by setting \`channel:\` to a channel name. Previously only an agent's
|
||||
heartbeat could post to a channel; now every scheduled/manual task can too. The output
|
||||
is the task run's **full** output text, prefixed with \`*<agent> — <task_id>*\`.
|
||||
|
||||
Two delivery modes:
|
||||
- **\`channel_target\` empty** → broadcast (a DM to the channel's first allowed user) —
|
||||
same path the heartbeat uses.
|
||||
- **\`channel_target\` set** → post directly to that specific channel/conversation. The
|
||||
target is a transport-native id: a Discord/Slack channel id or a Telegram chat id.
|
||||
(Discord: enable Developer Mode → right-click the channel → Copy Channel ID. The bot
|
||||
must have permission to post there.)
|
||||
|
||||
Keep the task prompt's output concise/skimmable if it's going to chat. Heartbeats still
|
||||
use \`channel\` in HEARTBEAT.md (broadcast only); tasks use \`channel\`/\`channel_target\` in
|
||||
their own frontmatter.
|
||||
|
||||
### Task Types
|
||||
- **recurring** — runs on a cron schedule. Requires \`schedule\` field.
|
||||
- **once** — runs at a specific time. Requires \`run_at\` field (ISO datetime).
|
||||
|
|
@ -1787,7 +1815,7 @@ For smooth animation:
|
|||
\`\`\`javascript
|
||||
// Color utilities
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})$/i.exec(hex);
|
||||
const result = /^#?([a-f\\d]{2})([a-f\\d]{2})([a-f\\d]{2})\$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
|
|
@ -2876,7 +2904,7 @@ Top-level fields (\`id\`, \`display_name\`, \`max_input_tokens\`, \`max_tokens\`
|
|||
|
||||
\`\`\`bash
|
||||
curl https://api.anthropic.com/v1/models/claude-opus-4-6 \\
|
||||
-H "x-api-key: $ANTHROPIC_API_KEY" \\
|
||||
-H "x-api-key: \$ANTHROPIC_API_KEY" \\
|
||||
-H "anthropic-version: 2023-06-01"
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -3090,7 +3118,7 @@ The response \`usage\` object reports cache activity:
|
|||
|
||||
If \`cache_read_input_tokens\` is zero across repeated requests with identical prefixes, a silent invalidator is at work — diff the rendered prompt bytes between two requests to find it.
|
||||
|
||||
Language-specific access: \`response.usage.cache_read_input_tokens\` (Python/TS/Ruby), \`$message->usage->cacheReadInputTokens\` (PHP), \`resp.Usage.CacheReadInputTokens\` (Go/C#), \`.usage().cacheReadInputTokens()\` (Java).
|
||||
Language-specific access: \`response.usage.cache_read_input_tokens\` (Python/TS/Ruby), \`\$message->usage->cacheReadInputTokens\` (PHP), \`resp.Usage.CacheReadInputTokens\` (Go/C#), \`.usage().cacheReadInputTokens()\` (Java).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -3204,7 +3232,7 @@ The code execution tool lets Claude run code in a secure, sandboxed container. U
|
|||
- No internet access (fully sandboxed)
|
||||
- Python 3.11 with data science libraries pre-installed
|
||||
- Containers persist for 30 days and can be reused across requests
|
||||
- Free when used with web search/web fetch tools; otherwise $0.05/hour after 1,550 free hours/month per organization
|
||||
- Free when used with web search/web fetch tools; otherwise \$0.05/hour after 1,550 free hours/month per organization
|
||||
|
||||
### Tool Definition
|
||||
|
||||
|
|
@ -3363,7 +3391,7 @@ Two features are available:
|
|||
**Supported:**
|
||||
|
||||
- Basic types: object, array, string, integer, number, boolean, null
|
||||
- \`enum\`, \`const\`, \`anyOf\`, \`allOf\`, \`$ref\`/\`$def\`
|
||||
- \`enum\`, \`const\`, \`anyOf\`, \`allOf\`, \`\$ref\`/\`\$def\`
|
||||
- String formats: \`date-time\`, \`time\`, \`date\`, \`duration\`, \`email\`, \`hostname\`, \`uri\`, \`ipv4\`, \`ipv6\`, \`uuid\`
|
||||
- \`additionalProperties: false\` (required for all objects)
|
||||
|
||||
|
|
@ -3544,11 +3572,11 @@ Everything goes through \`POST /v1/messages\`. Tools and output constraints are
|
|||
|
||||
## Current Models (cached: 2026-02-17)
|
||||
|
||||
| Model | Model ID | Context | Input $/1M | Output $/1M |
|
||||
| Model | Model ID | Context | Input \$/1M | Output \$/1M |
|
||||
| ----------------- | ------------------- | -------------- | ---------- | ----------- |
|
||||
| Claude Opus 4.6 | \`claude-opus-4-6\` | 200K (1M beta) | $5.00 | $25.00 |
|
||||
| Claude Sonnet 4.6 | \`claude-sonnet-4-6\` | 200K (1M beta) | $3.00 | $15.00 |
|
||||
| Claude Haiku 4.5 | \`claude-haiku-4-5\` | 200K | $1.00 | $5.00 |
|
||||
| Claude Opus 4.6 | \`claude-opus-4-6\` | 200K (1M beta) | \$5.00 | \$25.00 |
|
||||
| Claude Sonnet 4.6 | \`claude-sonnet-4-6\` | 200K (1M beta) | \$3.00 | \$15.00 |
|
||||
| Claude Haiku 4.5 | \`claude-haiku-4-5\` | 200K | \$1.00 | \$5.00 |
|
||||
|
||||
**ALWAYS use \`claude-opus-4-6\` unless the user explicitly names a different model.** This is non-negotiable. Do not use \`claude-sonnet-4-6\`, \`claude-sonnet-4-5\`, or any other model unless the user literally says "use sonnet" or "use haiku". Never downgrade for cost — that's the user's decision, not yours.
|
||||
|
||||
|
|
@ -5701,7 +5729,7 @@ server.registerResource(
|
|||
mimeType: "text/plain"
|
||||
},
|
||||
async (uri: string) => {
|
||||
const match = uri.match(/^file:\\/\\/documents\\/(.+)$/);
|
||||
const match = uri.match(/^file:\\/\\/documents\\/(.+)\$/);
|
||||
const documentName = match[1];
|
||||
const content = await loadDocument(documentName);
|
||||
return { contents: [{ uri, mimeType: "text/plain", text: content }] };
|
||||
|
|
@ -5853,7 +5881,7 @@ from pydantic import BaseModel, Field, field_validator, ConfigDict
|
|||
class CreateUserInput(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, validate_assignment=True)
|
||||
name: str = Field(..., description="User's full name", min_length=1, max_length=100)
|
||||
email: str = Field(..., description="User's email", pattern=r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+$')
|
||||
email: str = Field(..., description="User's email", pattern=r'^[\\w\\.-]+@[\\w\\.-]+\\.\\w+\$')
|
||||
|
||||
@field_validator('email')
|
||||
@classmethod
|
||||
|
|
@ -6499,7 +6527,7 @@ Example evaluation file demonstrating the XML format for evaluation questions:
|
|||
\`\`\`xml
|
||||
<evaluation>
|
||||
<qa_pair>
|
||||
<question>Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question>
|
||||
<question>Calculate the compound interest on \$10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)?</question>
|
||||
<answer>11614.72</answer>
|
||||
</qa_pair>
|
||||
<!-- More qa_pairs... -->
|
||||
|
|
@ -9289,7 +9317,7 @@ Put each with_skill version before its baseline counterpart.
|
|||
--skill-name "my-skill" \\
|
||||
--benchmark <workspace>/iteration-N/benchmark.json \\
|
||||
> /dev/null 2>&1 &
|
||||
VIEWER_PID=$!
|
||||
VIEWER_PID=\$!
|
||||
\`\`\`
|
||||
For iteration 2+, also pass \`--previous-workspace <workspace>/iteration-<N-1>\`.
|
||||
|
||||
|
|
@ -9333,7 +9361,7 @@ Empty feedback means the user thought it was fine. Focus your improvements on th
|
|||
Kill the viewer server when you're done with it:
|
||||
|
||||
\`\`\`bash
|
||||
kill $VIEWER_PID 2>/dev/null
|
||||
kill \$VIEWER_PID 2>/dev/null
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
|
@ -12232,8 +12260,8 @@ Unless otherwise stated by the user or existing template
|
|||
|
||||
#### Required Format Rules
|
||||
- **Years**: Format as text strings (e.g., "2024" not "2,024")
|
||||
- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)")
|
||||
- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-")
|
||||
- **Currency**: Use \$#,##0 format; ALWAYS specify units in headers ("Revenue (\$mm)")
|
||||
- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "\$#,##0;(\$#,##0);-")
|
||||
- **Percentages**: Default to 0.0% format (one decimal)
|
||||
- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E)
|
||||
- **Negative numbers**: Use parentheses (123) not minus -123
|
||||
|
|
@ -12243,7 +12271,7 @@ Unless otherwise stated by the user or existing template
|
|||
#### Assumptions Placement
|
||||
- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells
|
||||
- Use cell references instead of hardcoded values in formulas
|
||||
- Example: Use =B5*(1+$B$6) instead of =B5*1.05
|
||||
- Example: Use =B5*(1+\$B\$6) instead of =B5*1.05
|
||||
|
||||
#### Formula Error Prevention
|
||||
- Verify all cell references are correct
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import type {
|
|||
WorkingMemory,
|
||||
SkillConfig,
|
||||
TaskConfig,
|
||||
UsageRecord,
|
||||
ValidationIssue,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -553,6 +554,7 @@ export class FleetRepository {
|
|||
let heartbeatBody = "";
|
||||
let heartbeatNotify = true;
|
||||
let heartbeatChannel = "";
|
||||
let heartbeatChannelTarget = "";
|
||||
const heartbeatPath = normalizePath(`${folderPath}/HEARTBEAT.md`);
|
||||
const heartbeatFile = this.vault.getAbstractFileByPath(heartbeatPath);
|
||||
if (heartbeatFile instanceof TFile) {
|
||||
|
|
@ -562,6 +564,7 @@ export class FleetRepository {
|
|||
heartbeatSchedule = asString(parsed.frontmatter.schedule) ?? "";
|
||||
heartbeatNotify = asBoolean(parsed.frontmatter.notify, true);
|
||||
heartbeatChannel = asString(parsed.frontmatter.channel) ?? "";
|
||||
heartbeatChannelTarget = asString(parsed.frontmatter.channel_target) ?? "";
|
||||
heartbeatBody = parsed.body;
|
||||
}
|
||||
|
||||
|
|
@ -616,6 +619,7 @@ export class FleetRepository {
|
|||
heartbeatBody,
|
||||
heartbeatNotify,
|
||||
heartbeatChannel,
|
||||
heartbeatChannelTarget,
|
||||
wikiKeeper: this.parseWikiKeeperConfig(configFm.wiki_keeper ?? agentFm.wiki_keeper),
|
||||
wikiReferences: this.parseWikiReferences(configFm.wiki_references ?? agentFm.wiki_references),
|
||||
};
|
||||
|
|
@ -1311,6 +1315,60 @@ export class FleetRepository {
|
|||
return parsed.sort((a, b) => b.started.localeCompare(a.started));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Usage ledger (chat/channel token+cost) — _fleet/usage/YYYY-MM-DD.jsonl
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private usageLedgerPath(ts: string): string {
|
||||
return normalizePath(`${this.getSubfolder("usage")}/${ts.slice(0, 10)}.jsonl`);
|
||||
}
|
||||
|
||||
/** Append one usage record to the day's JSONL ledger (one line per turn).
|
||||
* Uses the raw adapter so it doesn't go through the markdown pipeline. */
|
||||
async appendUsage(record: UsageRecord): Promise<void> {
|
||||
await this.ensureFolder(this.getSubfolder("usage"));
|
||||
const path = this.usageLedgerPath(record.ts);
|
||||
const line = `${JSON.stringify(record)}\n`;
|
||||
const adapter = this.vault.adapter;
|
||||
if (await adapter.exists(path)) {
|
||||
await adapter.append(path, line);
|
||||
} else {
|
||||
await adapter.write(path, line);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read all usage records on or after `sinceDate` (by ledger file date). */
|
||||
async readUsageSince(sinceDate: Date): Promise<UsageRecord[]> {
|
||||
const dir = this.getSubfolder("usage");
|
||||
const adapter = this.vault.adapter;
|
||||
if (!(await adapter.exists(dir))) return [];
|
||||
const sinceStr = `${sinceDate.getFullYear()}-${String(sinceDate.getMonth() + 1).padStart(2, "0")}-${String(sinceDate.getDate()).padStart(2, "0")}`;
|
||||
const out: UsageRecord[] = [];
|
||||
const listing = await adapter.list(dir);
|
||||
for (const filePath of listing.files) {
|
||||
if (!filePath.endsWith(".jsonl")) continue;
|
||||
const base = (filePath.split("/").pop() ?? "").replace(/\.jsonl$/, "");
|
||||
// Files are named YYYY-MM-DD; lexicographic >= matches calendar >=.
|
||||
if (base < sinceStr) continue;
|
||||
let content: string;
|
||||
try {
|
||||
content = await adapter.read(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const raw of content.split("\n")) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
out.push(JSON.parse(trimmed) as UsageRecord);
|
||||
} catch {
|
||||
// skip a corrupt line rather than failing the whole read
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async readRunLog(file: TFile): Promise<RunLogData | null> {
|
||||
const content = await this.vault.cachedRead(file);
|
||||
const { frontmatter, body } = parseMarkdownWithFrontmatter<Record<string, unknown>>(content);
|
||||
|
|
@ -1776,6 +1834,8 @@ export class FleetRepository {
|
|||
catch_up?: boolean;
|
||||
effort?: string;
|
||||
model?: string;
|
||||
channel?: string;
|
||||
channelTarget?: string;
|
||||
tags?: string[];
|
||||
body?: string;
|
||||
}): Promise<void> {
|
||||
|
|
@ -1796,6 +1856,8 @@ export class FleetRepository {
|
|||
if (updates.catch_up !== undefined) frontmatter.catch_up = updates.catch_up;
|
||||
if (updates.effort !== undefined) frontmatter.effort = updates.effort || undefined;
|
||||
if (updates.model !== undefined) frontmatter.model = updates.model || undefined;
|
||||
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
|
||||
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
|
||||
if (updates.tags !== undefined) frontmatter.tags = updates.tags;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
|
|
@ -2024,6 +2086,7 @@ export class FleetRepository {
|
|||
schedule?: string;
|
||||
notify?: boolean;
|
||||
channel?: string;
|
||||
channelTarget?: string;
|
||||
body?: string;
|
||||
}): Promise<void> {
|
||||
const agent = this.getAgentByName(agentName);
|
||||
|
|
@ -2041,6 +2104,7 @@ export class FleetRepository {
|
|||
if (updates.schedule !== undefined) frontmatter.schedule = updates.schedule || undefined;
|
||||
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
|
||||
if (updates.channel !== undefined) frontmatter.channel = updates.channel || undefined;
|
||||
if (updates.channelTarget !== undefined) frontmatter.channel_target = updates.channelTarget || undefined;
|
||||
const newBody = updates.body !== undefined ? updates.body : body;
|
||||
await this.vault.modify(file, stringifyMarkdownWithFrontmatter(frontmatter, newBody));
|
||||
} else {
|
||||
|
|
@ -2051,6 +2115,7 @@ export class FleetRepository {
|
|||
if (updates.schedule) frontmatter.schedule = updates.schedule;
|
||||
if (updates.notify !== undefined) frontmatter.notify = updates.notify;
|
||||
if (updates.channel) frontmatter.channel = updates.channel;
|
||||
if (updates.channelTarget) frontmatter.channel_target = updates.channelTarget;
|
||||
const body = updates.body ?? "";
|
||||
await this.vault.create(
|
||||
heartbeatPath,
|
||||
|
|
@ -2257,6 +2322,7 @@ export class FleetRepository {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2319,6 +2385,8 @@ export class FleetRepository {
|
|||
catchUp: asBoolean(frontmatter.catch_up, this.settings.catchUpMissedTasks),
|
||||
effort: asString(frontmatter.effort),
|
||||
model: asString(frontmatter.model),
|
||||
channel: asString(frontmatter.channel),
|
||||
channelTarget: asString(frontmatter.channel_target),
|
||||
tags: asStringArray(frontmatter.tags),
|
||||
body,
|
||||
};
|
||||
|
|
@ -2334,7 +2402,7 @@ export class FleetRepository {
|
|||
}
|
||||
|
||||
const rawType = asString(frontmatter.type);
|
||||
const validTypes: readonly string[] = ["slack", "telegram"];
|
||||
const validTypes: readonly string[] = ["slack", "telegram", "discord"];
|
||||
if (!rawType || !validTypes.includes(rawType)) {
|
||||
this.setIssue(
|
||||
path,
|
||||
|
|
|
|||
36
src/main.ts
36
src/main.ts
|
|
@ -20,6 +20,7 @@ import { SecretStore } from "./services/secretStore";
|
|||
import { parseClaudeMcpServers, parseCodexMcpServers, mergeImports } from "./services/mcpImport";
|
||||
import { SlackAdapter } from "./services/channels/slackAdapter";
|
||||
import { TelegramAdapter } from "./services/channels/telegramAdapter";
|
||||
import { DiscordAdapter } from "./services/channels/discordAdapter";
|
||||
import type { ChannelAdapter } from "./services/channels/adapter";
|
||||
import type { ChannelConfig, ChannelCredentialEntry, FleetSettings } from "./types";
|
||||
import { parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "./utils/markdown";
|
||||
|
|
@ -154,6 +155,7 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
getSettings: () => this.settings,
|
||||
getChannelCredentials: () => this.channelCredentials.toRecord(),
|
||||
getMcpAuth: () => this.mcpAuth,
|
||||
recordUsage: (record) => this.runtime.recordUsage(record),
|
||||
adapterFactory: (config: ChannelConfig, credential: ChannelCredentialEntry): ChannelAdapter => {
|
||||
if (config.type === "slack") {
|
||||
return new SlackAdapter(config, credential);
|
||||
|
|
@ -161,6 +163,9 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
if (config.type === "telegram") {
|
||||
return new TelegramAdapter(config, credential);
|
||||
}
|
||||
if (config.type === "discord") {
|
||||
return new DiscordAdapter(config, credential);
|
||||
}
|
||||
throw new Error(`Channel type \`${config.type}\` is not yet supported in this version.`);
|
||||
},
|
||||
});
|
||||
|
|
@ -171,16 +176,19 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
new Notice("Agent Fleet: channel manager failed to start — check console.");
|
||||
}
|
||||
|
||||
// Wire heartbeat results to Slack channels. When an agent's heartbeat
|
||||
// completes and its heartbeatChannel is set, the runtime calls this handler
|
||||
// which posts the output via the channel's broadcast method (opens a DM with
|
||||
// the first allowed user and posts there).
|
||||
this.runtime.onHeartbeatResult((agentName, channelName, output) => {
|
||||
void this.channelManager?.broadcastToChannel(
|
||||
channelName,
|
||||
`*Heartbeat — ${agentName}*\n\n${output}`,
|
||||
).catch((err: unknown) => {
|
||||
console.warn(`Agent Fleet: heartbeat channel post failed for ${agentName}`, err);
|
||||
// Wire run results to channels. Fires when an agent's heartbeat completes
|
||||
// (using heartbeatChannel) or when a scheduled/manual task sets a `channel`
|
||||
// field. `source` is "heartbeat" or the task id, used only to label the post.
|
||||
// When `target` is set the post goes to that specific channel id; otherwise it
|
||||
// broadcasts (opens a DM with the first allowed user and posts there).
|
||||
this.runtime.onChannelResult((agentName, channelName, output, source, target) => {
|
||||
const label = source === "heartbeat" ? `Heartbeat — ${agentName}` : `${agentName} — ${source}`;
|
||||
const text = `*${label}*\n\n${output}`;
|
||||
const delivery = target
|
||||
? this.channelManager?.postToChannelTarget(channelName, target, text)
|
||||
: this.channelManager?.broadcastToChannel(channelName, text);
|
||||
void delivery?.catch((err: unknown) => {
|
||||
console.warn(`Agent Fleet: channel post failed for ${agentName}`, err);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -630,16 +638,20 @@ export default class AgentFleetPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private registerVaultHandlers(): void {
|
||||
// The usage ledger (`_fleet/usage/`) is appended on every chat/channel turn
|
||||
// and holds no parsed entities — skip it so the hot path never triggers a
|
||||
// full fleet reparse.
|
||||
const isLedgerPath = (p: string) => p.startsWith(`${this.settings.fleetFolder}/usage/`);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("create", (file) => {
|
||||
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`)) {
|
||||
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`) && !isLedgerPath(file.path)) {
|
||||
this.debouncedVaultRefresh();
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.vault.on("modify", (file) => {
|
||||
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`)) {
|
||||
if (file instanceof TFile && file.path.startsWith(`${this.settings.fleetFolder}/`) && !isLedgerPath(file.path)) {
|
||||
this.debouncedVaultRefresh();
|
||||
}
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
ChannelStatus,
|
||||
FleetSettings,
|
||||
FleetSnapshot,
|
||||
UsageRecord,
|
||||
} from "../types";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import type { McpAuthManager } from "./mcpAuth";
|
||||
|
|
@ -43,6 +44,9 @@ export interface ChannelManagerDeps {
|
|||
getMcpAuth?: () => McpAuthManager;
|
||||
/** Factory that produces the transport-specific adapter. */
|
||||
adapterFactory: ChannelAdapterFactory;
|
||||
/** Sink for per-turn token/cost usage from channel sessions (the usage ledger).
|
||||
* Late-bound to the current runtime so it survives runtime rebuilds. */
|
||||
recordUsage?: (record: UsageRecord) => void;
|
||||
/** Injectable clock, primarily for tests. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
|
@ -629,6 +633,9 @@ export class ChannelManager {
|
|||
mcpAuth: this.deps.getMcpAuth?.(),
|
||||
},
|
||||
);
|
||||
if (this.deps.recordUsage) {
|
||||
session.setUsageRecorder(this.deps.recordUsage);
|
||||
}
|
||||
|
||||
try {
|
||||
await session.loadPersistedState();
|
||||
|
|
@ -786,6 +793,24 @@ export class ChannelManager {
|
|||
await adapter.broadcast(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to an explicit destination id within a channel (a Discord/Slack
|
||||
* channel id or Telegram chat id) — used for per-task delivery to a specific
|
||||
* channel rather than the broadcast DM.
|
||||
*/
|
||||
async postToChannelTarget(channelName: string, target: string, text: string): Promise<void> {
|
||||
const adapter = this.adapters.get(channelName);
|
||||
if (!adapter) {
|
||||
console.warn(`Agent Fleet: postToChannelTarget — no adapter for channel ${channelName}`);
|
||||
return;
|
||||
}
|
||||
if (!adapter.sendToTarget) {
|
||||
console.warn(`Agent Fleet: postToChannelTarget — adapter ${channelName} does not support sendToTarget`);
|
||||
return;
|
||||
}
|
||||
await adapter.sendToTarget(target, text);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Conversation lock — per-key FIFO promise chain
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -87,6 +87,15 @@ export interface ChannelAdapter {
|
|||
*/
|
||||
broadcast?(text: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Post a message to an explicit transport-native destination id — a Discord or
|
||||
* Slack channel id, or a Telegram chat id — independent of any inbound
|
||||
* conversation. Used for per-task result delivery to a specific channel.
|
||||
* Adapters should chunk + rate-limit the same way `send()` does. Optional;
|
||||
* transports that don't support it can omit.
|
||||
*/
|
||||
sendToTarget?(target: string, text: string): Promise<void>;
|
||||
|
||||
onInbound(handler: InboundHandler): () => void;
|
||||
onStatusChange(handler: StatusHandler): () => void;
|
||||
|
||||
|
|
|
|||
236
src/services/channels/discordAdapter.test.ts
Normal file
236
src/services/channels/discordAdapter.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The adapter's only runtime obsidian dependency is requestUrl — mock it so we can
|
||||
// assert the outbound REST request shape (headers/url) without a network call.
|
||||
const { requestUrlMock } = vi.hoisted(() => ({ requestUrlMock: vi.fn() }));
|
||||
vi.mock("obsidian", () => ({ requestUrl: requestUrlMock }));
|
||||
|
||||
import {
|
||||
DiscordAdapter,
|
||||
buildConversationId,
|
||||
channelIdFromConversationId,
|
||||
describeDiscordError,
|
||||
stripLeadingMention,
|
||||
} from "./discordAdapter";
|
||||
import type { ChannelConfig, ChannelCredentialEntry } from "../../types";
|
||||
import type { InboundMessage } from "./adapter";
|
||||
|
||||
function makeChannel(overrides: Partial<ChannelConfig> = {}): ChannelConfig {
|
||||
return {
|
||||
filePath: "_fleet/channels/test-discord.md",
|
||||
name: "test-discord",
|
||||
type: "discord",
|
||||
defaultAgent: "test-agent",
|
||||
allowedAgents: [],
|
||||
enabled: true,
|
||||
credentialRef: "discord-creds",
|
||||
allowedUsers: [],
|
||||
perUserSessions: true,
|
||||
channelContext: "",
|
||||
transport: {},
|
||||
tags: [],
|
||||
body: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const credential: ChannelCredentialEntry = { type: "discord", botToken: "test-token" };
|
||||
|
||||
/** Typed view into the adapter's internals for testing the inbound mapping. */
|
||||
interface AdapterInternals {
|
||||
selfUserId: string | null;
|
||||
routeMessage(message: unknown): void;
|
||||
}
|
||||
|
||||
function internals(adapter: DiscordAdapter): AdapterInternals {
|
||||
return adapter as unknown as AdapterInternals;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("conversationId helpers", () => {
|
||||
it("builds and parses guild conversation ids", () => {
|
||||
const id = buildConversationId("guild1", "chan1");
|
||||
expect(id).toBe("discord:guild1:chan1");
|
||||
expect(channelIdFromConversationId(id)).toBe("chan1");
|
||||
});
|
||||
|
||||
it("builds and parses DM conversation ids", () => {
|
||||
const id = buildConversationId(undefined, "dmchan");
|
||||
expect(id).toBe("discord:dm:dmchan");
|
||||
expect(channelIdFromConversationId(id)).toBe("dmchan");
|
||||
});
|
||||
|
||||
it("treats a thread as just another channel id", () => {
|
||||
// A thread message arrives with channel_id = the thread's id.
|
||||
const id = buildConversationId("guild1", "thread99");
|
||||
expect(channelIdFromConversationId(id)).toBe("thread99");
|
||||
});
|
||||
|
||||
it("returns null for non-discord conversation ids", () => {
|
||||
expect(channelIdFromConversationId("tg:123")).toBeNull();
|
||||
expect(channelIdFromConversationId("slack:t:c:thread:1")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLeadingMention", () => {
|
||||
it("strips a leading <@id> mention", () => {
|
||||
expect(stripLeadingMention("<@123> hello there", "123")).toBe("hello there");
|
||||
});
|
||||
|
||||
it("strips a leading <@!id> nickname mention", () => {
|
||||
expect(stripLeadingMention("<@!123> hi", "123")).toBe("hi");
|
||||
});
|
||||
|
||||
it("ignores mentions of other users", () => {
|
||||
expect(stripLeadingMention("<@999> hi", "123")).toBe("<@999> hi");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace and handles no mention", () => {
|
||||
expect(stripLeadingMention(" plain text ", "123")).toBe("plain text");
|
||||
expect(stripLeadingMention("plain", null)).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MESSAGE_CREATE → InboundMessage mapping", () => {
|
||||
it("emits an InboundMessage for a normal guild message", async () => {
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
internals(adapter).selfUserId = "bot-self";
|
||||
const received: InboundMessage[] = [];
|
||||
adapter.onInbound((m) => received.push(m));
|
||||
|
||||
internals(adapter).routeMessage({
|
||||
id: "msg1",
|
||||
channel_id: "chan1",
|
||||
guild_id: "guild1",
|
||||
author: { id: "user1", bot: false },
|
||||
content: "<@bot-self> do the thing",
|
||||
timestamp: "2026-06-19T00:00:00.000Z",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
const msg = received[0]!;
|
||||
expect(msg.conversationId).toBe("discord:guild1:chan1");
|
||||
expect(msg.externalUserId).toBe("user1");
|
||||
expect(msg.text).toBe("do the thing");
|
||||
expect(msg.meta).toMatchObject({
|
||||
discord_channel_id: "chan1",
|
||||
discord_guild_id: "guild1",
|
||||
is_dm: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks DMs with is_dm and a discord:dm: conversation id", async () => {
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
internals(adapter).selfUserId = "bot-self";
|
||||
const received: InboundMessage[] = [];
|
||||
adapter.onInbound((m) => received.push(m));
|
||||
|
||||
internals(adapter).routeMessage({
|
||||
id: "msg2",
|
||||
channel_id: "dmchan",
|
||||
author: { id: "user2", bot: false },
|
||||
content: "hi",
|
||||
timestamp: "2026-06-19T00:00:00.000Z",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]!.conversationId).toBe("discord:dm:dmchan");
|
||||
expect(received[0]!.meta).toMatchObject({ is_dm: true });
|
||||
});
|
||||
|
||||
it("ignores bot-authored and self-authored messages", async () => {
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
internals(adapter).selfUserId = "bot-self";
|
||||
const received: InboundMessage[] = [];
|
||||
adapter.onInbound((m) => received.push(m));
|
||||
|
||||
internals(adapter).routeMessage({
|
||||
id: "m3", channel_id: "c", guild_id: "g",
|
||||
author: { id: "other-bot", bot: true }, content: "beep",
|
||||
});
|
||||
internals(adapter).routeMessage({
|
||||
id: "m4", channel_id: "c", guild_id: "g",
|
||||
author: { id: "bot-self", bot: false }, content: "echo",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores empty-content messages with no attachments", async () => {
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
internals(adapter).selfUserId = "bot-self";
|
||||
const received: InboundMessage[] = [];
|
||||
adapter.onInbound((m) => received.push(m));
|
||||
|
||||
internals(adapter).routeMessage({
|
||||
id: "m5", channel_id: "c", guild_id: "g",
|
||||
author: { id: "user1", bot: false }, content: "",
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a non-discord credential", () => {
|
||||
expect(
|
||||
() => new DiscordAdapter(makeChannel(), { type: "telegram", botToken: "x" }),
|
||||
).toThrow(/discord credential/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("REST request shape", () => {
|
||||
it("sends the required Discord User-Agent + bot auth on outbound calls", async () => {
|
||||
// Regression guard: a missing User-Agent gets Cloudflare-blocked (403 code 40333)
|
||||
// on every REST call while the Gateway still connects.
|
||||
requestUrlMock.mockReset();
|
||||
requestUrlMock.mockResolvedValue({ status: 200, json: {}, text: "" });
|
||||
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
await adapter.send("discord:dm:chan1", "hello");
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledTimes(1);
|
||||
const opts = requestUrlMock.mock.calls[0]![0] as {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
expect(opts.url).toContain("/channels/chan1/messages");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers["User-Agent"]).toMatch(/^DiscordBot \(/);
|
||||
expect(opts.headers.Authorization).toBe("Bot test-token");
|
||||
expect(opts.body).toContain("hello");
|
||||
});
|
||||
|
||||
it("sendToTarget posts directly to the given channel id", async () => {
|
||||
requestUrlMock.mockReset();
|
||||
requestUrlMock.mockResolvedValue({ status: 200, json: {}, text: "" });
|
||||
|
||||
const adapter = new DiscordAdapter(makeChannel(), credential);
|
||||
await adapter.sendToTarget("987654321", "task result");
|
||||
|
||||
expect(requestUrlMock).toHaveBeenCalledTimes(1);
|
||||
const opts = requestUrlMock.mock.calls[0]![0] as { url: string; body?: string };
|
||||
expect(opts.url).toContain("/channels/987654321/messages");
|
||||
expect(opts.body).toContain("task result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeDiscordError", () => {
|
||||
it("extracts Discord's message + code from a JSON error body", () => {
|
||||
expect(describeDiscordError('{"message":"internal network error","code":40333}'))
|
||||
.toBe("internal network error (code 40333)");
|
||||
expect(describeDiscordError('{"message":"Missing Access","code":50001}'))
|
||||
.toBe("Missing Access (code 50001)");
|
||||
});
|
||||
|
||||
it("falls back to raw text for non-JSON bodies", () => {
|
||||
expect(describeDiscordError("Bad Gateway")).toBe("Bad Gateway");
|
||||
expect(describeDiscordError("")).toBe("no body");
|
||||
expect(describeDiscordError(undefined)).toBe("no body");
|
||||
});
|
||||
});
|
||||
852
src/services/channels/discordAdapter.ts
Normal file
852
src/services/channels/discordAdapter.ts
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
import WebSocket from "ws";
|
||||
import { requestUrl } from "obsidian";
|
||||
import type {
|
||||
ChannelConfig,
|
||||
ChannelCredentialEntry,
|
||||
ChannelStatus,
|
||||
ChannelType,
|
||||
} from "../../types";
|
||||
import type {
|
||||
ChannelAdapter,
|
||||
InboundHandler,
|
||||
InboundImage,
|
||||
InboundMessage,
|
||||
StatusHandler,
|
||||
} from "./adapter";
|
||||
|
||||
/**
|
||||
* Discord adapter using the Gateway (WebSocket) for inbound events and the REST
|
||||
* API for everything outbound. Hand-rolled over `ws` — no discord.js — to stay
|
||||
* consistent with the Slack/Telegram adapters and avoid a heavy dependency.
|
||||
*
|
||||
* Transport: outbound WebSocket + outbound HTTPS, so no NAT/firewall concerns.
|
||||
*
|
||||
* Security invariant: the allowlist check uses `author.id` from the
|
||||
* Gateway-delivered MESSAGE_CREATE payload. The Gateway connection is
|
||||
* authenticated by the bot token during IDENTIFY, so `author.id` is trustworthy.
|
||||
* Never accept a sender id from anywhere else (e.g. message content).
|
||||
*
|
||||
* Note: the Message Content intent is privileged and must be enabled in the
|
||||
* Discord Developer Portal, or message `content` arrives empty. A disallowed
|
||||
* intent closes the Gateway with code 4014 — we surface that as `needs-auth`
|
||||
* with a clear console hint.
|
||||
*/
|
||||
|
||||
interface DiscordCredential {
|
||||
type: "discord";
|
||||
botToken: string;
|
||||
}
|
||||
|
||||
const DISCORD_API = "https://discord.com/api/v10";
|
||||
const DEFAULT_GATEWAY = "wss://gateway.discord.gg";
|
||||
const GATEWAY_QUERY = "?v=10&encoding=json";
|
||||
|
||||
// Discord's REST API REQUIRES a User-Agent in the form `DiscordBot ($url, $version)`.
|
||||
// Without it, requests get Cloudflare-blocked with HTTP 403 `internal network error`
|
||||
// (code 40333) — every REST call fails while the Gateway (no Cloudflare WAF) still
|
||||
// connects. See https://docs.discord.food/topics/errors.
|
||||
const DISCORD_USER_AGENT = "DiscordBot (https://github.com/denberek/obsidian-agent-fleet, 0.13.6)";
|
||||
|
||||
// Gateway intents: GUILD_MESSAGES (1<<9) | DIRECT_MESSAGES (1<<12) | MESSAGE_CONTENT (1<<15)
|
||||
const INTENTS = (1 << 9) | (1 << 12) | (1 << 15); // 37376
|
||||
|
||||
// Gateway opcodes
|
||||
const OP_DISPATCH = 0;
|
||||
const OP_HEARTBEAT = 1;
|
||||
const OP_IDENTIFY = 2;
|
||||
const OP_RESUME = 6;
|
||||
const OP_RECONNECT = 7;
|
||||
const OP_INVALID_SESSION = 9;
|
||||
const OP_HELLO = 10;
|
||||
const OP_HEARTBEAT_ACK = 11;
|
||||
|
||||
// Interaction types
|
||||
const INTERACTION_APPLICATION_COMMAND = 2;
|
||||
const INTERACTION_MESSAGE_COMPONENT = 3;
|
||||
|
||||
// Interaction callback types
|
||||
const CALLBACK_CHANNEL_MESSAGE = 4;
|
||||
const CALLBACK_UPDATE_MESSAGE = 7;
|
||||
|
||||
// Message flags
|
||||
const FLAG_EPHEMERAL = 1 << 6; // 64
|
||||
|
||||
export class DiscordAdapter implements ChannelAdapter {
|
||||
readonly type: ChannelType = "discord";
|
||||
public config: ChannelConfig;
|
||||
|
||||
private readonly credential: DiscordCredential;
|
||||
private ws: WebSocket | null = null;
|
||||
private status: ChannelStatus = "stopped";
|
||||
private stopping = false;
|
||||
private backoffMs = 1000;
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
// Gateway session state (for RESUME).
|
||||
private sessionId: string | null = null;
|
||||
private resumeGatewayUrl: string | null = null;
|
||||
private seq: number | null = null;
|
||||
private canResume = false;
|
||||
|
||||
// Heartbeat state.
|
||||
private heartbeatTimer: number | null = null;
|
||||
private heartbeatInitialTimer: number | null = null;
|
||||
private heartbeatAcked = true;
|
||||
|
||||
// Identity learned from the READY dispatch.
|
||||
private selfUserId: string | null = null;
|
||||
private applicationId: string | null = null;
|
||||
private commandRegistered = false;
|
||||
private warnedEmptyContent = false;
|
||||
|
||||
private typingIntervals = new Map<string, number>();
|
||||
/** Per-channel outbound queue so a chunked reply doesn't burst Discord's rate limit. */
|
||||
private readonly sendQueues = new Map<string, Promise<void>>();
|
||||
|
||||
private readonly inboundHandlers = new Set<InboundHandler>();
|
||||
private readonly statusHandlers = new Set<StatusHandler>();
|
||||
private readonly agentSwitchHandlers = new Set<(conversationId: string, agentName: string, userId: string) => void>();
|
||||
/** Resolver for the validated (existing + enabled) agent list, set by the
|
||||
* ChannelManager. Falls back to raw allowed_agents when unset. */
|
||||
private allowedAgentsResolver?: () => string[];
|
||||
|
||||
constructor(config: ChannelConfig, credential: ChannelCredentialEntry) {
|
||||
if (credential.type !== "discord") {
|
||||
throw new Error(`DiscordAdapter requires a discord credential, got ${credential.type}`);
|
||||
}
|
||||
this.config = config;
|
||||
this.credential = credential;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Lifecycle
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stopping = false;
|
||||
this.backoffMs = 1000;
|
||||
this.canResume = false;
|
||||
await this.connect();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopping = true;
|
||||
if (this.reconnectTimer) {
|
||||
window.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.clearHeartbeat();
|
||||
for (const [, interval] of this.typingIntervals) {
|
||||
window.clearInterval(interval);
|
||||
}
|
||||
this.typingIntervals.clear();
|
||||
try {
|
||||
this.ws?.close();
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
this.ws = null;
|
||||
this.setStatus("stopped");
|
||||
}
|
||||
|
||||
getStatus(): ChannelStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Outbound
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
async send(conversationId: string, text: string): Promise<void> {
|
||||
const channelId = channelIdFromConversationId(conversationId);
|
||||
if (!channelId) return;
|
||||
await this.sendToTarget(channelId, text);
|
||||
}
|
||||
|
||||
/** Post directly to a Discord channel id (server channel, thread, or DM channel). */
|
||||
async sendToTarget(channelId: string, text: string): Promise<void> {
|
||||
if (!channelId) return;
|
||||
// Discord's message content cap is 2000 chars.
|
||||
const chunks = splitText(text, 2000);
|
||||
await this.enqueueSend(channelId, async () => {
|
||||
for (const chunk of chunks) {
|
||||
await this.discordApi("POST", `/channels/${channelId}/messages`, { content: chunk });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setTyping(conversationId: string, on: boolean): Promise<void> {
|
||||
const channelId = channelIdFromConversationId(conversationId);
|
||||
if (!channelId) return;
|
||||
|
||||
if (on) {
|
||||
// Clear any existing interval first to prevent a double-create leak.
|
||||
const existing = this.typingIntervals.get(conversationId);
|
||||
if (existing) window.clearInterval(existing);
|
||||
// Discord typing lasts ~10s; send immediately and refresh every 8s.
|
||||
try {
|
||||
await this.discordApi("POST", `/channels/${channelId}/typing`);
|
||||
} catch (err) {
|
||||
console.warn("Agent Fleet: Discord typing trigger failed", err);
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void this.discordApi("POST", `/channels/${channelId}/typing`).catch(() => { /* best-effort */ });
|
||||
}, 8000);
|
||||
this.typingIntervals.set(conversationId, interval);
|
||||
} else {
|
||||
const interval = this.typingIntervals.get(conversationId);
|
||||
if (interval) {
|
||||
window.clearInterval(interval);
|
||||
this.typingIntervals.delete(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async broadcast(text: string): Promise<void> {
|
||||
// Post to the first allowed user via a DM channel.
|
||||
const userId = this.config.allowedUsers[0];
|
||||
if (!userId) return;
|
||||
try {
|
||||
const dm = await this.discordApi<{ id?: string }>("POST", "/users/@me/channels", {
|
||||
recipient_id: userId,
|
||||
});
|
||||
const channelId = dm?.id;
|
||||
if (!channelId) return;
|
||||
const chunks = splitText(text, 2000);
|
||||
for (const chunk of chunks) {
|
||||
await this.discordApi("POST", `/channels/${channelId}/messages`, { content: chunk });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Agent Fleet: Discord broadcast failed on ${this.config.name}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Event subscription
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
onInbound(handler: InboundHandler): () => void {
|
||||
this.inboundHandlers.add(handler);
|
||||
return () => this.inboundHandlers.delete(handler);
|
||||
}
|
||||
|
||||
onStatusChange(handler: StatusHandler): () => void {
|
||||
this.statusHandlers.add(handler);
|
||||
return () => this.statusHandlers.delete(handler);
|
||||
}
|
||||
|
||||
onAgentSwitch(handler: (conversationId: string, agentName: string, userId: string) => void): () => void {
|
||||
this.agentSwitchHandlers.add(handler);
|
||||
return () => this.agentSwitchHandlers.delete(handler);
|
||||
}
|
||||
|
||||
setAllowedAgentsResolver(resolve: () => string[]): void {
|
||||
this.allowedAgentsResolver = resolve;
|
||||
}
|
||||
|
||||
/** The agents the picker should offer: validated list if available, else the
|
||||
* raw configured allow-list. */
|
||||
private pickerAgents(): string[] {
|
||||
if (this.allowedAgentsResolver) return this.allowedAgentsResolver();
|
||||
return this.config.allowedAgents;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Gateway connection
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
this.setStatus(this.ws ? "reconnecting" : "connecting");
|
||||
|
||||
const base = this.canResume && this.resumeGatewayUrl ? this.resumeGatewayUrl : DEFAULT_GATEWAY;
|
||||
const url = `${base}${GATEWAY_QUERY}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
// Attach handlers BEFORE assigning to this.ws to avoid a race where events
|
||||
// fire between construction and handler attachment.
|
||||
ws.on("message", (data: WebSocket.RawData) => {
|
||||
this.handleFrame(data);
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.warn(`Agent Fleet: Discord WebSocket error on ${this.config.name}`, err);
|
||||
});
|
||||
|
||||
ws.on("close", (code: number) => {
|
||||
this.ws = null;
|
||||
this.clearHeartbeat();
|
||||
this.handleClose(code);
|
||||
});
|
||||
|
||||
this.ws = ws;
|
||||
|
||||
// Connection timeout — if we don't get HELLO within 30s, tear down and retry.
|
||||
const connectTimeout = window.setTimeout(() => {
|
||||
if (this.status === "connecting" || this.status === "reconnecting") {
|
||||
console.warn(`Agent Fleet: Discord WebSocket connect timeout on ${this.config.name}`);
|
||||
try { ws.close(); } catch { /* best-effort */ }
|
||||
}
|
||||
}, 30_000);
|
||||
ws.on("close", () => window.clearTimeout(connectTimeout));
|
||||
const statusUnsub = this.onStatusChange((status) => {
|
||||
if (status === "connected") {
|
||||
window.clearTimeout(connectTimeout);
|
||||
statusUnsub();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Agent Fleet: Discord WebSocket open failed", err);
|
||||
this.setStatus("error");
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(code: number): void {
|
||||
// Fatal codes won't recover by retrying — they need the user to fix the app
|
||||
// config (bad token / disallowed intents). Auto-reconnecting every ≤30s with a
|
||||
// known-bad IDENTIFY just wastes cycles and risks Discord rate-limiting the IP,
|
||||
// so we surface needs-auth and STOP. Recovery happens when the channel restarts
|
||||
// (Obsidian reload, or a connection-relevant config edit re-creates the adapter).
|
||||
// 4004 authentication failed · 4013 invalid intents · 4014 disallowed intents
|
||||
let fatal = false;
|
||||
if (code === 4014) {
|
||||
console.error(
|
||||
`Agent Fleet: Discord channel ${this.config.name} — disallowed intents (4014). ` +
|
||||
"Enable the Message Content intent in the Developer Portal → your app → Bot → Privileged Gateway Intents, then reload.",
|
||||
);
|
||||
this.canResume = false;
|
||||
this.setStatus("needs-auth");
|
||||
fatal = true;
|
||||
} else if (code === 4004 || code === 4013) {
|
||||
console.error(
|
||||
`Agent Fleet: Discord channel ${this.config.name} — gateway auth/intents error (${code}). ` +
|
||||
"Check the bot token credential, then reload.",
|
||||
);
|
||||
this.canResume = false;
|
||||
this.setStatus("needs-auth");
|
||||
fatal = true;
|
||||
} else if (code === 4007 || code === 4009) {
|
||||
// Invalid seq / session timed out — reconnect fresh (no resume).
|
||||
this.canResume = false;
|
||||
}
|
||||
if (!this.stopping && !fatal) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleFrame(data: WebSocket.RawData): void {
|
||||
let frame: GatewayFrame;
|
||||
try {
|
||||
frame = JSON.parse(data.toString()) as GatewayFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof frame.s === "number") this.seq = frame.s;
|
||||
|
||||
switch (frame.op) {
|
||||
case OP_HELLO: {
|
||||
const interval = (frame.d as { heartbeat_interval?: number } | undefined)?.heartbeat_interval;
|
||||
this.startHeartbeat(interval ?? 41_250);
|
||||
if (this.canResume && this.sessionId && this.seq !== null) {
|
||||
this.sendGateway(OP_RESUME, {
|
||||
token: this.credential.botToken,
|
||||
session_id: this.sessionId,
|
||||
seq: this.seq,
|
||||
});
|
||||
} else {
|
||||
this.identify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case OP_HEARTBEAT: {
|
||||
// Server requested an immediate heartbeat.
|
||||
this.sendGateway(OP_HEARTBEAT, this.seq);
|
||||
return;
|
||||
}
|
||||
case OP_HEARTBEAT_ACK: {
|
||||
this.heartbeatAcked = true;
|
||||
return;
|
||||
}
|
||||
case OP_RECONNECT: {
|
||||
// Server asked us to reconnect — keep the session so we can RESUME.
|
||||
try { this.ws?.close(); } catch { /* best-effort */ }
|
||||
return;
|
||||
}
|
||||
case OP_INVALID_SESSION: {
|
||||
// d is a boolean: whether the session is resumable.
|
||||
const resumable = frame.d === true;
|
||||
if (!resumable) {
|
||||
this.canResume = false;
|
||||
this.sessionId = null;
|
||||
}
|
||||
// Discord asks for a short random delay (1–5s) before re-identifying.
|
||||
window.setTimeout(() => {
|
||||
try { this.ws?.close(); } catch { /* best-effort */ }
|
||||
}, 1000 + Math.floor(Math.random() * 4000));
|
||||
return;
|
||||
}
|
||||
case OP_DISPATCH: {
|
||||
this.handleDispatch(frame.t ?? "", frame.d);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDispatch(type: string, d: unknown): void {
|
||||
if (type === "READY") {
|
||||
const ready = d as {
|
||||
session_id?: string;
|
||||
resume_gateway_url?: string;
|
||||
user?: { id?: string };
|
||||
application?: { id?: string };
|
||||
};
|
||||
this.sessionId = ready.session_id ?? null;
|
||||
this.resumeGatewayUrl = ready.resume_gateway_url ?? null;
|
||||
this.selfUserId = ready.user?.id ?? null;
|
||||
this.applicationId = ready.application?.id ?? null;
|
||||
this.canResume = true;
|
||||
this.backoffMs = 1000;
|
||||
this.setStatus("connected");
|
||||
void this.registerAgentsCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "RESUMED") {
|
||||
this.backoffMs = 1000;
|
||||
this.setStatus("connected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "MESSAGE_CREATE") {
|
||||
this.routeMessage(d as DiscordMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "INTERACTION_CREATE") {
|
||||
void this.handleInteraction(d as DiscordInteraction);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private identify(): void {
|
||||
this.sendGateway(OP_IDENTIFY, {
|
||||
token: this.credential.botToken,
|
||||
intents: INTENTS,
|
||||
properties: { os: "linux", browser: "agent-fleet", device: "agent-fleet" },
|
||||
});
|
||||
}
|
||||
|
||||
private sendGateway(op: number, d: unknown): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ op, d }));
|
||||
} catch (err) {
|
||||
console.warn("Agent Fleet: Discord gateway send failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(intervalMs: number): void {
|
||||
this.clearHeartbeat();
|
||||
this.heartbeatAcked = true;
|
||||
const beat = () => {
|
||||
if (!this.heartbeatAcked) {
|
||||
// Zombie connection — no ACK since the last beat. Reconnect (resume).
|
||||
console.warn(`Agent Fleet: Discord heartbeat not ACKed on ${this.config.name} — reconnecting`);
|
||||
try { this.ws?.close(); } catch { /* best-effort */ }
|
||||
return;
|
||||
}
|
||||
this.heartbeatAcked = false;
|
||||
this.sendGateway(OP_HEARTBEAT, this.seq);
|
||||
};
|
||||
// First beat after interval * jitter (per Discord docs), then every interval.
|
||||
this.heartbeatInitialTimer = window.setTimeout(() => {
|
||||
beat();
|
||||
this.heartbeatTimer = window.setInterval(beat, intervalMs);
|
||||
}, Math.floor(intervalMs * Math.random()));
|
||||
}
|
||||
|
||||
private clearHeartbeat(): void {
|
||||
if (this.heartbeatInitialTimer) {
|
||||
window.clearTimeout(this.heartbeatInitialTimer);
|
||||
this.heartbeatInitialTimer = null;
|
||||
}
|
||||
if (this.heartbeatTimer) {
|
||||
window.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.stopping) return;
|
||||
if (this.reconnectTimer) return;
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = Math.min(30_000, this.backoffMs * 2);
|
||||
console.warn(`Agent Fleet: Discord channel ${this.config.name} scheduling reconnect in ${delay}ms`);
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
if (this.stopping) return;
|
||||
void this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Inbound messages
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private routeMessage(message: DiscordMessage): void {
|
||||
if (!message.author || message.author.bot) return;
|
||||
if (this.selfUserId && message.author.id === this.selfUserId) return;
|
||||
if (!message.channel_id) return;
|
||||
|
||||
const imageAttachments = (message.attachments ?? []).filter(
|
||||
(a) => typeof a.content_type === "string" && a.content_type.startsWith("image/"),
|
||||
);
|
||||
|
||||
let text = stripLeadingMention(message.content ?? "", this.selfUserId);
|
||||
|
||||
// The agent prefix / routing happens in ChannelManager. Empty content with no
|
||||
// attachments usually means the privileged Message Content intent is off.
|
||||
if (!text && imageAttachments.length === 0) {
|
||||
if (!this.warnedEmptyContent) {
|
||||
this.warnedEmptyContent = true;
|
||||
console.warn(
|
||||
`Agent Fleet: Discord channel ${this.config.name} received a message with empty content. ` +
|
||||
"If this persists, enable the Message Content intent in the Developer Portal.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationId = buildConversationId(message.guild_id, message.channel_id);
|
||||
void this.buildAndEmitMessage(message, text, conversationId, imageAttachments);
|
||||
}
|
||||
|
||||
private async buildAndEmitMessage(
|
||||
message: DiscordMessage,
|
||||
text: string,
|
||||
conversationId: string,
|
||||
imageAttachments: DiscordAttachment[],
|
||||
): Promise<void> {
|
||||
const images: InboundImage[] = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
try {
|
||||
const resp = await requestUrl({ url: attachment.url, method: "GET" });
|
||||
images.push({
|
||||
data: resp.arrayBuffer,
|
||||
filename: attachment.filename || `attachment_${message.id}`,
|
||||
mimeType: attachment.content_type ?? "image/jpeg",
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Agent Fleet: Discord attachment download failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
const msg: InboundMessage = {
|
||||
conversationId,
|
||||
externalUserId: message.author!.id,
|
||||
text,
|
||||
timestamp: message.timestamp ?? new Date().toISOString(),
|
||||
meta: {
|
||||
discord_guild_id: message.guild_id,
|
||||
discord_channel_id: message.channel_id,
|
||||
discord_message_id: message.id,
|
||||
is_dm: !message.guild_id,
|
||||
},
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
};
|
||||
|
||||
for (const handler of this.inboundHandlers) {
|
||||
try {
|
||||
handler(msg);
|
||||
} catch (err) {
|
||||
console.error("Agent Fleet: Discord inbound handler threw", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// /agents slash command + buttons
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private async registerAgentsCommand(): Promise<void> {
|
||||
if (this.commandRegistered || !this.applicationId) return;
|
||||
try {
|
||||
await this.discordApi("PUT", `/applications/${this.applicationId}/commands`, [
|
||||
{ name: "agents", description: "Switch the active agent", type: 1 },
|
||||
]);
|
||||
this.commandRegistered = true;
|
||||
} catch (err) {
|
||||
// Best-effort — global command registration can take up to ~1h to propagate.
|
||||
console.warn(`Agent Fleet: Discord /agents command registration failed on ${this.config.name}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInteraction(interaction: DiscordInteraction): Promise<void> {
|
||||
if (interaction.type === INTERACTION_APPLICATION_COMMAND) {
|
||||
if (interaction.data?.name === "agents") {
|
||||
await this.respondWithAgentPicker(interaction);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.type === INTERACTION_MESSAGE_COMPONENT) {
|
||||
const customId = interaction.data?.custom_id ?? "";
|
||||
if (!customId.startsWith("switch:")) return;
|
||||
const agentName = customId.slice("switch:".length);
|
||||
const userId = interaction.member?.user?.id ?? interaction.user?.id;
|
||||
const channelId = interaction.channel_id;
|
||||
if (!userId || !channelId) return;
|
||||
const conversationId = buildConversationId(interaction.guild_id, channelId);
|
||||
|
||||
// Notify ChannelManager of the binding change.
|
||||
for (const handler of this.agentSwitchHandlers) {
|
||||
try {
|
||||
handler(conversationId, agentName, userId);
|
||||
} catch (err) {
|
||||
console.error("Agent Fleet: Discord agent switch handler threw", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge by updating the original message to show the selection.
|
||||
await this.respondToInteraction(interaction, CALLBACK_UPDATE_MESSAGE, {
|
||||
content: `Active agent: **${agentName}**`,
|
||||
components: this.buildAgentButtons(agentName),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async respondWithAgentPicker(interaction: DiscordInteraction): Promise<void> {
|
||||
const agents = this.pickerAgents();
|
||||
if (agents.length === 0) {
|
||||
await this.respondToInteraction(interaction, CALLBACK_CHANNEL_MESSAGE, {
|
||||
content: "No agents available. Add existing, enabled agents to `allowed_agents` in the channel file.",
|
||||
flags: FLAG_EPHEMERAL,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.respondToInteraction(interaction, CALLBACK_CHANNEL_MESSAGE, {
|
||||
content: "Select an agent to chat with:",
|
||||
flags: FLAG_EPHEMERAL,
|
||||
components: this.buildAgentButtons(this.config.defaultAgent),
|
||||
});
|
||||
}
|
||||
|
||||
/** Build action-row button components (≤5 buttons/row, ≤5 rows = 25 agents). */
|
||||
private buildAgentButtons(activeAgent: string): unknown[] {
|
||||
const agents = this.pickerAgents().slice(0, 25);
|
||||
const rows: unknown[] = [];
|
||||
for (let i = 0; i < agents.length; i += 5) {
|
||||
const slice = agents.slice(i, i + 5);
|
||||
rows.push({
|
||||
type: 1,
|
||||
components: slice.map((name) => ({
|
||||
type: 2,
|
||||
// style 1 = primary (active), 2 = secondary
|
||||
style: name === activeAgent ? 1 : 2,
|
||||
label: name === activeAgent ? `${name} ✓` : name,
|
||||
custom_id: `switch:${name}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async respondToInteraction(
|
||||
interaction: DiscordInteraction,
|
||||
callbackType: number,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.discordApi(
|
||||
"POST",
|
||||
`/interactions/${interaction.id}/${interaction.token}/callback`,
|
||||
{ type: callbackType, data },
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("Agent Fleet: Discord interaction response failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Discord REST API + per-channel send queue
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
private async discordApi<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const res = await requestUrl({
|
||||
url: `${DISCORD_API}${path}`,
|
||||
method,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
Authorization: `Bot ${this.credential.botToken}`,
|
||||
"User-Agent": DISCORD_USER_AGENT,
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
// Handle statuses ourselves so we can honor retry_after on 429.
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
const retryAfter = (res.json as { retry_after?: number } | undefined)?.retry_after
|
||||
?? Number(res.headers["retry-after"] ?? "1");
|
||||
await new Promise((r) => window.setTimeout(r, Math.max(1000, retryAfter * 1000)));
|
||||
return this.discordApi<T>(method, path, body);
|
||||
}
|
||||
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
// Surface Discord's real reason (body carries `message` + numeric `code`,
|
||||
// e.g. 50001 Missing Access, 50007 Cannot send messages to this user,
|
||||
// 0 401:Unauthorized). Without it a 403 is unactionable.
|
||||
throw new Error(`Discord ${method} ${path} ${res.status}: ${describeDiscordError(res.text)}`);
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Discord ${method} ${path} HTTP ${res.status}: ${res.text}`);
|
||||
}
|
||||
|
||||
// 204 No Content (e.g. typing, interaction callbacks) has no JSON body.
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json as T;
|
||||
}
|
||||
|
||||
/** Serialize sends per Discord channel id so a chunked reply stays under the
|
||||
* per-channel rate limit. */
|
||||
private async enqueueSend(channel: string, fn: () => Promise<void>): Promise<void> {
|
||||
const prev = this.sendQueues.get(channel) ?? Promise.resolve();
|
||||
const next = prev.then(fn);
|
||||
// `wrapped` keeps the per-channel chain alive (never rejects) so a failed send
|
||||
// doesn't poison the next one. But the CALLER must still see this send's failure
|
||||
// — otherwise delivery errors are invisible (the dashboard counts them as sent
|
||||
// and the channel status never reflects them). So await `next`, not `wrapped`.
|
||||
const wrapped = next.catch(() => { /* keep chain unrejected for the next send */ });
|
||||
this.sendQueues.set(channel, wrapped);
|
||||
try {
|
||||
await next;
|
||||
} finally {
|
||||
if (this.sendQueues.get(channel) === wrapped) {
|
||||
this.sendQueues.delete(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(next: ChannelStatus): void {
|
||||
if (this.status === next) return;
|
||||
this.status = next;
|
||||
for (const h of this.statusHandlers) {
|
||||
try { h(next); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Discord types (minimal subset)
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
interface GatewayFrame {
|
||||
op: number;
|
||||
d?: unknown;
|
||||
s?: number | null;
|
||||
t?: string | null;
|
||||
}
|
||||
|
||||
interface DiscordAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
content_type?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface DiscordMessage {
|
||||
id: string;
|
||||
channel_id?: string;
|
||||
guild_id?: string;
|
||||
author?: { id: string; bot?: boolean; username?: string };
|
||||
content?: string;
|
||||
timestamp?: string;
|
||||
attachments?: DiscordAttachment[];
|
||||
}
|
||||
|
||||
interface DiscordInteraction {
|
||||
id: string;
|
||||
token: string;
|
||||
type: number;
|
||||
channel_id?: string;
|
||||
guild_id?: string;
|
||||
member?: { user?: { id: string } };
|
||||
user?: { id: string };
|
||||
data?: { name?: string; custom_id?: string; component_type?: number };
|
||||
message?: { id: string };
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Conversation id shape:
|
||||
* guild message / thread: `discord:<guild_id>:<channel_id>`
|
||||
* DM: `discord:dm:<channel_id>`
|
||||
* A thread is just another channel_id, so per-thread isolation is automatic.
|
||||
* The channel id is always the third segment in both forms.
|
||||
*/
|
||||
export function buildConversationId(guildId: string | undefined, channelId: string): string {
|
||||
return guildId ? `discord:${guildId}:${channelId}` : `discord:dm:${channelId}`;
|
||||
}
|
||||
|
||||
export function channelIdFromConversationId(conversationId: string): string | null {
|
||||
const parts = conversationId.split(":");
|
||||
if (parts[0] !== "discord") return null;
|
||||
return parts[2] ?? null;
|
||||
}
|
||||
|
||||
/** Strip a leading bot mention (`<@id>` or `<@!id>`) so the agent sees clean text. */
|
||||
export function stripLeadingMention(content: string, selfUserId: string | null): string {
|
||||
let text = content.trimStart();
|
||||
if (selfUserId) {
|
||||
const mention = new RegExp(`^<@!?${selfUserId}>\\s*`);
|
||||
text = text.replace(mention, "");
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
/** Extract Discord's human-readable error (`message` + `code`) from a response
|
||||
* body, falling back to the raw text. Discord error bodies are JSON like
|
||||
* `{"message":"Missing Access","code":50001}`. */
|
||||
export function describeDiscordError(rawBody: string | undefined): string {
|
||||
const raw = rawBody ?? "";
|
||||
try {
|
||||
const j = JSON.parse(raw) as { message?: string; code?: number };
|
||||
if (j && (j.message !== undefined || j.code !== undefined)) {
|
||||
return `${j.message ?? "error"} (code ${j.code ?? "?"})`;
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to raw
|
||||
}
|
||||
return raw || "no body";
|
||||
}
|
||||
|
||||
function splitText(text: string, limit: number): string[] {
|
||||
if (text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
let cutAt = remaining.lastIndexOf("\n\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = remaining.lastIndexOf("\n", limit);
|
||||
if (cutAt < limit / 2) cutAt = limit;
|
||||
chunks.push(remaining.slice(0, cutAt));
|
||||
remaining = remaining.slice(cutAt).replace(/^\n+/, "");
|
||||
}
|
||||
if (remaining) chunks.push(remaining);
|
||||
return chunks;
|
||||
}
|
||||
|
|
@ -194,6 +194,15 @@ export class SlackAdapter implements ChannelAdapter {
|
|||
});
|
||||
}
|
||||
|
||||
/** Post directly to a Slack channel id (the bot must be a member). */
|
||||
async sendToTarget(channelId: string, text: string): Promise<void> {
|
||||
if (!channelId) return;
|
||||
const mrkdwn = markdownToMrkdwn(text);
|
||||
await this.enqueueSend(channelId, async () => {
|
||||
await this.slackApi("chat.postMessage", { channel: channelId, text: mrkdwn });
|
||||
});
|
||||
}
|
||||
|
||||
async broadcast(text: string): Promise<void> {
|
||||
// Post to the first allowed user's DM. Open the DM channel first via
|
||||
// conversations.open, then post with chat.postMessage.
|
||||
|
|
|
|||
|
|
@ -135,6 +135,19 @@ export class TelegramAdapter implements ChannelAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
/** Post directly to a Telegram chat id (user, group, or @channelusername). */
|
||||
async sendToTarget(chatId: string, text: string): Promise<void> {
|
||||
if (!chatId) return;
|
||||
const chunks = splitText(text, 4096);
|
||||
for (const chunk of chunks) {
|
||||
await this.tgApi("sendMessage", {
|
||||
chat_id: chatId,
|
||||
text: chunk,
|
||||
parse_mode: "Markdown",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(conversationId: string, on: boolean): Promise<void> {
|
||||
const chatId = chatIdFromConversationId(conversationId);
|
||||
if (!chatId) return;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { type ChildProcess } from "child_process";
|
|||
import { createHash, randomUUID } from "crypto";
|
||||
import { normalizePath, TFile } from "obsidian";
|
||||
import type { Vault } from "obsidian";
|
||||
import type { AgentConfig, ChatMessage, FleetSettings } from "../types";
|
||||
import type { AgentConfig, ChatMessage, FleetSettings, UsageRecord } from "../types";
|
||||
import type { FleetRepository } from "../fleetRepository";
|
||||
import { slugify } from "../utils/markdown";
|
||||
import { resolveModel, shouldPassModelFlag } from "./../utils/modelResolution";
|
||||
|
|
@ -320,6 +320,14 @@ export class ChatSession {
|
|||
|
||||
// Stats (derived from stream-json events)
|
||||
private stats: ChatSessionStats = { costTotalUsd: 0, turnCount: 0 };
|
||||
/** Sink for per-turn token/cost records (the usage ledger). Set by the plugin. */
|
||||
private usageRecorder?: (record: UsageRecord) => void;
|
||||
|
||||
/** Wire the per-turn usage ledger sink. Called once after construction by the
|
||||
* plugin for both chat-panel and channel sessions. */
|
||||
setUsageRecorder(fn: (record: UsageRecord) => void): void {
|
||||
this.usageRecorder = fn;
|
||||
}
|
||||
private statsListeners = new Set<(s: ChatSessionStats) => void>();
|
||||
|
||||
/** Subscribe to stats changes. Fires once immediately with current state. */
|
||||
|
|
@ -1008,6 +1016,9 @@ export class ChatSession {
|
|||
}
|
||||
this.stats.turnCount += 1;
|
||||
dirty = true;
|
||||
// Ledger this turn's tokens + cost so chat/channel usage counts toward the
|
||||
// dashboard totals (runs are counted separately via their run logs).
|
||||
this.recordTurnUsage(event as unknown as Record<string, unknown>, costDelta);
|
||||
}
|
||||
|
||||
// After digesting a result event, evaluate the auto-compact threshold.
|
||||
|
|
@ -1021,6 +1032,33 @@ export class ChatSession {
|
|||
if (dirty) this.emitStats();
|
||||
}
|
||||
|
||||
/** Emit one usage-ledger record for the just-completed turn. Token breakdown
|
||||
* comes from the result event's aggregate `usage`; cost is the CLI's per-turn
|
||||
* `total_cost_usd` when present (else estimated from tokens at read time). */
|
||||
private recordTurnUsage(event: Record<string, unknown>, costUsd: number): void {
|
||||
if (!this.usageRecorder) return;
|
||||
const usage = event.usage as Record<string, unknown> | undefined;
|
||||
const num = (v: unknown) => (typeof v === "number" ? v : 0);
|
||||
const inputTokens = num(usage?.input_tokens);
|
||||
const outputTokens = num(usage?.output_tokens);
|
||||
const cacheReadTokens = num(usage?.cache_read_input_tokens);
|
||||
const cacheCreateTokens = num(usage?.cache_creation_input_tokens);
|
||||
const totalTokens = inputTokens + outputTokens + cacheReadTokens + cacheCreateTokens;
|
||||
if (totalTokens === 0 && costUsd === 0) return;
|
||||
this.usageRecorder({
|
||||
ts: new Date().toISOString(),
|
||||
agent: this.agent.name,
|
||||
source: this.channelName ? "channel" : "chat",
|
||||
model: this.stats.concreteModel ?? this.agent.model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens,
|
||||
cacheCreateTokens,
|
||||
totalTokens,
|
||||
...(costUsd > 0 ? { costUsd } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** If the agent has autoCompactThreshold set and the latest turn's
|
||||
* context fraction is above it, queue a /compact for the next user turn.
|
||||
* Rate-limited to once per 30s to avoid loops if the CLI reports odd usage. */
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type {
|
|||
SkillCandidate,
|
||||
SkillProposal,
|
||||
TaskConfig,
|
||||
UsageRecord,
|
||||
} from "../types";
|
||||
|
||||
export class FleetRuntime {
|
||||
|
|
@ -47,6 +48,9 @@ export class FleetRuntime {
|
|||
* for days that actually had activity. */
|
||||
private chartRuns: RunLogData[] = [];
|
||||
private static readonly CHART_WINDOW_DAYS = 14;
|
||||
/** Chat/channel usage records over the chart window. Feeds the comprehensive
|
||||
* token/cost totals (runs are counted from run logs). */
|
||||
private recentUsage: UsageRecord[] = [];
|
||||
private statusChangeListeners = new Set<() => void>();
|
||||
private runOutputListeners = new Map<string, Set<(chunk: string) => void>>();
|
||||
private runOutputBuffers = new Map<string, string>();
|
||||
|
|
@ -64,8 +68,11 @@ export class FleetRuntime {
|
|||
private heartbeatRegisteredAt = 0;
|
||||
/** Tracks agents with a heartbeat currently in-flight (prevents duplicate runs). */
|
||||
private heartbeatsInFlight = new Set<string>();
|
||||
/** Callback for heartbeat result delivery (e.g. Slack posting). Set by main.ts. */
|
||||
private heartbeatResultHandler?: (agentName: string, channel: string, output: string) => void;
|
||||
/** Callback for run-result channel delivery (e.g. Slack/Discord posting). Set by
|
||||
* main.ts. `source` is "heartbeat" for heartbeat runs or the task id otherwise,
|
||||
* so the handler can label the post. `target` is an optional transport-native
|
||||
* destination id (post to a specific channel) — empty means broadcast/DM. */
|
||||
private channelResultHandler?: (agentName: string, channel: string, output: string, source: string, target: string) => void;
|
||||
/** Single choke point for memory writes (per-agent locked). */
|
||||
private readonly memoryWriter: MemoryWriter;
|
||||
|
||||
|
|
@ -105,11 +112,13 @@ export class FleetRuntime {
|
|||
}
|
||||
|
||||
/**
|
||||
* Register a callback that receives heartbeat results for delivery to external
|
||||
* channels (e.g. Slack). Called from main.ts after the ChannelManager is ready.
|
||||
* Register a callback that receives run results for delivery to external
|
||||
* channels (e.g. Slack, Discord). Fires for heartbeat runs (using the agent's
|
||||
* heartbeatChannel) and for any scheduled/manual task that sets a `channel`
|
||||
* field. Called from main.ts after the ChannelManager is ready.
|
||||
*/
|
||||
onHeartbeatResult(handler: (agentName: string, channel: string, output: string) => void): void {
|
||||
this.heartbeatResultHandler = handler;
|
||||
onChannelResult(handler: (agentName: string, channel: string, output: string, source: string, target: string) => void): void {
|
||||
this.channelResultHandler = handler;
|
||||
}
|
||||
|
||||
async refreshFromVault(): Promise<void> {
|
||||
|
|
@ -139,6 +148,22 @@ export class FleetRuntime {
|
|||
const since = new Date();
|
||||
since.setDate(since.getDate() - (FleetRuntime.CHART_WINDOW_DAYS - 1));
|
||||
this.chartRuns = await this.repository.listRunsSince(since);
|
||||
this.recentUsage = await this.repository.readUsageSince(since);
|
||||
}
|
||||
|
||||
/** Chat/channel usage records over the chart window (for token/cost totals). */
|
||||
getUsageRecords(): UsageRecord[] {
|
||||
return this.recentUsage;
|
||||
}
|
||||
|
||||
/** Append a chat/channel turn's usage to the ledger and reflect it live in the
|
||||
* cached totals. Fire-and-forget from ChatSession via the plugin. */
|
||||
recordUsage(record: UsageRecord): void {
|
||||
this.recentUsage.push(record);
|
||||
void this.repository.appendUsage(record).catch((err) => {
|
||||
console.warn("Agent Fleet: failed to append usage record", err);
|
||||
});
|
||||
this.emitStatusChange();
|
||||
}
|
||||
|
||||
getAgentState(agentName: string): AgentRuntimeState {
|
||||
|
|
@ -523,7 +548,18 @@ export class FleetRuntime {
|
|||
return { ok: false, message: "A reflection is already in progress." };
|
||||
}
|
||||
this.reflectionsInFlight.add(agentName);
|
||||
const nowIso = new Date().toISOString();
|
||||
const started = new Date().toISOString();
|
||||
const nowIso = started;
|
||||
const reflectionTaskId = `reflection-${Date.now()}`;
|
||||
// Surface the reflection as a running agent (overview "Active Agents" card +
|
||||
// sidebar status) with live output, exactly like a task/heartbeat run.
|
||||
this.runtimeState.set(agentName, {
|
||||
status: "running",
|
||||
currentTaskId: reflectionTaskId,
|
||||
runStarted: started,
|
||||
});
|
||||
this.runOutputBuffers.set(agentName, "");
|
||||
this.emitStatusChange();
|
||||
try {
|
||||
// Guarantee the legacy→v2 migration (and its raw-archive seeding) has run
|
||||
// before we read/consolidate, so a first reflection can't lose pre-v2
|
||||
|
|
@ -541,7 +577,7 @@ export class FleetRuntime {
|
|||
|
||||
const task: TaskConfig = {
|
||||
filePath: "",
|
||||
taskId: `reflection-${Date.now()}`,
|
||||
taskId: reflectionTaskId,
|
||||
agent: agent.name,
|
||||
type: "immediate",
|
||||
priority: "low",
|
||||
|
|
@ -556,9 +592,17 @@ export class FleetRuntime {
|
|||
|
||||
// Gate the spawn through the reflection semaphore, and suppress memory
|
||||
// capture for the reflection run itself (it consolidates memory; it must
|
||||
// not emit new captures or carry the capture instruction).
|
||||
// not emit new captures or carry the capture instruction). Stream output so
|
||||
// the overview shows live progress.
|
||||
const result = await this.withReflectionSlot(() =>
|
||||
this.executor.execute(agent, task, prompt, undefined, { suppressMemoryCapture: true }),
|
||||
this.executor.execute(agent, task, prompt, (chunk) => {
|
||||
const current = this.runOutputBuffers.get(agentName) ?? "";
|
||||
this.runOutputBuffers.set(agentName, current + chunk);
|
||||
const listeners = this.runOutputListeners.get(agentName);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) listener(chunk);
|
||||
}
|
||||
}, { suppressMemoryCapture: true }),
|
||||
);
|
||||
const parsed = parseReflectionOutput(result.outputText);
|
||||
|
||||
|
|
@ -572,20 +616,79 @@ export class FleetRuntime {
|
|||
await this.generateProposals(agent, merged, nowIso);
|
||||
}
|
||||
|
||||
const message = applied
|
||||
? "Reflection complete — working memory consolidated."
|
||||
: "Reflection produced no memory block; working memory left unchanged.";
|
||||
|
||||
// Record the reflection as a run so it shows in Recent Activity / run
|
||||
// history with its full output — the user can confirm it ran and see what
|
||||
// it consolidated. A clean CLI exit is "success" even if nothing changed;
|
||||
// `finalResult` carries the consolidation outcome.
|
||||
const runStatus: RunStatus =
|
||||
result.exitCode === 0 || result.exitCode === null ? "success" : "failure";
|
||||
const run: RunLogData = {
|
||||
runId: result.runId,
|
||||
agent: agent.name,
|
||||
task: reflectionTaskId,
|
||||
status: runStatus,
|
||||
started,
|
||||
completed: new Date().toISOString(),
|
||||
durationSeconds: result.durationSeconds,
|
||||
tokensUsed: result.tokensUsed,
|
||||
costUsd: result.costUsd,
|
||||
model: result.resolvedModel || agent.reflection.model || agent.model,
|
||||
modelSource: result.modelSource,
|
||||
concreteModel: result.concreteModel,
|
||||
exitCode: result.exitCode,
|
||||
tags: Array.from(new Set([...agent.tags, "reflection"])),
|
||||
prompt: result.prompt,
|
||||
output: result.outputText,
|
||||
toolsUsed: result.toolsUsed.map((tool) => `${tool.tool}${tool.command ? `: ${tool.command}` : ""}`),
|
||||
finalResult: message,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
const runPath = await this.repository.writeRunLog(run);
|
||||
await this.refreshRunCaches();
|
||||
this.emitStatusChange();
|
||||
return applied
|
||||
? { ok: true, message: "Reflection complete — working memory consolidated." }
|
||||
: {
|
||||
ok: false,
|
||||
message: "Reflection produced no memory block; working memory left unchanged.",
|
||||
};
|
||||
this.runtimeState.set(agentName, {
|
||||
status: runStatus === "success" ? "idle" : "error",
|
||||
currentRunId: result.runId,
|
||||
lastRun: { ...run, filePath: runPath },
|
||||
});
|
||||
return { ok: applied, message };
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Agent Fleet: reflection failed for "${agentName}":`, error);
|
||||
// Record the failure as a run too, so it's visible in activity.
|
||||
const run: RunLogData = {
|
||||
runId: randomUUID(),
|
||||
agent: agentName,
|
||||
task: reflectionTaskId,
|
||||
status: "failure",
|
||||
started,
|
||||
completed: new Date().toISOString(),
|
||||
durationSeconds: Math.round((Date.now() - new Date(started).getTime()) / 1000),
|
||||
model: agent.reflection.model || agent.model,
|
||||
exitCode: 1,
|
||||
tags: Array.from(new Set([...agent.tags, "reflection"])),
|
||||
prompt: "",
|
||||
output: `Reflection failed: ${msg}`,
|
||||
toolsUsed: [],
|
||||
};
|
||||
let lastRun: RunLogData = run;
|
||||
try {
|
||||
const runPath = await this.repository.writeRunLog(run);
|
||||
await this.refreshRunCaches();
|
||||
lastRun = { ...run, filePath: runPath };
|
||||
} catch (writeErr) {
|
||||
console.warn(`Agent Fleet: failed to write reflection run log for "${agentName}"`, writeErr);
|
||||
}
|
||||
this.runtimeState.set(agentName, { status: "error", lastRun });
|
||||
return { ok: false, message: `Reflection failed: ${msg}` };
|
||||
} finally {
|
||||
this.reflectionsInFlight.delete(agentName);
|
||||
this.runOutputBuffers.delete(agentName);
|
||||
this.runOutputListeners.delete(agentName);
|
||||
this.emitStatusChange();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -693,13 +796,29 @@ export class FleetRuntime {
|
|||
}
|
||||
}
|
||||
|
||||
// Post heartbeat results to Slack channel if configured
|
||||
// Post run results to a channel if configured. Heartbeat runs use the
|
||||
// agent's heartbeatChannel; regular scheduled/manual tasks use their own
|
||||
// `channel` field. This lets any task — not just the heartbeat — deliver
|
||||
// its output to Slack/Discord/Telegram.
|
||||
const isHeartbeatRun = task.tags.includes("heartbeat");
|
||||
if (isHeartbeatRun && !wasAborted && agent.heartbeatChannel && run.output.trim()) {
|
||||
const deliveryChannel = isHeartbeatRun ? agent.heartbeatChannel : (task.channel ?? "");
|
||||
// Both heartbeats and tasks may post to a specific channel id via their
|
||||
// target field (HEARTBEAT.md `channel_target` / task `channel_target`);
|
||||
// an empty target falls back to broadcast (DM to the first allowed user).
|
||||
const deliveryTarget = isHeartbeatRun
|
||||
? (agent.heartbeatChannelTarget ?? "")
|
||||
: (task.channelTarget ?? "");
|
||||
if (deliveryChannel && !wasAborted && run.output.trim()) {
|
||||
try {
|
||||
this.heartbeatResultHandler?.(agent.name, agent.heartbeatChannel, run.output);
|
||||
this.channelResultHandler?.(
|
||||
agent.name,
|
||||
deliveryChannel,
|
||||
run.output,
|
||||
isHeartbeatRun ? "heartbeat" : task.taskId,
|
||||
deliveryTarget,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`Agent Fleet: heartbeat channel delivery failed for ${agent.name}`, err);
|
||||
console.warn(`Agent Fleet: channel delivery failed for ${agent.name}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -288,11 +288,13 @@ export class AgentFleetSettingTab extends PluginSettingTab {
|
|||
dropdown
|
||||
.addOption("slack", "Slack")
|
||||
.addOption("telegram", "Telegram")
|
||||
.addOption("discord", "Discord")
|
||||
.setValue("slack")
|
||||
.onChange((value) => {
|
||||
state.type = value;
|
||||
slackFields.setCssStyles({ display: value === "slack" ? "" : "none" });
|
||||
telegramFields.setCssStyles({ display: value === "telegram" ? "" : "none" });
|
||||
discordFields.setCssStyles({ display: value === "discord" ? "" : "none" });
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -330,6 +332,19 @@ export class AgentFleetSettingTab extends PluginSettingTab {
|
|||
});
|
||||
});
|
||||
|
||||
// Discord-specific fields
|
||||
const discordFields = addForm.createDiv();
|
||||
discordFields.setCssStyles({ display: "none" });
|
||||
new Setting(discordFields)
|
||||
.setName("Bot token")
|
||||
.setDesc("From the Discord Developer Portal → your application → Bot → Reset Token. Enable the Message Content intent on the same page.")
|
||||
.addText((text) => {
|
||||
text.inputEl.type = "password";
|
||||
text.setPlaceholder("MTA...").onChange((value) => {
|
||||
state.botToken = value.trim();
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(addForm).addButton((button) =>
|
||||
button
|
||||
.setButtonText("Add credential")
|
||||
|
|
@ -342,6 +357,8 @@ export class AgentFleetSettingTab extends PluginSettingTab {
|
|||
let entry: ChannelCredentialEntry;
|
||||
if (state.type === "telegram") {
|
||||
entry = { type: "telegram", botToken: state.botToken };
|
||||
} else if (state.type === "discord") {
|
||||
entry = { type: "discord", botToken: state.botToken };
|
||||
} else {
|
||||
if (!state.appToken) {
|
||||
new Notice("Slack requires both bot token and app-level token.");
|
||||
|
|
|
|||
42
src/types.ts
42
src/types.ts
|
|
@ -44,7 +44,7 @@ export interface FleetSettings {
|
|||
mcpImported?: boolean;
|
||||
}
|
||||
|
||||
export type ChannelType = "slack" | "telegram";
|
||||
export type ChannelType = "slack" | "telegram" | "discord";
|
||||
|
||||
export type ChannelStatus =
|
||||
| "connected"
|
||||
|
|
@ -57,7 +57,8 @@ export type ChannelStatus =
|
|||
|
||||
export type ChannelCredentialEntry =
|
||||
| { type: "slack"; botToken: string; appToken: string }
|
||||
| { type: "telegram"; botToken: string };
|
||||
| { type: "telegram"; botToken: string }
|
||||
| { type: "discord"; botToken: string };
|
||||
|
||||
export interface ChannelConfig {
|
||||
filePath: string;
|
||||
|
|
@ -82,6 +83,29 @@ export interface ValidationIssue {
|
|||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One token-consuming event recorded to the usage ledger
|
||||
* (`_fleet/usage/YYYY-MM-DD.jsonl`). Chat and channel turns append these so their
|
||||
* token/cost are counted in the dashboard totals (runs/heartbeats/reflections
|
||||
* already carry tokens+cost in their run logs). `costUsd` is the CLI-reported
|
||||
* dollar cost when available; when absent it's estimated from tokens at read time.
|
||||
*/
|
||||
export interface UsageRecord {
|
||||
/** ISO-8601 timestamp of the turn. */
|
||||
ts: string;
|
||||
agent: string;
|
||||
/** Origin of the usage — only chat/channel are ledgered; runs live in run logs. */
|
||||
source: "chat" | "channel";
|
||||
model: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreateTokens: number;
|
||||
totalTokens: number;
|
||||
/** CLI-reported cost for this turn; undefined → estimate from tokens. */
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
export interface SkillConfig {
|
||||
filePath: string;
|
||||
name: string;
|
||||
|
|
@ -178,6 +202,10 @@ export interface AgentConfig {
|
|||
heartbeatNotify: boolean;
|
||||
/** Channel name to post heartbeat results to (e.g. "my-slack"). Empty = no channel post. */
|
||||
heartbeatChannel: string;
|
||||
/** Specific channel/conversation id to post heartbeat results to within
|
||||
* heartbeatChannel (e.g. a Discord channel id). Empty = broadcast as a DM to
|
||||
* the channel's first allowed user. Mirrors a task's channelTarget. */
|
||||
heartbeatChannelTarget: string;
|
||||
/** Wiki Keeper scope config. Present only on agents that manage a wiki
|
||||
* scope. See WIKI_KEEPER_DESIGN.md. */
|
||||
wikiKeeper?: WikiKeeperConfig;
|
||||
|
|
@ -206,6 +234,16 @@ export interface TaskConfig {
|
|||
effort?: string;
|
||||
/** Optional per-task model override. Empty/absent = inherit from agent. */
|
||||
model?: string;
|
||||
/** Channel name to post this task's output to (e.g. "my-discord"). Empty/absent
|
||||
* = no channel post (run log only). Mirrors an agent's heartbeatChannel but is
|
||||
* per-task, so scheduled tasks — not just the heartbeat — can deliver to a
|
||||
* channel. */
|
||||
channel?: string;
|
||||
/** Optional transport-native destination id within `channel` (Discord/Slack
|
||||
* channel id, Telegram chat id). When set, the task posts directly to that
|
||||
* channel/conversation; when empty, delivery falls back to the channel's
|
||||
* broadcast (DM to the first allowed user). Ignored unless `channel` is set. */
|
||||
channelTarget?: string;
|
||||
tags: string[];
|
||||
body: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: false,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,6 +212,18 @@ describe("memoryFormat", () => {
|
|||
expect(prefs?.entries.filter((e) => /post to #wiki/i.test(e.text))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("carryForwardPinnedPreferences trusts the model and does not re-add a reworded pin", () => {
|
||||
// The model UPDATED a pin (14 → 24) and emitted pins, so we trust its
|
||||
// consolidation rather than re-adding the byte-different stale version.
|
||||
let prev = emptyWorkingMemory("p", "A");
|
||||
prev = appendEntries(prev, [{ text: "warm-path priority: 14 shortlists", pinned: true }], "Preferences");
|
||||
const consolidated = parseSectionsFromBody("## Preferences\n- [pin] warm-path priority: 24 shortlists");
|
||||
const merged = carryForwardPinnedPreferences(prev, consolidated);
|
||||
const prefs = merged.find((s) => s.name === "Preferences");
|
||||
expect(prefs?.entries).toHaveLength(1);
|
||||
expect(prefs?.entries[0]?.text).toContain("24 shortlists");
|
||||
});
|
||||
|
||||
it("redactRememberForDisplay strips complete blocks and holds partial/unclosed tags", () => {
|
||||
// Complete block removed.
|
||||
expect(redactRememberForDisplay("a [REMEMBER] x [/REMEMBER] b")).toBe("a b");
|
||||
|
|
|
|||
|
|
@ -393,19 +393,23 @@ export function carryForwardPinnedPreferences(
|
|||
prev: WorkingMemory | null,
|
||||
sections: MemorySection[],
|
||||
): MemorySection[] {
|
||||
// Rescue pinned entries from ANY prior section (not just Preferences) — a
|
||||
// pin could have landed in Recent/Observations — so a reflection that omits
|
||||
// it can never silently drop a pinned fact.
|
||||
// Rescue pinned entries from ANY prior section (not just Preferences) — a pin
|
||||
// could have landed in Recent/Observations.
|
||||
const pins = (prev?.sections ?? [])
|
||||
.flatMap((s) => s.entries)
|
||||
.filter((e) => e.pinned);
|
||||
if (pins.length === 0) return sections;
|
||||
|
||||
const present = new Set(
|
||||
sections.flatMap((s) => s.entries).map((e) => e.text.trim().toLowerCase()),
|
||||
);
|
||||
const missing = pins.filter((e) => !present.has(e.text.trim().toLowerCase()));
|
||||
if (missing.length === 0) return sections;
|
||||
// The reflection model is explicitly instructed to preserve AND update pinned
|
||||
// facts, so a reworded/updated pin ("14 shortlists" → "24 shortlists") is the
|
||||
// model doing its job — not a drop. An earlier version re-added every prev pin
|
||||
// that wasn't byte-identical to the new output, which undid the model's
|
||||
// consolidation and piled up near-duplicate pins on every reflection. Instead,
|
||||
// trust the consolidated output whenever it contains ANY pinned entry, and only
|
||||
// rescue the previous pins when the reflection produced NONE at all — the real
|
||||
// signal that it dropped them rather than updating them.
|
||||
const newHasPins = sections.some((s) => s.entries.some((e) => e.pinned));
|
||||
if (newHasPins) return sections;
|
||||
|
||||
const out = sections.map((s) => ({ name: s.name, entries: [...s.entries] }));
|
||||
let prefs = out.find((s) => s.name === "Preferences");
|
||||
|
|
@ -413,7 +417,7 @@ export function carryForwardPinnedPreferences(
|
|||
prefs = { name: "Preferences", entries: [] };
|
||||
out.unshift(prefs);
|
||||
}
|
||||
prefs.entries.unshift(...missing);
|
||||
prefs.entries.unshift(...pins);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
37
src/utils/pricing.test.ts
Normal file
37
src/utils/pricing.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { estimateCostFromBreakdown, estimateCostFromTotal } from "./pricing";
|
||||
|
||||
describe("pricing", () => {
|
||||
it("prices a Claude breakdown using the model's per-type rates", () => {
|
||||
// opus: input $15, output $75, cacheRead $1.50 per 1M.
|
||||
const cost = estimateCostFromBreakdown("claude-opus-4-7", {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
cacheCreateTokens: 0,
|
||||
});
|
||||
expect(cost).toBeCloseTo(15 + 75 + 1.5, 5);
|
||||
});
|
||||
|
||||
it("matches family by substring (sonnet/haiku)", () => {
|
||||
const sonnet = estimateCostFromBreakdown("sonnet", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
|
||||
const haiku = estimateCostFromBreakdown("claude-haiku-4-5", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
|
||||
expect(sonnet).toBeCloseTo(3, 5);
|
||||
expect(haiku).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("falls back to a default rate for unknown models", () => {
|
||||
const cost = estimateCostFromBreakdown("some-unknown-model", { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
|
||||
expect(cost).toBeCloseTo(3, 5); // default = sonnet-ish input rate
|
||||
});
|
||||
|
||||
it("estimates from a total-token count with a blended rate", () => {
|
||||
// haiku blended = 0.7*1 + 0.3*5 = 2.2 per 1M.
|
||||
expect(estimateCostFromTotal("haiku", 1_000_000)).toBeCloseTo(2.2, 5);
|
||||
});
|
||||
|
||||
it("returns zero cost for zero tokens", () => {
|
||||
expect(estimateCostFromBreakdown("opus", { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 })).toBe(0);
|
||||
expect(estimateCostFromTotal("opus", 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
58
src/utils/pricing.ts
Normal file
58
src/utils/pricing.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Token → USD cost estimation, used as a fallback when the CLI doesn't report a
|
||||
// dollar cost (Codex agents, or any turn missing `total_cost_usd`). Claude runs
|
||||
// carry an exact CLI cost; this table only fills the gaps. Rates are USD per
|
||||
// MILLION tokens and will drift as vendors change pricing — update as needed.
|
||||
|
||||
interface ModelRates {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheWrite: number;
|
||||
cacheRead: number;
|
||||
}
|
||||
|
||||
/** Per-family rates (USD / 1M tokens). Matched by substring against the model id. */
|
||||
const RATE_TABLE: Array<{ match: RegExp; rates: ModelRates }> = [
|
||||
{ match: /opus/i, rates: { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
|
||||
{ match: /sonnet/i, rates: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
|
||||
{ match: /haiku/i, rates: { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 } },
|
||||
// OpenAI Codex / GPT slugs — approximate; no cache-tier distinction.
|
||||
{ match: /gpt-5|codex|o[0-9]/i, rates: { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
|
||||
];
|
||||
|
||||
/** Fallback when the model id matches nothing (use mid-tier Sonnet-ish rates). */
|
||||
const DEFAULT_RATES: ModelRates = { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
||||
|
||||
function ratesFor(model: string): ModelRates {
|
||||
for (const { match, rates } of RATE_TABLE) {
|
||||
if (match.test(model)) return rates;
|
||||
}
|
||||
return DEFAULT_RATES;
|
||||
}
|
||||
|
||||
export interface TokenBreakdown {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreateTokens: number;
|
||||
}
|
||||
|
||||
/** Estimate USD cost from a full per-type token breakdown (most accurate). */
|
||||
export function estimateCostFromBreakdown(model: string, b: TokenBreakdown): number {
|
||||
const r = ratesFor(model);
|
||||
return (
|
||||
(b.inputTokens * r.input +
|
||||
b.outputTokens * r.output +
|
||||
b.cacheCreateTokens * r.cacheWrite +
|
||||
b.cacheReadTokens * r.cacheRead) /
|
||||
1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate USD cost when only a single total-token count is known (e.g. a run
|
||||
* log, which doesn't store the input/output split). Assumes a ~70/30 input/output
|
||||
* blend — a rough best-effort, used only when the CLI reported no cost. */
|
||||
export function estimateCostFromTotal(model: string, totalTokens: number): number {
|
||||
const r = ratesFor(model);
|
||||
const blended = 0.7 * r.input + 0.3 * r.output;
|
||||
return (totalTokens * blended) / 1_000_000;
|
||||
}
|
||||
|
|
@ -362,7 +362,7 @@ export class AgentChatView extends ItemView {
|
|||
// resolved (in switchToAgent), so we don't pre-check here.
|
||||
this.selectedConversationId = "";
|
||||
this.textarea.disabled = false;
|
||||
this.textarea.placeholder = "Message the agent\u2026 (Ctrl+Enter to send)";
|
||||
this.textarea.placeholder = "Message the agent\u2026 (Shift+Enter for newline)";
|
||||
void this.switchToAgent(val);
|
||||
};
|
||||
|
||||
|
|
@ -393,7 +393,7 @@ export class AgentChatView extends ItemView {
|
|||
|
||||
this.textarea = inputRow.createEl("textarea", {
|
||||
cls: "af-chat-input",
|
||||
attr: { placeholder: "Message the agent\u2026 (Ctrl+Enter to send)", rows: "1" },
|
||||
attr: { placeholder: "Message the agent\u2026 (Shift+Enter for newline)", rows: "1" },
|
||||
});
|
||||
this.sendBtn = inputRow.createEl("button", { cls: "af-chat-send-btn" });
|
||||
createIcon(this.sendBtn, "arrow-up", "af-btn-icon");
|
||||
|
|
@ -415,7 +415,10 @@ export class AgentChatView extends ItemView {
|
|||
|
||||
this.sendBtn.onclick = () => void this.handleSend();
|
||||
this.textarea.onkeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
// Enter sends; Shift+Enter inserts a newline (common chat convention).
|
||||
// Skip while an IME composition is active so Enter confirms the candidate
|
||||
// rather than sending a half-composed message.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
void this.handleSend();
|
||||
}
|
||||
|
|
@ -582,7 +585,7 @@ export class AgentChatView extends ItemView {
|
|||
}
|
||||
|
||||
this.leaf.updateHeader();
|
||||
this.textarea.placeholder = "Message the agent\u2026 (Ctrl+Enter to send)";
|
||||
this.textarea.placeholder = "Message the agent\u2026 (Shift+Enter for newline)";
|
||||
|
||||
// Load the session if not already displayed. Check for actual chat bubbles
|
||||
// (not just the empty state divs) to decide whether switchToAgent is needed.
|
||||
|
|
@ -690,6 +693,7 @@ export class AgentChatView extends ItemView {
|
|||
this.app.vault,
|
||||
{ inAppConversationId: resolvedId, mcpAuth: this.plugin.mcpAuth },
|
||||
);
|
||||
session.setUsageRecorder((r) => this.plugin.runtime.recordUsage(r));
|
||||
managed = { session };
|
||||
this.sessions.set(key, managed);
|
||||
await session.loadPersistedState();
|
||||
|
|
@ -1417,7 +1421,7 @@ export class AgentChatView extends ItemView {
|
|||
|
||||
const input = composer.createEl("textarea", {
|
||||
cls: "af-chat-input af-thread-input",
|
||||
attr: { placeholder: "Message in thread\u2026 (Ctrl+Enter to send)", rows: "1" },
|
||||
attr: { placeholder: "Message in thread\u2026 (Shift+Enter for newline)", rows: "1" },
|
||||
});
|
||||
|
||||
// Paste image support
|
||||
|
|
@ -1532,7 +1536,8 @@ export class AgentChatView extends ItemView {
|
|||
|
||||
sendBtn.onclick = () => void submit();
|
||||
input.onkeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
// Enter sends; Shift+Enter inserts a newline.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { ItemView, MarkdownRenderer, Notice, TFile, WorkspaceLeaf, setIcon } fro
|
|||
import { IconPickerModal } from "../modals/iconPickerModal";
|
||||
import { VIEW_TYPE_DASHBOARD } from "../constants";
|
||||
import type AgentFleetPlugin from "../main";
|
||||
import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig } from "../types";
|
||||
import type { AgentConfig, AgentHealth, ChannelConfig, McpServer, McpTool, RunLogData, SkillConfig, TaskConfig, UsageRecord } from "../types";
|
||||
import { estimateCostFromBreakdown, estimateCostFromTotal } from "../utils/pricing";
|
||||
import { truncate, slugify, parseMarkdownWithFrontmatter, stringifyMarkdownWithFrontmatter } from "../utils/markdown";
|
||||
import { splitLines } from "../utils/platform";
|
||||
import { createIcon } from "../utils/icons";
|
||||
|
|
@ -499,8 +500,9 @@ export class FleetDashboardView extends ItemView {
|
|||
this.renderStatCard(grid, "Runs Today", String(todayRuns.length), "", "activity",
|
||||
`${passed} passed \u00B7 ${failed} failed \u00B7 ${status.running} running`);
|
||||
|
||||
const totalTokens = todayRuns.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
||||
const totalCost = todayRuns.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
|
||||
// Comprehensive: run logs (tasks/heartbeats/reflections) + chat/channel usage.
|
||||
const todayUsage = this.plugin.runtime.getUsageRecords().filter((u) => this.runToLocalDate(u.ts) === todayStr);
|
||||
const { tokens: totalTokens, cost: totalCost } = this.combinedTotals(todayRuns, todayUsage);
|
||||
const costSuffix = totalCost > 0 ? ` \u00B7 $${totalCost.toFixed(2)}` : "";
|
||||
this.renderStatCard(grid, "Tokens Used", formatTokenCount(totalTokens), "", "zap", `today${costSuffix}`);
|
||||
|
||||
|
|
@ -567,6 +569,35 @@ export class FleetDashboardView extends ItemView {
|
|||
return this.toLocalDateStr(new Date(started));
|
||||
}
|
||||
|
||||
/** Cost of a run log: the CLI-reported dollars, or an estimate from total
|
||||
* tokens when the CLI reported none (e.g. Codex). */
|
||||
private runCost(r: RunLogData): number {
|
||||
return r.costUsd ?? estimateCostFromTotal(r.model, r.tokensUsed ?? 0);
|
||||
}
|
||||
|
||||
/** Cost of a chat/channel usage record: CLI-reported, or estimated from the
|
||||
* per-type token breakdown when absent. */
|
||||
private usageCost(u: UsageRecord): number {
|
||||
return u.costUsd ?? estimateCostFromBreakdown(u.model, {
|
||||
inputTokens: u.inputTokens,
|
||||
outputTokens: u.outputTokens,
|
||||
cacheReadTokens: u.cacheReadTokens,
|
||||
cacheCreateTokens: u.cacheCreateTokens,
|
||||
});
|
||||
}
|
||||
|
||||
/** Comprehensive token + cost totals across run logs AND chat/channel usage
|
||||
* records, with the pricing fallback applied per source. */
|
||||
private combinedTotals(runs: RunLogData[], usage: UsageRecord[]): { tokens: number; cost: number } {
|
||||
const tokens =
|
||||
runs.reduce((s, r) => s + (r.tokensUsed ?? 0), 0) +
|
||||
usage.reduce((s, u) => s + u.totalTokens, 0);
|
||||
const cost =
|
||||
runs.reduce((s, r) => s + this.runCost(r), 0) +
|
||||
usage.reduce((s, u) => s + this.usageCost(u), 0);
|
||||
return { tokens, cost };
|
||||
}
|
||||
|
||||
private buildChartData(runs: RunLogData[], days: number): BarChartDay[] {
|
||||
const result: BarChartDay[] = [];
|
||||
const now = new Date();
|
||||
|
|
@ -616,7 +647,11 @@ export class FleetDashboardView extends ItemView {
|
|||
: undefined;
|
||||
const taskLabel = runningTask
|
||||
? ` → ${runningTask.taskId}`
|
||||
: state.status === "running" ? " → Heartbeat" : "";
|
||||
: state.currentTaskId?.startsWith("reflection-")
|
||||
? " → Reflection"
|
||||
: state.currentTaskId?.startsWith("heartbeat-")
|
||||
? " → Heartbeat"
|
||||
: state.status === "running" ? " → Heartbeat" : "";
|
||||
|
||||
const header = card.createDiv({ cls: "af-streaming-card-header" });
|
||||
header.createSpan({ cls: "af-dot pulse", attr: { style: "background: var(--af-yellow)" } });
|
||||
|
|
@ -626,14 +661,20 @@ export class FleetDashboardView extends ItemView {
|
|||
}
|
||||
|
||||
const output = card.createDiv({ cls: "af-streaming-output" });
|
||||
const buffer = this.plugin.runtime.getRunOutputBuffer(agentName);
|
||||
const lines = splitLines(buffer).slice(-4);
|
||||
output.setText(lines.join("\n"));
|
||||
// Reflection reads a lot of context before emitting its memory block, so the
|
||||
// buffer is empty for most of the run — show a placeholder instead of a blank
|
||||
// card so it doesn't look stalled.
|
||||
const placeholder = state.currentTaskId?.startsWith("reflection-")
|
||||
? "Consolidating memory…"
|
||||
: "Working…";
|
||||
const renderBuffer = (buf: string) => {
|
||||
const lines = splitLines(buf).filter((l) => l.trim().length > 0).slice(-4);
|
||||
output.setText(lines.length > 0 ? lines.join("\n") : placeholder);
|
||||
};
|
||||
renderBuffer(this.plugin.runtime.getRunOutputBuffer(agentName));
|
||||
|
||||
const unsub = this.plugin.runtime.onRunOutput(agentName, () => {
|
||||
const updated = this.plugin.runtime.getRunOutputBuffer(agentName);
|
||||
const updatedLines = splitLines(updated).slice(-4);
|
||||
output.setText(updatedLines.join("\n"));
|
||||
renderBuffer(this.plugin.runtime.getRunOutputBuffer(agentName));
|
||||
output.scrollTop = output.scrollHeight;
|
||||
});
|
||||
this.streamingUnsubscribes.push(unsub);
|
||||
|
|
@ -1058,8 +1099,9 @@ export class FleetDashboardView extends ItemView {
|
|||
const successRuns = runs.filter((r) => r.status === "success").length;
|
||||
const successRate = totalRuns > 0 ? Math.round((successRuns / totalRuns) * 100) : 0;
|
||||
const avgTime = totalRuns > 0 ? Math.round(runs.reduce((s, r) => s + r.durationSeconds, 0) / totalRuns) : 0;
|
||||
const totalTokens = runs.reduce((s, r) => s + (r.tokensUsed ?? 0), 0);
|
||||
const totalCostAgent = runs.reduce((s, r) => s + (r.costUsd ?? 0), 0);
|
||||
// Comprehensive: this agent's run logs + its chat/channel usage over the window.
|
||||
const agentUsage = this.plugin.runtime.getUsageRecords().filter((u) => u.agent === agent.name);
|
||||
const { tokens: totalTokens, cost: totalCostAgent } = this.combinedTotals(runs, agentUsage);
|
||||
const costSuffixAgent = totalCostAgent > 0 ? ` \u00B7 $${totalCostAgent.toFixed(2)}` : "";
|
||||
|
||||
this.renderStatCard(statsRow, "Total Runs", String(totalRuns), "", "activity", "all time");
|
||||
|
|
@ -2121,6 +2163,7 @@ export class FleetDashboardView extends ItemView {
|
|||
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
|
||||
typeSelect.createEl("option", { text: "slack", attr: { value: "slack" } });
|
||||
typeSelect.createEl("option", { text: "telegram", attr: { value: "telegram" } });
|
||||
typeSelect.createEl("option", { text: "discord", attr: { value: "discord" } });
|
||||
typeSelect.addEventListener("change", () => { state.type = typeSelect.value; });
|
||||
|
||||
// Credential dropdown
|
||||
|
|
@ -2205,7 +2248,7 @@ export class FleetDashboardView extends ItemView {
|
|||
|
||||
const usersLabel = accessSection.createDiv({ cls: "af-form-label" });
|
||||
usersLabel.setText("Allowed users");
|
||||
this.addTooltip(usersLabel, "Slack user IDs (U...), one per line. Only listed users can reach the bot.");
|
||||
this.addTooltip(usersLabel, "User IDs, one per line — Slack (U...), Telegram (numeric), or Discord (numeric snowflakes). Only listed users can reach the bot.");
|
||||
const usersTextarea = accessSection.createEl("textarea", {
|
||||
cls: "af-create-prompt-textarea",
|
||||
attr: { placeholder: "U0AQW6P37N1\nU0BXYZ12345", rows: "4" },
|
||||
|
|
@ -2389,7 +2432,7 @@ export class FleetDashboardView extends ItemView {
|
|||
const typeRow = detailsSection.createDiv({ cls: "af-form-row" });
|
||||
typeRow.createDiv({ cls: "af-form-label", text: "Type" });
|
||||
const typeSelect = typeRow.createEl("select", { cls: "af-form-select" });
|
||||
for (const t of ["slack", "telegram"] as const) {
|
||||
for (const t of ["slack", "telegram", "discord"] as const) {
|
||||
const opt = typeSelect.createEl("option", { text: t, attr: { value: t } });
|
||||
if (t === channel.type) opt.selected = true;
|
||||
}
|
||||
|
|
@ -3573,6 +3616,7 @@ export class FleetDashboardView extends ItemView {
|
|||
heartbeatBody: "",
|
||||
heartbeatNotify: true,
|
||||
heartbeatChannel: "",
|
||||
heartbeatChannelTarget: "",
|
||||
autoCompactThreshold: 85,
|
||||
wikiReferences: [] as string[],
|
||||
};
|
||||
|
|
@ -3838,7 +3882,17 @@ export class FleetDashboardView extends ItemView {
|
|||
for (const ch of createSnapshot.channels) {
|
||||
hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
|
||||
}
|
||||
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
|
||||
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); });
|
||||
|
||||
const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" });
|
||||
const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" });
|
||||
hbTargetLabel.setText("Target ID");
|
||||
this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user.");
|
||||
const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } });
|
||||
hbTargetInput.value = state.heartbeatChannelTarget;
|
||||
hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); });
|
||||
const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); };
|
||||
syncHbTarget();
|
||||
|
||||
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
|
||||
hbInstructionLabel.setCssStyles({ width: "auto" });
|
||||
|
|
@ -4010,6 +4064,7 @@ export class FleetDashboardView extends ItemView {
|
|||
schedule: state.heartbeatSchedule.trim(),
|
||||
notify: state.heartbeatNotify,
|
||||
channel: state.heartbeatChannel,
|
||||
channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "",
|
||||
body: state.heartbeatBody.trim(),
|
||||
});
|
||||
}
|
||||
|
|
@ -4238,6 +4293,7 @@ export class FleetDashboardView extends ItemView {
|
|||
heartbeatBody: agent.heartbeatBody,
|
||||
heartbeatNotify: agent.heartbeatNotify,
|
||||
heartbeatChannel: agent.heartbeatChannel,
|
||||
heartbeatChannelTarget: agent.heartbeatChannelTarget,
|
||||
autoCompactThreshold: agent.autoCompactThreshold ?? 85,
|
||||
wikiReferences: (agent.wikiReferences ?? []).map((r) => r.agent),
|
||||
};
|
||||
|
|
@ -4495,7 +4551,17 @@ export class FleetDashboardView extends ItemView {
|
|||
const opt = hbChannelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
|
||||
if (ch.name === state.heartbeatChannel) opt.selected = true;
|
||||
}
|
||||
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; });
|
||||
hbChannelSelect.addEventListener("change", () => { state.heartbeatChannel = hbChannelSelect.value; syncHbTarget(); });
|
||||
|
||||
const hbTargetRow = hbBody.createDiv({ cls: "af-form-row" });
|
||||
const hbTargetLabel = hbTargetRow.createDiv({ cls: "af-form-label" });
|
||||
hbTargetLabel.setText("Target ID");
|
||||
this.addTooltip(hbTargetLabel, "Specific channel id to post to (Discord/Slack channel id, Telegram chat id). Empty = DM the channel’s first allowed user.");
|
||||
const hbTargetInput = hbTargetRow.createEl("input", { cls: "af-form-input", attr: { type: "text", placeholder: "Channel/chat id — empty = DM" } });
|
||||
hbTargetInput.value = state.heartbeatChannelTarget;
|
||||
hbTargetInput.addEventListener("input", () => { state.heartbeatChannelTarget = hbTargetInput.value.trim(); });
|
||||
const syncHbTarget = () => { hbTargetRow.setCssStyles({ display: state.heartbeatChannel ? "" : "none" }); };
|
||||
syncHbTarget();
|
||||
|
||||
const hbInstructionLabel = hbBody.createDiv({ cls: "af-form-label" });
|
||||
hbInstructionLabel.setCssStyles({ width: "auto" });
|
||||
|
|
@ -4703,6 +4769,7 @@ export class FleetDashboardView extends ItemView {
|
|||
schedule: state.heartbeatSchedule.trim(),
|
||||
notify: state.heartbeatNotify,
|
||||
channel: state.heartbeatChannel,
|
||||
channelTarget: state.heartbeatChannel ? state.heartbeatChannelTarget : "",
|
||||
body: state.heartbeatBody.trim(),
|
||||
});
|
||||
}
|
||||
|
|
@ -4717,6 +4784,50 @@ export class FleetDashboardView extends ItemView {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the optional "post results to a channel" controls shared by the
|
||||
* create and edit task forms: a channel picker plus a transport-native target
|
||||
* id (Discord/Slack channel id, Telegram chat id). Empty target = broadcast/DM.
|
||||
*/
|
||||
private renderTaskChannelDelivery(
|
||||
section: HTMLElement,
|
||||
channels: ChannelConfig[],
|
||||
state: { channel: string; channelTarget: string },
|
||||
): void {
|
||||
const channelRow = section.createDiv({ cls: "af-form-row" });
|
||||
const channelLabel = channelRow.createDiv({ cls: "af-form-label", text: "Channel" });
|
||||
const channelSelect = channelRow.createEl("select", { cls: "af-form-select" });
|
||||
channelSelect.createEl("option", { text: "— none (run log only) —", attr: { value: "" } });
|
||||
for (const ch of channels) {
|
||||
const opt = channelSelect.createEl("option", { text: ch.name, attr: { value: ch.name } });
|
||||
if (ch.name === state.channel) opt.selected = true;
|
||||
}
|
||||
this.addTooltip(
|
||||
channelLabel,
|
||||
"Post this task’s full output to a channel when it finishes. Leave as none to only write the run log (the default batched behavior).",
|
||||
);
|
||||
|
||||
const targetRow = section.createDiv({ cls: "af-form-row" });
|
||||
const targetLabel = targetRow.createDiv({ cls: "af-form-label", text: "Target ID" });
|
||||
const targetInput = targetRow.createEl("input", {
|
||||
cls: "af-form-input",
|
||||
attr: { type: "text", placeholder: "Discord/Slack channel ID or Telegram chat ID — empty = DM you" },
|
||||
});
|
||||
targetInput.value = state.channelTarget;
|
||||
targetInput.addEventListener("input", () => { state.channelTarget = targetInput.value.trim(); });
|
||||
this.addTooltip(
|
||||
targetLabel,
|
||||
"Where in the channel to post. For Discord, enable Developer Mode and right-click the channel → Copy Channel ID. Leave empty to DM the first allowed user instead.",
|
||||
);
|
||||
|
||||
const syncVisibility = () => { targetRow.setCssStyles({ display: state.channel ? "" : "none" }); };
|
||||
syncVisibility();
|
||||
channelSelect.addEventListener("change", () => {
|
||||
state.channel = channelSelect.value;
|
||||
syncVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Create Task Page
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
@ -4754,6 +4865,8 @@ export class FleetDashboardView extends ItemView {
|
|||
catchUp: true,
|
||||
effort: "",
|
||||
model: "",
|
||||
channel: "",
|
||||
channelTarget: "",
|
||||
};
|
||||
|
||||
const form = page.createDiv({ cls: "af-create-form" });
|
||||
|
|
@ -4926,6 +5039,8 @@ export class FleetDashboardView extends ItemView {
|
|||
if (val === state.effort) opt.selected = true;
|
||||
}
|
||||
taskEffortSelect.addEventListener("change", () => { state.effort = taskEffortSelect.value; });
|
||||
// Channel delivery (optional) — post this task's output to a channel on completion.
|
||||
this.renderTaskChannelDelivery(execSection, snapshot.channels, state);
|
||||
this.addTooltip(
|
||||
taskEffortLabel,
|
||||
"Overrides the agent\u2019s effort level for this task. Higher effort = more thinking tokens spent.",
|
||||
|
|
@ -4965,6 +5080,8 @@ export class FleetDashboardView extends ItemView {
|
|||
catch_up: state.catchUp,
|
||||
effort: state.effort || undefined,
|
||||
model: state.model || undefined,
|
||||
channel: state.channel || undefined,
|
||||
channel_target: state.channel && state.channelTarget ? state.channelTarget : undefined,
|
||||
tags: parseTags(state.tags),
|
||||
};
|
||||
|
||||
|
|
@ -5055,6 +5172,8 @@ export class FleetDashboardView extends ItemView {
|
|||
catchUp: task.catchUp,
|
||||
effort: task.effort ?? "",
|
||||
model: task.model ?? "",
|
||||
channel: task.channel ?? "",
|
||||
channelTarget: task.channelTarget ?? "",
|
||||
tags: task.tags.join(", "),
|
||||
body: task.body,
|
||||
};
|
||||
|
|
@ -5243,6 +5362,8 @@ export class FleetDashboardView extends ItemView {
|
|||
};
|
||||
renderTaskModelPicker(state.agent);
|
||||
agentSelect.addEventListener("change", () => renderTaskModelPicker(agentSelect.value));
|
||||
// Channel delivery (optional) — post this task's output to a channel on completion.
|
||||
this.renderTaskChannelDelivery(execSection, snapshot.channels, state);
|
||||
this.addTooltip(
|
||||
modelLabelEdit,
|
||||
"Override the agent\u2019s model for this task only. Useful for routing simple runs to haiku while the agent stays on opus for heavier work.",
|
||||
|
|
@ -5292,6 +5413,8 @@ export class FleetDashboardView extends ItemView {
|
|||
catch_up: state.catchUp,
|
||||
effort: state.effort || undefined,
|
||||
model: state.model || "",
|
||||
channel: state.channel || "",
|
||||
channelTarget: state.channel && state.channelTarget ? state.channelTarget : "",
|
||||
tags: parseTags(state.tags),
|
||||
body: state.body.trim(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@
|
|||
"0.13.3": "1.11.4",
|
||||
"0.13.4": "1.11.4",
|
||||
"0.13.5": "1.11.4",
|
||||
"0.13.6": "1.11.4"
|
||||
"0.13.6": "1.11.4",
|
||||
"0.14.0": "1.11.4"
|
||||
}
|
||||
Loading…
Reference in a new issue