diff --git a/CHANGELOG.md b/CHANGELOG.md index f278e0de0..991a73075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Fixes - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. +- Agent tools: scope the Discord tool to Discord surface runs. +- Agent tools: format verbose tool summaries without brackets, with unique emojis and `tool: detail` style. ### Docs - Skills: add Sheets/Docs examples to gog skill (#128) — thanks @mbelinky. diff --git a/docs/discord.md b/docs/discord.md index 4228bb9be..7080dedcf 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -28,6 +28,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. 10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. 11. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). + - The `discord` tool is only exposed when the current surface is Discord. 12. Slash commands use isolated session keys (`${sessionPrefix}:${userId}`) rather than the shared `main` session. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. diff --git a/docs/thinking.md b/docs/thinking.md index 5e208d939..0946b89f6 100644 --- a/docs/thinking.md +++ b/docs/thinking.md @@ -32,7 +32,7 @@ read_when: - Levels: `on|full` or `off` (default). - Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state. - Inline directive affects only that message; session/global defaults apply otherwise. -- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `[🛠️ ]` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. +- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with ` : ` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting. ## Heartbeats - Heartbeat probe body is `HEARTBEAT`. Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats). diff --git a/docs/tools.md b/docs/tools.md index b59b84d83..72d2a4c7a 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -143,6 +143,7 @@ Notes: - `reactions` returns per-emoji user lists (limited to 100 per reaction). - `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`. - `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays). +- The tool is only exposed when the current surface is Discord. ## Parameters (common) diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 9915720ff..1fa5e83e0 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -310,6 +310,7 @@ function resolvePromptSkills( export async function runEmbeddedPiAgent(params: { sessionId: string; sessionKey?: string; + surface?: string; sessionFile: string; workspaceDir: string; config?: ClawdisConfig; @@ -414,6 +415,7 @@ export async function runEmbeddedPiAgent(params: { const promptSkills = resolvePromptSkills(skillsSnapshot, skillEntries); const tools = createClawdisCodingTools({ bash: params.config?.agent?.bash, + surface: params.surface, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 2a7930b8c..06ef91229 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -83,6 +83,14 @@ describe("createClawdisCodingTools", () => { expect(tools.some((tool) => tool.name === "process")).toBe(true); }); + it("scopes discord tool to discord surface", () => { + const other = createClawdisCodingTools({ surface: "whatsapp" }); + expect(other.some((tool) => tool.name === "discord")).toBe(false); + + const discord = createClawdisCodingTools({ surface: "discord" }); + expect(discord.some((tool) => tool.name === "discord")).toBe(true); + }); + it("keeps read tool image metadata intact", async () => { const tools = createClawdisCodingTools(); const readTool = tools.find((tool) => tool.name === "read"); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 811321cfe..f69e53f2e 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -294,8 +294,20 @@ function createClawdisReadTool(base: AnyAgentTool): AnyAgentTool { }; } +function normalizeSurface(surface?: string): string | undefined { + const trimmed = surface?.trim().toLowerCase(); + return trimmed ? trimmed : undefined; +} + +function shouldIncludeDiscordTool(surface?: string): boolean { + const normalized = normalizeSurface(surface); + if (!normalized) return false; + return normalized === "discord" || normalized.startsWith("discord:"); +} + export function createClawdisCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; + surface?: string; }): AnyAgentTool[] { const bashToolName = "bash"; const base = (codingTools as unknown as AnyAgentTool[]).flatMap((tool) => { @@ -314,5 +326,9 @@ export function createClawdisCodingTools(options?: { createWhatsAppLoginTool(), ...createClawdisTools(), ]; - return tools.map(normalizeToolParameters); + const allowDiscord = shouldIncludeDiscordTool(options?.surface); + const filtered = allowDiscord + ? tools + : tools.filter((tool) => tool.name !== "discord"); + return filtered.map(normalizeToolParameters); } diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index ef6eea4dd..8b0caa4e1 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -105,6 +105,7 @@ type FollowupRun = { run: { sessionId: string; sessionKey?: string; + surface?: string; sessionFile: string; workspaceDir: string; config: ClawdisConfig; @@ -1871,6 +1872,7 @@ export async function getReplyFromConfig( run: { sessionId: sessionIdFinal, sessionKey, + surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, sessionFile, workspaceDir, config: cfg, @@ -1942,6 +1944,7 @@ export async function getReplyFromConfig( runResult = await runEmbeddedPiAgent({ sessionId: queued.run.sessionId, sessionKey: queued.run.sessionKey, + surface: queued.run.surface, sessionFile: queued.run.sessionFile, workspaceDir: queued.run.workspaceDir, config: queued.run.config, @@ -2061,6 +2064,7 @@ export async function getReplyFromConfig( runResult = await runEmbeddedPiAgent({ sessionId: sessionIdFinal, sessionKey, + surface: sessionCtx.Surface?.trim().toLowerCase() || undefined, sessionFile, workspaceDir, config: cfg, diff --git a/src/auto-reply/tool-meta.test.ts b/src/auto-reply/tool-meta.test.ts index 98ff2f3a9..214738b8a 100644 --- a/src/auto-reply/tool-meta.test.ts +++ b/src/auto-reply/tool-meta.test.ts @@ -35,7 +35,7 @@ describe("tool meta formatting", () => { "note", "a→b", ]); - expect(out).toMatch(/^\[🛠️ fs]/); + expect(out).toMatch(/^🛠️ fs/); expect(out).toContain("~/dir/{a.txt, b.txt}"); expect(out).toContain("note"); expect(out).toContain("a→b"); @@ -43,8 +43,8 @@ describe("tool meta formatting", () => { it("formats prefixes with default labels", () => { vi.stubEnv("HOME", "/Users/test"); - expect(formatToolPrefix(undefined, undefined)).toBe("[🛠️ tool]"); - expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("[🛠️ x ~/a.txt]"); + expect(formatToolPrefix(undefined, undefined)).toBe("🛠️ tool"); + expect(formatToolPrefix("x", "/Users/test/a.txt")).toBe("🛠️ x: ~/a.txt"); }); }); diff --git a/src/auto-reply/tool-meta.ts b/src/auto-reply/tool-meta.ts index 994542ee5..532994459 100644 --- a/src/auto-reply/tool-meta.ts +++ b/src/auto-reply/tool-meta.ts @@ -1,6 +1,28 @@ export const TOOL_RESULT_DEBOUNCE_MS = 500; export const TOOL_RESULT_FLUSH_COUNT = 5; +const TOOL_EMOJI_BY_NAME: Record = { + bash: "💻", + process: "🧰", + read: "📖", + write: "✍️", + edit: "📝", + attach: "📎", + clawdis_browser: "🌐", + clawdis_canvas: "🖼️", + clawdis_nodes: "📱", + clawdis_cron: "⏰", + clawdis_gateway: "🔌", + whatsapp_login: "🟢", + discord: "💬", +}; + +function resolveToolEmoji(toolName?: string): string { + const key = toolName?.trim().toLowerCase(); + if (key && TOOL_EMOJI_BY_NAME[key]) return TOOL_EMOJI_BY_NAME[key]; + return "🛠️"; +} + export function shortenPath(p: string): string { const home = process.env.HOME; if (home && (p === home || p.startsWith(`${home}/`))) @@ -23,7 +45,7 @@ export function formatToolAggregate( ): string { const filtered = (metas ?? []).filter(Boolean).map(shortenMeta); const label = toolName?.trim() || "tool"; - const prefix = `[🛠️ ${label}]`; + const prefix = `${resolveToolEmoji(label)} ${label}`; if (!filtered.length) return prefix; const rawSegments: string[] = []; @@ -53,13 +75,14 @@ export function formatToolAggregate( }); const allSegments = [...rawSegments, ...segments]; - return `${prefix} ${allSegments.join("; ")}`; + return `${prefix}: ${allSegments.join("; ")}`; } export function formatToolPrefix(toolName?: string, meta?: string) { const label = toolName?.trim() || "tool"; + const emoji = resolveToolEmoji(label); const extra = meta?.trim() ? shortenMeta(meta) : undefined; - return extra ? `[🛠️ ${label} ${extra}]` : `[🛠️ ${label}]`; + return extra ? `${emoji} ${label}: ${extra}` : `${emoji} ${label}`; } export function createToolDebouncer( diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 1518f70a1..fcb693992 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -329,9 +329,17 @@ export async function agentCommand( let result: Awaited>; try { + const surface = + opts.surface?.trim().toLowerCase() || + (() => { + const raw = opts.provider?.trim().toLowerCase(); + if (!raw) return undefined; + return raw === "imsg" ? "imessage" : raw; + })(); result = await runEmbeddedPiAgent({ sessionId, sessionKey, + surface, sessionFile, workspaceDir, config: cfg, diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index e972837d3..2eb59c440 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -255,9 +255,16 @@ export async function runCronIsolatedAgentTurn(params: { registerAgentRunContext(cronSession.sessionEntry.sessionId, { sessionKey: params.sessionKey, }); + const surface = + resolvedDelivery.channel && + resolvedDelivery.channel !== "last" && + resolvedDelivery.channel !== "none" + ? resolvedDelivery.channel + : undefined; runResult = await runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: params.sessionKey, + surface, sessionFile, workspaceDir, config: params.cfg, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index aa89c85b0..2b4053e91 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1591,8 +1591,8 @@ describe("web auto-reply", () => { _ctx, opts?: { onToolResult?: (r: { text: string }) => Promise }, ) => { - await opts?.onToolResult?.({ text: "[🛠️ tool1]" }); - await opts?.onToolResult?.({ text: "[🛠️ tool2]" }); + await opts?.onToolResult?.({ text: "🛠️ tool1" }); + await opts?.onToolResult?.({ text: "🛠️ tool2" }); return { text: "final" }; }, ); @@ -1611,7 +1611,7 @@ describe("web auto-reply", () => { }); const replies = reply.mock.calls.map((call) => call[0]); - expect(replies).toEqual(["🦞 [🛠️ tool1]", "🦞 [🛠️ tool2]", "🦞 final"]); + expect(replies).toEqual(["🦞 🛠️ tool1", "🦞 🛠️ tool2", "🦞 final"]); resetLoadConfigMock(); }); });