Skip to content

Commit f9ba347

Browse files
authored
[codex] Add IM Inbox workflows and custom commands (#37)
* Polish AgentForge UI * Add IM Inbox product spec * Add IM Inbox task brief foundation * Add Slack IM brief fallback commands * Add Feishu IM brief fallback commands * Add Weixin IM brief fallback commands * Add Telegram streaming and brief fallback * Add IM runbooks implementation plan * Add shared IM runbook registry * Add IM runbook persistence * Add scheduler runbook actions * Add IM runbook API * Add IM runbook text fallback * Complete IM runbooks phase * Add IM digests implementation plan * Add IM digest composer * Add scheduler IM digest action * Add IM digest API * Complete IM digests phase * Add IM skill suggestions implementation plan * Add IM skill suggestion renderer * Persist IM skill suggestion state * Add IM skill suggestion actions * Add IM skill suggestion text commands * Format IM skill suggestion changes * Minimize Telegram IM changes * Pivot IM runbooks to custom commands * Explain custom commands in IM help
1 parent ca067ba commit f9ba347

45 files changed

Lines changed: 11856 additions & 1435 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/api.ts

Lines changed: 870 additions & 4 deletions
Large diffs are not rendered by default.

backend/src/bus.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ export const InboundMessageType = {
3131
RESPOND_TASK: "respond_task", // answer a question a task is waiting on
3232
CANCEL_TASK: "cancel_task", // cancel a task
3333
STATUS_QUERY: "status_query", // query task status
34+
CREATE_BRIEF: "create_brief", // create a draft task brief
35+
CONFIRM_BRIEF: "confirm_brief", // convert a draft brief into a task
36+
DISCARD_BRIEF: "discard_brief", // discard a draft brief
37+
PREVIEW_RUNBOOK: "preview_runbook", // create a draft preview from an IM runbook
38+
RUN_RUNBOOK: "run_runbook", // run an IM runbook or create a confirmation draft
39+
TRIGGER_DIGEST: "trigger_digest", // preview or send an IM digest
40+
SKILL_SUGGESTION_ACTION: "skill_suggestion_action", // draft/show/approve/dismiss a skill suggestion
3441
} as const;
3542
export type InboundMessageType =
3643
(typeof InboundMessageType)[keyof typeof InboundMessageType];
@@ -65,6 +72,13 @@ function utcNowIso(): string {
6572
* RESPOND_TASK -> {"task_id", "answer"}
6673
* CANCEL_TASK -> {"task_id"}
6774
* STATUS_QUERY -> {"task_id"}
75+
* CREATE_BRIEF -> {"title", "goal", "source_channel", "source_ref", ...}
76+
* CONFIRM_BRIEF -> {"brief_id"}
77+
* DISCARD_BRIEF -> {"brief_id"}
78+
* PREVIEW_RUNBOOK -> {"name", "raw_args", "source_channel", "source_ref", ...}
79+
* RUN_RUNBOOK -> {"name", "raw_args", "source_channel", "source_ref", ...}
80+
* TRIGGER_DIGEST -> {"include_empty", "limit", "since"}
81+
* SKILL_SUGGESTION_ACTION -> {"action", "pattern_id", "source_channel", "target"}
6882
* reply_to: optional reply target (e.g. Feishu chat_id / open_id).
6983
* metadata: channel-specific context (e.g. Feishu message_id).
7084
*/
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import {
2+
parse_runbook_command,
3+
runbook_from_row,
4+
type ParsedRunbookCommand,
5+
type RunbookDefinition,
6+
} from "../runbooks.ts";
7+
8+
export type { ParsedRunbookCommand } from "../runbooks.ts";
9+
10+
type Row = Record<string, unknown>;
11+
type RunbookDB = {
12+
get_im_runbooks?: (enabled_only?: boolean) => Row[];
13+
};
14+
15+
export type BriefCommand =
16+
| { action: "create"; goal: string }
17+
| { action: "confirm"; brief_id: number }
18+
| { action: "discard"; brief_id: number }
19+
| { action: "help"; reason: "invalid_brief_id" };
20+
type BriefHelpReason = Extract<BriefCommand, { action: "help" }>["reason"];
21+
22+
export type SkillSuggestionCommand =
23+
| { action: "draft" | "show" | "approve" | "dismiss"; pattern_id: number }
24+
| { action: "help"; reason: "invalid_pattern_id" };
25+
type SkillSuggestionHelpReason = Extract<
26+
SkillSuggestionCommand,
27+
{ action: "help" }
28+
>["reason"];
29+
30+
function parseBriefId(value: string): number | null {
31+
const raw = value.trim().replace(/^#+/, "");
32+
if (!/^\d+$/.test(raw)) return null;
33+
const id = Number.parseInt(raw, 10);
34+
return Number.isInteger(id) && id > 0 ? id : null;
35+
}
36+
37+
export function parse_brief_command(text: string): BriefCommand | null {
38+
const trimmed = text.trim();
39+
const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/i.exec(trimmed);
40+
if (!match) return null;
41+
const cmd = match[1]!.toLowerCase();
42+
const args = (match[2] ?? "").trim();
43+
44+
if (
45+
cmd === "run-draft" ||
46+
cmd === "confirm-draft" ||
47+
cmd === "confirm-brief" ||
48+
cmd === "run-brief"
49+
) {
50+
const brief_id = parseBriefId(args);
51+
return brief_id === null
52+
? { action: "help", reason: "invalid_brief_id" }
53+
: { action: "confirm", brief_id };
54+
}
55+
if (
56+
cmd === "cancel-draft" ||
57+
cmd === "discard-draft" ||
58+
cmd === "discard-brief"
59+
) {
60+
const brief_id = parseBriefId(args);
61+
return brief_id === null
62+
? { action: "help", reason: "invalid_brief_id" }
63+
: { action: "discard", brief_id };
64+
}
65+
return null;
66+
}
67+
68+
export function parse_skill_suggestion_command(
69+
text: string,
70+
): SkillSuggestionCommand | null {
71+
const trimmed = text.trim();
72+
const match = /^\/([a-z-]+)(?:\s+([\s\S]*))?$/i.exec(trimmed);
73+
if (!match) return null;
74+
const cmd = match[1]!.toLowerCase();
75+
const args = (match[2] ?? "").trim();
76+
const commandToAction: Record<
77+
string,
78+
"draft" | "show" | "approve" | "dismiss"
79+
> = {
80+
"draft-skill": "draft",
81+
"show-skill": "show",
82+
"review-skill": "show",
83+
"approve-skill": "approve",
84+
"dismiss-skill": "dismiss",
85+
};
86+
const action = commandToAction[cmd];
87+
if (!action) return null;
88+
const pattern_id = parseBriefId(args);
89+
return pattern_id === null
90+
? { action: "help", reason: "invalid_pattern_id" }
91+
: { action, pattern_id };
92+
}
93+
94+
function titleFromGoal(goal: string): string {
95+
const singleLine = goal.replace(/\s+/g, " ").trim();
96+
return singleLine.length > 60 ? `${singleLine.slice(0, 57)}...` : singleLine;
97+
}
98+
99+
export function build_brief_payload(args: {
100+
channel: string;
101+
goal: string;
102+
source_ref: string;
103+
source_metadata?: Row;
104+
working_dir?: string | null;
105+
agent?: string | null;
106+
}): Row {
107+
return {
108+
title: titleFromGoal(args.goal),
109+
goal: args.goal.trim(),
110+
context_summary: "",
111+
acceptance_criteria: [],
112+
working_dir: args.working_dir ?? null,
113+
working_dir_confidence: "unknown",
114+
agent: args.agent ?? null,
115+
risk_level: "normal",
116+
needs_confirmation: true,
117+
source_channel: args.channel,
118+
source_ref: args.source_ref,
119+
source_metadata: args.source_metadata ?? {},
120+
};
121+
}
122+
123+
function has_runbook_db(value: unknown): value is RunbookDB {
124+
return (
125+
typeof value === "object" &&
126+
value !== null &&
127+
"get_im_runbooks" in value &&
128+
typeof (value as RunbookDB).get_im_runbooks === "function"
129+
);
130+
}
131+
132+
function runbook_definitions_from_db(db: unknown): RunbookDefinition[] {
133+
if (!has_runbook_db(db)) return [];
134+
try {
135+
const get_im_runbooks = db.get_im_runbooks;
136+
if (!get_im_runbooks) return [];
137+
return get_im_runbooks.call(db, true).map((row) => runbook_from_row(row));
138+
} catch {
139+
return [];
140+
}
141+
}
142+
143+
export function parse_runbook_fallback(
144+
text: string,
145+
db: unknown = null,
146+
): ParsedRunbookCommand | null {
147+
return parse_runbook_command(text, runbook_definitions_from_db(db));
148+
}
149+
150+
export function build_runbook_payload(args: {
151+
channel: string;
152+
command: ParsedRunbookCommand;
153+
source_ref: string;
154+
source_metadata?: Row;
155+
working_dir?: string | null;
156+
agent?: string | null;
157+
}): Row {
158+
return {
159+
name: args.command.name,
160+
raw_args: args.command.raw_args,
161+
source_channel: args.channel,
162+
source_ref: args.source_ref,
163+
source_metadata: args.source_metadata ?? {},
164+
working_dir: args.working_dir ?? null,
165+
agent: args.agent ?? null,
166+
};
167+
}
168+
169+
export function format_brief_help(_reason: BriefHelpReason): string {
170+
return "Usage: `/run-draft <draft_id>` or `/cancel-draft <draft_id>`";
171+
}
172+
173+
export function format_skill_suggestion_help(
174+
_reason: SkillSuggestionHelpReason,
175+
): string {
176+
return "Usage: `/draft-skill <pattern_id>`, `/show-skill <pattern_id>`, `/approve-skill <pattern_id>`, or `/dismiss-skill <pattern_id>`";
177+
}
178+
179+
export function format_skill_suggestion_action_reply(
180+
result: Record<string, unknown>,
181+
): string {
182+
const pattern_id = Number(result["pattern_id"]);
183+
const id = Number.isInteger(pattern_id) && pattern_id > 0 ? pattern_id : "?";
184+
const status = String(result["status"] ?? "");
185+
if (status === "drafting") {
186+
return `Skill draft for pattern #${id} started. Review it with \`/show-skill ${id}\` when ready.`;
187+
}
188+
if (status === "ready") {
189+
return String(result["text"] ?? `Skill draft for pattern #${id} is ready.`);
190+
}
191+
if (status === "approved") {
192+
return `Skill suggestion #${id} approved and installed.`;
193+
}
194+
if (status === "dismissed") {
195+
return `Skill suggestion #${id} dismissed.`;
196+
}
197+
return `Skill suggestion #${id} updated.`;
198+
}
199+
200+
export function format_brief_created_reply(
201+
brief_id: number,
202+
title: string,
203+
): string {
204+
return [
205+
`Draft task #${brief_id}: ${title}`,
206+
"",
207+
`Run: \`/run-draft ${brief_id}\``,
208+
`Cancel: \`/cancel-draft ${brief_id}\``,
209+
].join("\n");
210+
}
211+
212+
export function format_brief_started_reply(
213+
brief_id: number,
214+
task_id: number,
215+
): string {
216+
return `Task #${task_id} created from draft #${brief_id}. Thinking ▌`;
217+
}
218+
219+
export function format_brief_discarded_reply(brief_id: number): string {
220+
return `Draft task #${brief_id} discarded.`;
221+
}
222+
223+
export function format_runbook_created_reply(
224+
task_id: number,
225+
runbook: string,
226+
): string {
227+
return `Command /${runbook} created Task #${task_id}. Thinking ▌`;
228+
}
229+
230+
export function format_runbook_brief_reply(
231+
brief_id: number,
232+
runbook: string,
233+
): string {
234+
return [
235+
`Command /${runbook} created Draft task #${brief_id}.`,
236+
"",
237+
`Run: \`/run-draft ${brief_id}\``,
238+
`Cancel: \`/cancel-draft ${brief_id}\``,
239+
].join("\n");
240+
}

0 commit comments

Comments
 (0)