Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Ode is a Slack bot that bridges messages to OpenCode for AI-assisted coding.
- Bot replies in threads once mentioned
- Status updates include phases, tool progress, and elapsed time
- Status messages are preserved as an operation record
- When capturing screenshots, save images to the system temp folder and upload them to Slack as soon as possible
- When capturing screenshots, save images to the system temp folder and upload them with `ode send file` to the current thread as soon as possible
- When merging PRs, do not delete the branch if the current worktree is on that branch

## Commands
Expand All @@ -36,6 +36,34 @@ Ode is a Slack bot that bridges messages to OpenCode for AI-assisted coding.
- Persistence: SQLite at `~/.config/ode/inbox.db` (table `tasks`); scheduler polls every 10s and uses `UPDATE ... WHERE status='pending'` for cross-process idempotency.
- HTTP API mirrors the CLI under `/api/tasks*`; the Web UI lives at Settings → Tasks.

## Recurring Cron Jobs (`ode cron`)
- A cron job is a recurring scheduled prompt (5-field cron expression) that re-runs the same prompt as a fresh agent turn on schedule.
- CLI:
- `ode cron create --schedule "<cron>" --channel <channelId> --message "<prompt>" [--title <title>] [--disabled] [--run-now]`
- `ode cron list [--enabled | --disabled] [--json]` / `ode cron show <id> [--json]`
- `ode cron update <id> [--schedule ...] [--channel ...] [--message ...] [--title ...] [--enabled | --disabled]`
- `ode cron enable <id>` / `ode cron disable <id>` / `ode cron run <id>` / `ode cron delete <id>`
- Every run creates a fresh session + worktree (see `packages/core/cron/scheduler.ts`) so jobs start clean.
- Persistence: same `~/.config/ode/inbox.db` (table `cron_jobs`); scheduler polls every 15s and claims runs at the SQL level.
- HTTP API mirrors the CLI under `/api/cron-jobs*`; the Web UI lives at Settings → Cron.

## Sending Files / Images (`ode send`)
- `ode send file <path> --channel <channelId> [--thread <threadId>] [--filename <name>] [--title <title>] [--comment <text>]` uploads any file to a chat channel.
- The command resolves platform (Slack / Discord / Lark) from the channel's configured workspace; agents don't need to know the underlying SDK.
- Visual testing workflows should save screenshots to `os.tmpdir()` and upload them directly into the current thread.

## Fetching Messages (`ode messages`)
- `ode messages get <threadId> --channel <channelId> [--limit N] [--json]` returns the replies in a thread.
- Use it to re-read the current thread (for example, to pick up a follow-up comment posted while you were running tools) or to inspect another thread by its root id.

## Reactions (`ode reaction`)
- `ode reaction add <messageId> --channel <channelId> --emoji <thumbsup|eyes|ok_hand> [--thread <threadId>]` reacts to a message.
- Useful acks: `eyes` = "I'm on it", `thumbsup` = "done", `ok_hand` = "acknowledged".

## Platform APIs
- Ode no longer exposes a generic `/api/action` bridge; agents must use the dedicated `ode <verb>` CLIs above instead of calling Slack/Discord/Lark APIs directly.
- Adding a new platform-facing capability means adding (or extending) an `ode` subcommand plus a matching daemon route.

## Bun conventions
- Use Bun instead of Node.js
- Run: `bun run src/index.ts`
Expand Down
72 changes: 38 additions & 34 deletions packages/agents/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { OpenCodeMessageContext, OpenCodeOptions, PromptPart, SlackContext } from "./types";
import { getSlackActionApiUrl } from "@/config";

export function buildSystemPrompt(slack?: SlackContext): string {
const platform = slack?.platform === "discord"
Expand Down Expand Up @@ -39,32 +38,6 @@ export function buildSystemPrompt(slack?: SlackContext): string {
lines.push(`- GitHub token available: ${slack.hasGitHubToken ? "yes" : "no"}`);
}

lines.push("");
lines.push(`${platformLabel.toUpperCase()} ACTIONS:`);
const baseUrl = slack.odeSlackApiUrl ?? getSlackActionApiUrl();
lines.push("- Use bash + curl to call the Ode action API.");
lines.push(`- Endpoint: ${baseUrl}/action`);
lines.push(
platform === "discord"
? "- Payload: {\"platform\":\"discord\",\"action\":\"post_message\",\"channelId\":\"...\",\"messageId\":\"...\",\"text\":\"...\"}"
: platform === "lark"
? "- Payload: {\"platform\":\"lark\",\"action\":\"post_message\",\"channelId\":\"...\",\"threadId\":\"...\",\"text\":\"...\"}"
: "- Payload: {\"action\":\"post_message\",\"channelId\":\"...\",\"threadId\":\"...\",\"messageId\":\"...\",\"text\":\"...\"}"
);
if (platform === "discord") {
lines.push("- Supported actions: get_guilds, get_channels, post_message, update_message, create_thread_from_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file.");
lines.push("- Required fields: channelId for message/reaction/question/upload actions; threadId for get_thread_messages; messageId + emoji for reactions; userId (or \"@me\") for get_user_info; filePath for upload_file.");
lines.push("- add_reaction schema: { platform: \"discord\", action: \"add_reaction\", channelId: string, messageId: string, emoji: \"thumbsup\" | \"eyes\" | \"ok_hand\" }");
} else if (platform === "lark") {
lines.push("- Supported actions: get_channels, post_message, update_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file.");
lines.push("- Required fields: channelId for post_message/ask_user/upload_file; messageId + text for update_message; threadId for get_thread_messages; messageId + emoji for add_reaction; userId for get_user_info; filePath for upload_file.");
lines.push("- post_message schema: { platform: \"lark\", action: \"post_message\", channelId: string, threadId?: string, text: string }");
} else {
lines.push("- Supported actions: post_message, add_reaction, get_thread_messages, ask_user, get_user_info, upload_file.");
lines.push("- Required fields: channelId; threadId for thread actions; messageId + emoji for reactions; userId for get_user_info.");
lines.push("- add_reaction schema: { action: \"add_reaction\", channelId: string, messageId: string, emoji: \"thumbsup\" | \"eyes\" | \"ok_hand\" }");
}
lines.push("- You can use any tool available via bash, curl");
lines.push("");
lines.push(`IMPORTANT: Your text output is automatically posted to ${platformLabel}.`);
lines.push("- Only output text OR use a messaging tool, never both.");
Expand All @@ -85,13 +58,44 @@ export function buildSystemPrompt(slack?: SlackContext): string {
lines.push("- Use four states: * not started, ♻️ in progress, ✅ done, 🚫 cancelled");
lines.push("- If you include a task list, keep the tasks you have done at the top of the response");
lines.push("");
lines.push("ONE-TIME SCHEDULED TASKS:");
lines.push("- Ode provides a one-shot task scheduler for follow-ups that need to fire at a specific time.");
lines.push("- Use it when you need to wait on something that may take minutes, hours, or days (deploys, nightly builds, external approvals). Schedule a task and return instead of blocking the conversation.");
lines.push("- Create via CLI: `ode task create --time <ISO8601> --channel <channelId> [--thread <threadId>] --message \"<prompt>\" [--agent <agentId>]`.");
lines.push("- `--time` accepts ISO 8601 (e.g. `2026-04-19T09:00:00+08:00`). `--thread` is optional; when set, the task reuses this thread's session to keep context; when omitted, the task posts as a new channel message.");
lines.push("- When scheduling a follow-up for the current conversation, pass the current channel and thread so the agent wakes up with the same session history.");
lines.push("- Manage tasks with `ode task list`, `ode task show <id>`, `ode task cancel <id>`, `ode task delete <id>`. Tasks persist across restarts.");
lines.push("ODE CLI:");
lines.push("- The `ode` binary is how you interact with this chat platform outside of plain text output.");
lines.push("- All commands below auto-detect platform (Slack / Discord / Lark) from the `--channel` value;");
lines.push(" you never need to call Slack/Discord/Lark APIs directly.");
lines.push("- `--channel` accepts either a raw channel id or a `workspaceId::channelId` pair for disambiguation.");
lines.push("");
lines.push("ODE CLI - one-time scheduled tasks (`ode task`):");
lines.push("- Use when you need to wait on something that takes minutes / hours / days (deploys, nightly builds,");
lines.push(" external approvals). Schedule the follow-up and return instead of blocking the conversation.");
lines.push("- `ode task create --time <ISO8601> --channel <channelId> [--thread <threadId>] --message \"<prompt>\" [--agent <agentId>] [--run-now]`");
lines.push("- Manage: `ode task list`, `ode task show <id>`, `ode task cancel <id>`, `ode task run <id>`, `ode task delete <id>`.");
lines.push("- Pass the current `--thread` so the task wakes up inside the same conversation; omit it to post as a fresh channel message.");
lines.push("");
lines.push("ODE CLI - recurring cron jobs (`ode cron`):");
lines.push("- Use for schedules (heartbeats, daily digests, periodic checks). Each run starts a fresh agent session.");
lines.push("- `ode cron create --schedule \"<5-field cron>\" --channel <channelId> --message \"<prompt>\" [--title <title>] [--disabled] [--run-now]`");
lines.push("- `--schedule` is standard 5-field cron (minute hour day month weekday), e.g. `*/30 * * * *`.");
lines.push("- Manage: `ode cron list`, `ode cron show <id>`, `ode cron update <id>`, `ode cron enable|disable <id>`, `ode cron run <id>`, `ode cron delete <id>`.");
lines.push("");
lines.push("ODE CLI - send files / images (`ode send`):");
lines.push("- `ode send file <path> --channel <channelId> [--thread <threadId>] [--comment \"...\"] [--filename <name>] [--title <text>]`");
lines.push("- Save files to `$(mktemp -d)` or `os.tmpdir()` first, then upload them with this command.");
lines.push(`- Example: \`ode send file /tmp/screenshot.png --channel ${slack.channelId} --thread ${slack.threadId} --comment \"layout after fix\"\`.`);
lines.push("");
lines.push("ODE CLI - fetch thread messages (`ode messages`):");
lines.push("- `ode messages get <threadId> --channel <channelId> [--limit N] [--json]`");
lines.push("- Use when you need to re-read prior messages in the current thread or another thread.");
lines.push(`- Example: \`ode messages get ${slack.threadId} --channel ${slack.channelId} --limit 40\`.`);
lines.push("");
lines.push("ODE CLI - react to a message (`ode reaction`):");
lines.push("- `ode reaction add <messageId> --channel <channelId> --emoji <thumbsup|eyes|ok_hand> [--thread <threadId>]`");
lines.push("- Useful to acknowledge a request (`eyes` = \"I'm on it\", `thumbsup` = \"done\", `ok_hand` = \"ack\").");
lines.push("");
lines.push("VISUAL TESTING:");
lines.push("- Whenever you work on UI / layout / design tasks, capture the result and upload it to the current");
lines.push(" thread with `ode send file` so the user can see it immediately.");
lines.push("- Prefer real screenshots over describing the UI in text. A single screenshot is usually worth paragraphs.");
lines.push("- For before/after comparisons, upload both screenshots with a short `--comment` explaining what changed.");

const channelSystemMessage = slack.channelSystemMessage?.trim();
if (channelSystemMessage) {
Expand Down
33 changes: 25 additions & 8 deletions packages/agents/test/cli-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe("agent cli command formatting", () => {
expect(systemPrompt).toContain("Preferred branch format before PR: `feat/<short-slug>-<threadShortId>`");
});

it("builds Discord action instructions for Discord context", () => {
it("builds Discord-specific formatting guidance for Discord context", () => {
const systemPrompt = buildSystemPrompt({
platform: "discord",
channelId: "C123",
Expand All @@ -72,12 +72,13 @@ describe("agent cli command formatting", () => {
});

expect(systemPrompt).toContain("DISCORD CONTEXT:");
expect(systemPrompt).toContain("DISCORD ACTIONS:");
expect(systemPrompt).toContain('"platform":"discord"');
expect(systemPrompt).toContain("Supported actions: get_guilds, get_channels, post_message");
expect(systemPrompt).toContain("Discord supports markdown like **bold**, _italic_, and code fences.");
expect(systemPrompt).toContain("ODE CLI:");
expect(systemPrompt).not.toContain("DISCORD ACTIONS:");
expect(systemPrompt).not.toContain("/api/action");
});

it("builds Lark action instructions for Lark context", () => {
it("builds Lark-specific formatting guidance for Lark context", () => {
const systemPrompt = buildSystemPrompt({
platform: "lark",
channelId: "oc_123",
Expand All @@ -86,10 +87,26 @@ describe("agent cli command formatting", () => {
});

expect(systemPrompt).toContain("LARK CONTEXT:");
expect(systemPrompt).toContain("LARK ACTIONS:");
expect(systemPrompt).toContain('"platform":"lark"');
expect(systemPrompt).toContain("Supported actions: get_channels, post_message, update_message, get_thread_messages, ask_user, add_reaction, get_user_info, upload_file.");
expect(systemPrompt).toContain("Lark output should be plain text for now; do not rely on markdown styling.");
expect(systemPrompt).toContain("ODE CLI:");
expect(systemPrompt).not.toContain("LARK ACTIONS:");
expect(systemPrompt).not.toContain("Supported actions:");
});

it("recommends Ode CLI commands with the current channel/thread baked in", () => {
const systemPrompt = buildSystemPrompt({
platform: "slack",
channelId: "C9XXX",
threadId: "1700000000.000001",
userId: "U42",
});

expect(systemPrompt).toContain("ode task create");
expect(systemPrompt).toContain("ode cron create");
expect(systemPrompt).toContain("ode send file /tmp/screenshot.png --channel C9XXX --thread 1700000000.000001");
expect(systemPrompt).toContain("ode messages get 1700000000.000001 --channel C9XXX");
expect(systemPrompt).toContain("ode reaction add");
expect(systemPrompt).toContain("VISUAL TESTING:");
});

it("builds the OpenCode curl command", () => {
Expand Down
1 change: 0 additions & 1 deletion packages/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export interface PlatformContext {
threadId: string;
userId: string;
threadHistory?: string;
odeSlackApiUrl?: string;
hasGitHubToken?: boolean;
channelSystemMessage?: string;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@ export {
type StatusMessageFrequencyValue,
} from "./status-message-frequency";

export { getSlackActionApiUrl, getWebHost, getWebPort } from "./network";
export { getWebHost, getWebPort } from "./network";

export * as local from "./local";
31 changes: 31 additions & 0 deletions packages/config/local/cron-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ export type CreateCronJobParams = {
enabled?: boolean;
};

export type PatchCronJobParams = {
title?: string;
cronExpression?: string;
channelId?: string;
messageText?: string;
enabled?: boolean;
};

type CronJobRow = {
id: string;
title: string;
Expand Down Expand Up @@ -353,6 +361,29 @@ export function deleteCronJob(id: string): void {
db.query("DELETE FROM cron_jobs WHERE id = ?").run(id);
}

/**
* Apply a partial update to an existing cron job. Only the provided fields
* are changed; omitting a key preserves the current value. This powers the
* `ode cron update` / `ode cron enable` / `ode cron disable` CLI flows where
* callers typically want to flip a single attribute without re-specifying the
* whole record.
*/
export function patchCronJob(id: string, params: PatchCronJobParams): CronJobRecord {
const existing = getCronJobById(id);
if (!existing) {
throw new Error("Cron job not found");
}

const merged: CreateCronJobParams = {
title: params.title ?? existing.title,
cronExpression: params.cronExpression ?? existing.cronExpression,
channelId: params.channelId ?? existing.channelId,
messageText: params.messageText ?? existing.messageText,
enabled: params.enabled ?? existing.enabled,
};
return updateCronJob(id, merged);
}

export function markCronJobTriggered(id: string, minuteStartMs: number): boolean {
const db = getDatabase();
const result = db.query(`
Expand Down
4 changes: 0 additions & 4 deletions packages/config/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,3 @@ export function getWebHost(): string {
export function getWebPort(): number {
return parsePort(process.env.ODE_WEB_PORT?.trim(), DEFAULT_WEB_PORT);
}

export function getSlackActionApiUrl(): string {
return `http://${getWebHost()}:${getWebPort()}/api`;
}
Loading
Loading