diff --git a/src/config/profile.ts b/src/config/profile.ts index efe63ead..eda5ec4c 100644 --- a/src/config/profile.ts +++ b/src/config/profile.ts @@ -122,7 +122,7 @@ You are the user's digital clone with access to their message history, accumulat You run inside the Nomos daemon — a self-hosted, locally-running agent. You are NOT Claude.ai's web product. -**Scheduling — IMPORTANT.** You may notice a built-in skill called \`schedule\` (sometimes invoked as \`/schedule\`) in your skill list. **DO NOT USE IT.** That skill creates Anthropic-hosted Routines with a 1-hour minimum interval — it is a different product unrelated to Nomos. **Always** use the \`schedule_task\` MCP tool instead. It accepts any interval ('1m', '5m', '15m', '30m', '1h', etc.) and runs the task inside the local Nomos daemon. There is no minimum interval. Examples: +**Scheduling — IMPORTANT.** You may notice built-in skills called \`schedule\` (\`/schedule\`) and \`loop\` (\`/loop\`) in your skill list, and built-in tools named \`CronCreate\`, \`RemoteTrigger\`, or \`ScheduleWakeup\`. **DO NOT USE ANY OF THEM.** They create Anthropic-hosted claude.ai Routines (1-hour minimum, results land on the claude.ai dashboard, and they never run in the daemon or show up in the user's settings) — a different product unrelated to Nomos. **Always** use the Nomos MCP tools instead: \`schedule_task\` for one-off or recurring background tasks, and \`loop_create\` for autonomous loops. They accept any interval ('1m', '5m', '15m', '30m', '1h', etc.), have no minimum, and run inside the local Nomos daemon. Examples: - "check email every 15 min" → \`schedule_task(schedule='15m', schedule_type='every', prompt='Check unread emails and draft replies')\` - "remind me daily at 9am" → \`schedule_task(schedule='0 9 * * *', schedule_type='cron', prompt='...')\` @@ -315,6 +315,13 @@ You can create background tasks that run automatically in the daemon: When the user asks for recurring actions (e.g. "check my emails every 15 minutes", "remind me daily"), create a scheduled task instead of suggesting manual checks. Tasks run in the background even between conversations.`, ); + // Asking & planning — these tools render proper cards in the app. + sections.push( + `## Asking & planning +- **Need the user to decide or supply a missing detail before you can act?** ALWAYS ask through the \`ask_user\` tool — NEVER by writing questions in prose. It shows them tappable choices. Ask the SINGLE most important missing detail first, with 2-4 short options; once they answer, ask the next. Even when several things are unknown (e.g. "book me a dinner reservation" → day, time, party size, place), do NOT dump a numbered list of questions in the chat — pick the one detail that unblocks you and ask it with \`ask_user\`. Only skip the tool when you can reasonably proceed on a sensible default or infer the answer from memory. +- **Laying out a multi-step plan?** Use the \`TodoWrite\` tool to write the steps as todos — it renders a single tracked plan the user can follow, and you update statuses as you work. Do NOT spin up real scheduled tasks for ephemeral planning steps; \`schedule_task\` is only for things that should actually run on a schedule.`, + ); + // Permissions const permissionsSection = [ `## Permissions diff --git a/src/daemon/agent-runtime.ts b/src/daemon/agent-runtime.ts index fc872e95..c99b4981 100644 --- a/src/daemon/agent-runtime.ts +++ b/src/daemon/agent-runtime.ts @@ -46,11 +46,31 @@ import { resolveMemoryUserId } from "../auth/tenant-context.ts"; * here so both single-agent and team-runtime call sites stay consistent. */ function getDisallowedTools(): string[] { - // The SDK's generic `Workflow` orchestration tool spawns its OWN sub-agents - // outside Nomos's team runtime (no memory/persona, an async "notify when done" - // model that doesn't fit a single chat turn) and leaks a raw script into the UI. - // Block it so "spin up a team" routes to the Nomos-native `delegate_to_team`. - const blocked: string[] = ["Workflow"]; + // Block the SDK's generic orchestration/task built-ins so the agent routes to the + // Nomos-native equivalents (which render proper cards + own durable state): + // - `Workflow` spawns sub-agents outside the team runtime + leaks a raw script → + // use `delegate_to_team`. + // - `TaskCreate`/`TaskList`/`TaskUpdate`/`TaskDelete` are the SDK task tracker; + // they render as raw CoT noise instead of a Plan card and create stray tasks → + // use `TodoWrite` for a tracked plan, `schedule_task`/`loop_create` for real ones. + // - `CronCreate`/`CronDelete`/`CronList`/`RemoteTrigger`/`ScheduleWakeup` are what the + // built-in `schedule` + `loop` skills call to create Anthropic-hosted claude.ai + // Routines (1-hour minimum, results land on the claude.ai dashboard, never run in + // the daemon and never show in the user's settings). A prompt warning alone didn't + // stop the agent from reaching for them, so block them outright → the agent must use + // the `schedule_task` / `loop_create` MCP tools, which run locally in the daemon. + const blocked: string[] = [ + "Workflow", + "TaskCreate", + "TaskList", + "TaskUpdate", + "TaskDelete", + "CronCreate", + "CronDelete", + "CronList", + "RemoteTrigger", + "ScheduleWakeup", + ]; if (!FEATURES.bashTool()) { blocked.push("Bash", "BashOutput", "KillBash"); } @@ -88,16 +108,25 @@ function summarizeToolInput(name: string, input: unknown): string { case "Grep": case "Glob": return str(o.pattern); + case "Skill": + return str(o.skill) || str(o.name) || str(o.command); default: { - const pick = - str(o.query) || str(o.prompt) || str(o.path) || str(o.message) || str(o.description); - if (pick) return pick; - try { - const j = JSON.stringify(input); - return j && j.length > 2 ? (j.length > 120 ? `${j.slice(0, 117)}…` : j) : ""; - } catch { - return ""; - } + // A friendly summary from a known field. NEVER dump raw JSON into the card — + // an unrecognized tool with no readable field shows no subtitle instead. + return ( + str(o.query) || + str(o.prompt) || + str(o.path) || + str(o.message) || + str(o.description) || + str(o.skill) || + str(o.name) || + str(o.command) || + str(o.url) || + str(o.content) || + str(o.title) || + "" + ); } } } diff --git a/src/daemon/channels/slack.ts b/src/daemon/channels/slack.ts index f82e4932..e6e99830 100644 --- a/src/daemon/channels/slack.ts +++ b/src/daemon/channels/slack.ts @@ -8,6 +8,7 @@ import SlackBolt from "@slack/bolt"; import type { ChannelAdapter, IncomingMessage, OutgoingMessage } from "../types.ts"; import type { DraftManager } from "../draft-manager.ts"; import { chunkResponse } from "../response-chunker.ts"; +import { markdownToSlackMrkdwn } from "./slack-mrkdwn.ts"; import { randomUUID } from "node:crypto"; import { createLogger } from "../../lib/logger.ts"; @@ -168,7 +169,9 @@ export class SlackAdapter implements ChannelAdapter { async send(message: OutgoingMessage): Promise { if (!this.app) return; const token = process.env.SLACK_BOT_TOKEN!; - const result = chunkResponse(message.content, "slack"); + // Translate Markdown -> Slack mrkdwn before chunking so the language tag on + // fenced code blocks is stripped and **bold**/headings/links render natively. + const result = chunkResponse(markdownToSlackMrkdwn(message.content), "slack"); for (const text of result.chunks) { await this.app.client.chat.postMessage({ @@ -179,13 +182,14 @@ export class SlackAdapter implements ChannelAdapter { }); } - // Upload full response as file for very long messages + // Upload full response as file for very long messages. The file keeps the + // original Markdown -- it renders correctly in editors/viewers as-is. if (result.strategy === "file" && result.fullText && result.filename) { const uploadArgs = { token, channel_id: message.channelId, filename: result.filename, - content: result.fullText, + content: message.content, title: "Full Response", ...(message.threadId ? { thread_ts: message.threadId } : {}), }; @@ -203,7 +207,7 @@ export class SlackAdapter implements ChannelAdapter { const result = await this.app.client.chat.postMessage({ token: process.env.SLACK_BOT_TOKEN!, channel: channelId, - text, + text: markdownToSlackMrkdwn(text), thread_ts: threadId, }); return result.ts; @@ -215,7 +219,7 @@ export class SlackAdapter implements ChannelAdapter { token: process.env.SLACK_BOT_TOKEN!, channel: channelId, ts: messageId, - text, + text: markdownToSlackMrkdwn(text), }); }