loopengine 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/core/agent-config.d.ts +2 -0
  2. package/dist/core/http-notifier.js +14 -3
  3. package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
  4. package/dist/core/http-notify-triggers/telegram.js +63 -17
  5. package/dist/core/messaging/connector.d.ts +7 -0
  6. package/dist/core/messaging/connector.js +1 -1
  7. package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
  8. package/dist/core/messaging/connectors/slack/events.js +6 -2
  9. package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
  10. package/dist/core/messaging/connectors/slack/setup.js +10 -5
  11. package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
  12. package/dist/core/messaging/connectors/telegram/format.js +57 -0
  13. package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
  14. package/dist/core/messaging/connectors/telegram/index.js +136 -0
  15. package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
  16. package/dist/core/messaging/connectors/telegram/store.js +69 -0
  17. package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
  18. package/dist/core/messaging/connectors/telegram/updates.js +112 -0
  19. package/dist/core/messaging/jobs.d.ts +44 -0
  20. package/dist/core/messaging/jobs.js +100 -0
  21. package/dist/core/messaging/threads.d.ts +12 -0
  22. package/dist/core/messaging/threads.js +36 -0
  23. package/dist/core/notifier-file.d.ts +13 -5
  24. package/dist/core/notifier-file.js +41 -6
  25. package/dist/core/system-agents/workflow-planner.d.ts +37 -4
  26. package/dist/core/system-agents/workflow-planner.js +79 -5
  27. package/dist/core/workflow-triggers.d.ts +15 -5
  28. package/dist/core/workflow-triggers.js +25 -8
  29. package/dist/index.d.ts +6 -3
  30. package/dist/index.js +6 -3
  31. package/dist/web/agents-config-page.js +168 -50
  32. package/dist/web/notifier-admin.d.ts +69 -13
  33. package/dist/web/notifier-admin.js +127 -31
  34. package/dist/web/workflow-admin.d.ts +10 -0
  35. package/dist/web/workflow-admin.js +42 -3
  36. package/dist/web/workflow-page.js +93 -2
  37. package/package.json +1 -1
@@ -0,0 +1,69 @@
1
+ // What Telegram won't tell us later: the Bot API has no way to read a
2
+ // chat's history, so the messages in the agent's threads are kept here as
3
+ // they pass through, in agents/<name>/.messaging/telegram.jsonl, along
4
+ // with the people seen (for names in replies and the approver picker).
5
+ //
6
+ // Telegram has no threads either. A "thread" here is a chain of replies:
7
+ // its first message's id is the thread id, and each later message that
8
+ // replies into it (a person's reply, or the agent's) is recorded with
9
+ // that root, so a reply to any message in the chain finds its thread.
10
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ // Past this, the file is cut to its newer half on the next write.
13
+ const MAX_BYTES = 4 * 1024 * 1024;
14
+ function storeFile(agentDir) {
15
+ return join(agentDir, '.messaging', 'telegram.jsonl');
16
+ }
17
+ function readEntries(agentDir) {
18
+ const path = storeFile(agentDir);
19
+ if (!existsSync(path))
20
+ return [];
21
+ const entries = [];
22
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
23
+ if (!line)
24
+ continue;
25
+ try {
26
+ entries.push(JSON.parse(line));
27
+ }
28
+ catch { }
29
+ }
30
+ return entries;
31
+ }
32
+ /** Never throws: a message that can't be kept only loses its context. */
33
+ export function recordTelegramMessage(agentDir, entry) {
34
+ try {
35
+ mkdirSync(join(agentDir, '.messaging'), { recursive: true });
36
+ const path = storeFile(agentDir);
37
+ if (existsSync(path) && statSync(path).size > MAX_BYTES) {
38
+ const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
39
+ writeFileSync(path, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n');
40
+ }
41
+ appendFileSync(path, JSON.stringify({ ...entry, text: entry.text.slice(0, 4000), at: new Date().toISOString() }) + '\n');
42
+ }
43
+ catch (err) {
44
+ console.error('[loopengine] could not keep the Telegram message:', err instanceof Error ? err.message : err);
45
+ }
46
+ }
47
+ /** The thread a message belongs to: its recorded root, else the message
48
+ * itself. */
49
+ export function telegramRootOf(agentDir, chat, id) {
50
+ return readEntries(agentDir).find((e) => e.chat === chat && e.id === id)?.root ?? id;
51
+ }
52
+ /** A thread's messages, oldest first, at most the latest 100. */
53
+ export function readTelegramThread(agentDir, chat, root) {
54
+ return readEntries(agentDir)
55
+ .filter((e) => e.chat === chat && e.root === root)
56
+ .slice(-100)
57
+ .map((e) => ({ ...(e.user ? { user: e.user } : {}), fromBot: e.fromBot, text: e.text, ts: e.id }));
58
+ }
59
+ /** Everyone who has written to the agent, newest first, optionally in
60
+ * one chat only. */
61
+ export function telegramPeople(agentDir, chat) {
62
+ const people = new Map();
63
+ for (const e of readEntries(agentDir).reverse()) {
64
+ if (!e.user || people.has(e.user) || (chat && e.chat !== chat))
65
+ continue;
66
+ people.set(e.user, { id: e.user, name: e.name || (e.username ? `@${e.username}` : e.user), ...(e.username ? { username: e.username } : {}) });
67
+ }
68
+ return [...people.values()];
69
+ }
@@ -0,0 +1,40 @@
1
+ import type { InboundMessage, MessagingAction } from '../../connector.js';
2
+ export declare function verifyTelegramRequest(header: string | undefined, secret: string): boolean;
3
+ export type TelegramButton = {
4
+ type: 'decide';
5
+ decision: 'approve' | 'deny';
6
+ } | {
7
+ type: 'answer_option';
8
+ index: number;
9
+ } | {
10
+ type: 'action';
11
+ action: Exclude<MessagingAction, 'deny'>;
12
+ };
13
+ /** The callback_data for a button. */
14
+ export declare function telegramCallbackData(button: TelegramButton, pendingId: string): string;
15
+ export type TelegramUpdate = ({
16
+ type: 'message';
17
+ username?: string;
18
+ } & InboundMessage) | {
19
+ type: 'click';
20
+ /** answerCallbackQuery takes it, to stop the button's spinner. */
21
+ callbackId: string;
22
+ conversation: string;
23
+ /** The message the button is on. */
24
+ messageId: string;
25
+ user: string;
26
+ userName?: string;
27
+ username?: string;
28
+ button: TelegramButton;
29
+ pendingId: string;
30
+ } | {
31
+ type: 'ignore';
32
+ };
33
+ /** Reads an Update. Only people's new text messages and clicks on the
34
+ * agent's buttons come through. `botUsername` (without @) is how a
35
+ * mention is spotted; `rootOf` finds the thread a replied-to message is
36
+ * in (./store.ts). */
37
+ export declare function parseTelegramUpdate(rawBody: string, options: {
38
+ botUsername?: string;
39
+ rootOf: (chat: string, id: string) => string;
40
+ }): TelegramUpdate;
@@ -0,0 +1,112 @@
1
+ // Reading what Telegram sends to POST /hooks/telegram/<agent>: checking
2
+ // it came from Telegram, and turning an Update into a person's message or
3
+ // a button click.
4
+ //
5
+ // Telegram signs nothing; instead setWebhook is given a secret_token that
6
+ // Telegram sends back in X-Telegram-Bot-Api-Secret-Token with every
7
+ // update (./setup.ts makes one per agent).
8
+ //
9
+ // Buttons carry `<code>:<pendingId>` as callback_data, which Telegram
10
+ // caps at 64 bytes (a pendingId is a 36-character UUID): a/d for a
11
+ // request's Approve/Deny, q<n> for a question's n-th option, and the
12
+ // engine's own thread buttons (core/messaging/engine.ts).
13
+ import { timingSafeEqual } from 'node:crypto';
14
+ export function verifyTelegramRequest(header, secret) {
15
+ if (!header)
16
+ return false;
17
+ const expected = Buffer.from(secret);
18
+ const actual = Buffer.from(header);
19
+ return expected.length === actual.length && timingSafeEqual(expected, actual);
20
+ }
21
+ const ACTION_CODES = {
22
+ approve_edited: 'ae',
23
+ keep_original: 'ko',
24
+ confirm: 'c',
25
+ cancel_confirm: 'cc',
26
+ };
27
+ /** The callback_data for a button. */
28
+ export function telegramCallbackData(button, pendingId) {
29
+ const code = button.type === 'decide' ? (button.decision === 'approve' ? 'a' : 'd') : button.type === 'answer_option' ? `q${button.index}` : ACTION_CODES[button.action];
30
+ return `${code}:${pendingId}`;
31
+ }
32
+ function readCallbackData(data) {
33
+ const match = /^([a-z]+)(\d*):(.+)$/.exec(data);
34
+ if (!match)
35
+ return undefined;
36
+ const [, code, index, pendingId] = match;
37
+ if (code === 'a' && !index)
38
+ return { button: { type: 'decide', decision: 'approve' }, pendingId };
39
+ if (code === 'd' && !index)
40
+ return { button: { type: 'decide', decision: 'deny' }, pendingId };
41
+ if (code === 'q' && index)
42
+ return { button: { type: 'answer_option', index: Number(index) }, pendingId };
43
+ const action = Object.keys(ACTION_CODES).find((a) => ACTION_CODES[a] === code);
44
+ return action && !index ? { button: { type: 'action', action }, pendingId } : undefined;
45
+ }
46
+ const nameOf = (u) => [u.first_name, u.last_name].filter(Boolean).join(' ') || undefined;
47
+ /** Reads an Update. Only people's new text messages and clicks on the
48
+ * agent's buttons come through. `botUsername` (without @) is how a
49
+ * mention is spotted; `rootOf` finds the thread a replied-to message is
50
+ * in (./store.ts). */
51
+ export function parseTelegramUpdate(rawBody, options) {
52
+ let update;
53
+ try {
54
+ update = JSON.parse(rawBody);
55
+ }
56
+ catch {
57
+ return { type: 'ignore' };
58
+ }
59
+ const click = update.callback_query;
60
+ if (click) {
61
+ const parsed = click.data ? readCallbackData(click.data) : undefined;
62
+ if (!parsed || !click.id || !click.from?.id || click.message?.message_id === undefined || click.message.chat?.id === undefined)
63
+ return { type: 'ignore' };
64
+ return {
65
+ type: 'click',
66
+ callbackId: click.id,
67
+ conversation: String(click.message.chat.id),
68
+ messageId: String(click.message.message_id),
69
+ user: String(click.from.id),
70
+ ...(nameOf(click.from) ? { userName: nameOf(click.from) } : {}),
71
+ ...(click.from.username ? { username: click.from.username } : {}),
72
+ ...parsed,
73
+ };
74
+ }
75
+ const message = update.message;
76
+ if (update.update_id === undefined || !message || message.message_id === undefined || message.chat?.id === undefined)
77
+ return { type: 'ignore' };
78
+ if (!message.from?.id || message.from.is_bot || typeof message.text !== 'string')
79
+ return { type: 'ignore' };
80
+ const chat = String(message.chat.id);
81
+ const bot = options.botUsername?.toLowerCase();
82
+ let text = message.text;
83
+ let mentioned = false;
84
+ if (bot) {
85
+ // @bot as a mention entity, or a command addressed to it (/do@bot).
86
+ for (const entity of message.entities ?? []) {
87
+ const part = text.slice(entity.offset ?? 0, (entity.offset ?? 0) + (entity.length ?? 0)).toLowerCase();
88
+ if ((entity.type === 'mention' && part === `@${bot}`) || (entity.type === 'bot_command' && part.endsWith(`@${bot}`)))
89
+ mentioned = true;
90
+ }
91
+ if (mentioned)
92
+ text = text.replace(new RegExp(`@${bot}\\b`, 'ig'), '').trim();
93
+ }
94
+ // Opening a chat with a bot sends /start; it isn't a job.
95
+ if (/^\/start(\s|$)/.test(text))
96
+ text = text.replace(/^\/start\s*/, '');
97
+ const replyTo = message.reply_to_message?.message_id;
98
+ return {
99
+ type: 'message',
100
+ app: 'telegram',
101
+ eventId: String(update.update_id),
102
+ conversation: chat,
103
+ user: String(message.from.id),
104
+ text,
105
+ ts: String(message.message_id),
106
+ ...(replyTo !== undefined ? { threadTs: options.rootOf(chat, String(replyTo)) } : {}),
107
+ ...(message.chat.type === 'private' ? { direct: true } : {}),
108
+ ...(mentioned ? { mentioned: true } : {}),
109
+ ...(nameOf(message.from) ? { userName: nameOf(message.from) } : {}),
110
+ ...(message.from.username ? { username: message.from.username } : {}),
111
+ };
112
+ }
@@ -0,0 +1,44 @@
1
+ import type { InboundMessage, MessagingConnector } from './connector.js';
2
+ import { type JobThread } from './threads.js';
3
+ export type JobTurn = {
4
+ kind: 'done';
5
+ text: string;
6
+ } | {
7
+ kind: 'pending';
8
+ items: Array<{
9
+ pendingId: string;
10
+ kind: 'approval' | 'question';
11
+ tool: string;
12
+ question?: string;
13
+ }>;
14
+ };
15
+ export interface JobDeps {
16
+ agentName: string;
17
+ agentDir: string;
18
+ connector: MessagingConnector;
19
+ plan?: string;
20
+ /** Whether this person may start a job, or continue one, here. */
21
+ allowed(message: InboundMessage): {
22
+ allowed: boolean;
23
+ message?: string;
24
+ };
25
+ /** One agent turn in `sessionId` with the person's message. */
26
+ runTurn(sessionId: string, text: string): Promise<JobTurn>;
27
+ /** The decision page for a pending item, when the server's address is known. */
28
+ decideLink?(pendingId: string, kind: 'approval' | 'question'): string | undefined;
29
+ }
30
+ /** A session id for a job thread, from the app's own ids: one per thread
31
+ * in a channel, one per DM (so a DM keeps its context from one message to
32
+ * the next). Only characters a session id can hold. */
33
+ export declare function jobSessionId(message: Pick<InboundMessage, 'app' | 'conversation' | 'direct'>, threadTs: string): string;
34
+ /** The job thread a message continues, if any: the thread it's in, or,
35
+ * in a DM, the DM itself. */
36
+ export declare function findJobFor(agentDir: string, message: InboundMessage): JobThread | undefined;
37
+ /** Whether a message starts or continues a job: a DM, a mention, or any
38
+ * message in a job's thread. */
39
+ export declare function isJobMessage(agentDir: string, message: InboundMessage): boolean;
40
+ /** Runs the agent on a job message and answers in its thread. */
41
+ export declare function handleJobMessage(message: InboundMessage, deps: JobDeps): Promise<void>;
42
+ /** Posts a resumed run's outcome in the job thread it came from, if it
43
+ * came from one. */
44
+ export declare function settleJobThread(deps: Pick<JobDeps, 'agentDir' | 'connector' | 'decideLink'>, sessionId: string, turn: JobTurn): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { findJobThread, findJobThreadBySession, recordJobThread, recordThread } from './threads.js';
2
+ /** A session id for a job thread, from the app's own ids: one per thread
3
+ * in a channel, one per DM (so a DM keeps its context from one message to
4
+ * the next). Only characters a session id can hold. */
5
+ export function jobSessionId(message, threadTs) {
6
+ const safe = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_');
7
+ return message.direct ? `${message.app}-dm-${safe(message.conversation)}` : `${message.app}-${safe(message.conversation)}-${safe(threadTs)}`;
8
+ }
9
+ /** The job thread a message continues, if any: the thread it's in, or,
10
+ * in a DM, the DM itself. */
11
+ export function findJobFor(agentDir, message) {
12
+ const threadTs = message.threadTs ?? message.ts;
13
+ const inThread = findJobThread(agentDir, message.app, message.conversation, threadTs);
14
+ if (inThread)
15
+ return inThread;
16
+ if (!message.direct)
17
+ return undefined;
18
+ const sessionId = jobSessionId(message, threadTs);
19
+ return findJobThreadBySession(agentDir, sessionId);
20
+ }
21
+ /** Whether a message starts or continues a job: a DM, a mention, or any
22
+ * message in a job's thread. */
23
+ export function isJobMessage(agentDir, message) {
24
+ return Boolean(message.direct || message.mentioned || findJobFor(agentDir, message));
25
+ }
26
+ // One turn at a time per session: a second message waits for the first's
27
+ // answer rather than racing it.
28
+ const sessionLocks = new Map();
29
+ function inSession(key, fn) {
30
+ const previous = sessionLocks.get(key) ?? Promise.resolve();
31
+ const next = previous.then(fn, fn);
32
+ const settled = next.catch(() => { });
33
+ sessionLocks.set(key, settled);
34
+ void settled.then(() => {
35
+ if (sessionLocks.get(key) === settled)
36
+ sessionLocks.delete(key);
37
+ });
38
+ return next;
39
+ }
40
+ /** Runs the agent on a job message and answers in its thread. */
41
+ export async function handleJobMessage(message, deps) {
42
+ const { connector } = deps;
43
+ const threadTs = message.threadTs ?? message.ts;
44
+ const say = (text) => connector.postInThread(message.conversation, threadTs, text);
45
+ if (deps.plan === 'free') {
46
+ await say(`Starting jobs from ${connector.name} needs LoopEngine Pro.`);
47
+ return;
48
+ }
49
+ const gate = deps.allowed(message);
50
+ if (!gate.allowed) {
51
+ await say(gate.message ?? `You can't start jobs for ${deps.agentName} here.`);
52
+ return;
53
+ }
54
+ const text = message.text.trim();
55
+ if (!text) {
56
+ await say('What should I do? Write the job in the same message.');
57
+ return;
58
+ }
59
+ const sessionId = findJobFor(deps.agentDir, message)?.sessionId ?? jobSessionId(message, threadTs);
60
+ // Recorded per message, so a resumed run answers in the thread of the
61
+ // message that started it.
62
+ recordJobThread(deps.agentDir, { app: message.app, conversation: message.conversation, ts: threadTs, sessionId });
63
+ await inSession(`${deps.agentName}:${sessionId}`, async () => {
64
+ let turn;
65
+ try {
66
+ turn = await deps.runTurn(sessionId, text);
67
+ }
68
+ catch (err) {
69
+ await say(`That didn't work: ${connector.escape(err instanceof Error ? err.message : String(err))}`);
70
+ return;
71
+ }
72
+ await postTurn(deps, { app: message.app, conversation: message.conversation, ts: threadTs, sessionId }, turn);
73
+ });
74
+ }
75
+ async function postTurn(deps, job, turn) {
76
+ const say = (text) => deps.connector.postInThread(job.conversation, job.ts, text);
77
+ if (turn.kind === 'done') {
78
+ await say(turn.text.trim() || 'Done.');
79
+ return;
80
+ }
81
+ for (const item of turn.items) {
82
+ // The thread is linked to the request, so a reply here can ask about
83
+ // it or decide it (core/messaging/engine.ts).
84
+ recordThread(deps.agentDir, { app: job.app, conversation: job.conversation, ts: job.ts, pendingId: item.pendingId, kind: item.kind });
85
+ const link = deps.decideLink?.(item.pendingId, item.kind);
86
+ const head = item.kind === 'approval'
87
+ ? `This needs approval before it runs \`${item.tool}\`.`
88
+ : `A question before I go on: ${deps.connector.escape(item.question ?? '')}`;
89
+ const how = item.kind === 'approval' ? 'An approver can reply here to approve or deny it' : 'Reply here to answer it';
90
+ await say(`${head}\n${how}${link ? `, or [open the request](${link})` : ''}. I'll carry on in this thread once it's decided.`);
91
+ }
92
+ }
93
+ /** Posts a resumed run's outcome in the job thread it came from, if it
94
+ * came from one. */
95
+ export async function settleJobThread(deps, sessionId, turn) {
96
+ const job = findJobThreadBySession(deps.agentDir, sessionId);
97
+ if (!job || job.app !== deps.connector.app)
98
+ return;
99
+ await postTurn(deps, job, turn);
100
+ }
@@ -22,3 +22,15 @@ export declare function recordThread(agentDir: string, link: ThreadLink): void;
22
22
  export declare function findThreadByPending(agentDir: string, pendingId: string): ThreadLink | undefined;
23
23
  /** The request a thread belongs to, or undefined for any other thread. */
24
24
  export declare function findThread(agentDir: string, app: string, conversation: string, ts: string): ThreadLink | undefined;
25
+ export interface JobThread {
26
+ app: string;
27
+ conversation: string;
28
+ /** The thread's first message id. */
29
+ ts: string;
30
+ sessionId: string;
31
+ }
32
+ /** Never throws, like recordThread. */
33
+ export declare function recordJobThread(agentDir: string, job: JobThread): void;
34
+ export declare function findJobThread(agentDir: string, app: string, conversation: string, ts: string): JobThread | undefined;
35
+ /** The session's latest job thread: where its last message came from. */
36
+ export declare function findJobThreadBySession(agentDir: string, sessionId: string): JobThread | undefined;
@@ -42,3 +42,39 @@ function readLinks(agentDir) {
42
42
  export function findThread(agentDir, app, conversation, ts) {
43
43
  return readLinks(agentDir).filter((link) => link.app === app && link.conversation === conversation && link.ts === ts).at(-1);
44
44
  }
45
+ function jobsFile(agentDir) {
46
+ return join(agentDir, '.messaging', 'jobs.jsonl');
47
+ }
48
+ function readJobs(agentDir) {
49
+ const path = jobsFile(agentDir);
50
+ if (!existsSync(path))
51
+ return [];
52
+ const jobs = [];
53
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
54
+ if (!line)
55
+ continue;
56
+ try {
57
+ jobs.push(JSON.parse(line));
58
+ }
59
+ catch { }
60
+ }
61
+ return jobs;
62
+ }
63
+ /** Never throws, like recordThread. */
64
+ export function recordJobThread(agentDir, job) {
65
+ try {
66
+ mkdirSync(join(agentDir, '.messaging'), { recursive: true });
67
+ const { app, conversation, ts, sessionId } = job;
68
+ appendFileSync(jobsFile(agentDir), JSON.stringify({ app, conversation, ts, sessionId, at: new Date().toISOString() }) + '\n');
69
+ }
70
+ catch (err) {
71
+ console.error('[loopengine] could not record the job thread:', err instanceof Error ? err.message : err);
72
+ }
73
+ }
74
+ export function findJobThread(agentDir, app, conversation, ts) {
75
+ return readJobs(agentDir).filter((job) => job.app === app && job.conversation === conversation && job.ts === ts).at(-1);
76
+ }
77
+ /** The session's latest job thread: where its last message came from. */
78
+ export function findJobThreadBySession(agentDir, sessionId) {
79
+ return readJobs(agentDir).filter((job) => job.sessionId === sessionId).at(-1);
80
+ }
@@ -22,15 +22,18 @@ export interface NotifierFile {
22
22
  * remembered from the console's own address when saving.
23
23
  * LOOPENGINE_PUBLIC_URL, when set, wins over it. */
24
24
  publicUrl?: string;
25
- /** slack: answer replies in a request's thread (core/messaging/).
26
- * Unset counts as on. */
25
+ /** slack, telegram: answer replies in a request's thread
26
+ * (core/messaging/). Unset counts as on. */
27
27
  conversations?: boolean;
28
- /** slack: the people who can decide a request by replying in its
29
- * thread, and whose button clicks count once any are listed. */
28
+ /** slack, telegram: the people who can decide a request by replying in
29
+ * its thread, and whose button clicks count once any are listed. */
30
30
  approvers?: MessagingApprover[];
31
+ /** slack, telegram: a DM to the bot, or a mention of it, starts a job
32
+ * (core/messaging/jobs.ts). Unset counts as off. */
33
+ jobs?: boolean;
31
34
  }
32
35
  export interface MessagingApprover {
33
- /** The app's id for the person (Slack: U0123ABC). */
36
+ /** The app's id for the person (Slack: U0123ABC; Telegram: 123456789). */
34
37
  user: string;
35
38
  /** Their name when they were added, for the console. */
36
39
  name?: string;
@@ -41,6 +44,11 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
41
44
  * Approve/Deny buttons in Slack, handled by the server itself at
42
45
  * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
43
46
  export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
47
+ /** A secret LoopEngine makes when it points a Telegram bot's webhook at
48
+ * POST /hooks/telegram/<agent>; Telegram sends it back with every update.
49
+ * Set, requests get Approve/Deny buttons and replies reach the agent;
50
+ * unset, messages carry only the decision link. */
51
+ export declare const NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR = "LOOPENGINE_NOTIFIER_TELEGRAM_WEBHOOK_SECRET";
44
52
  /** In the order the Messaging tab lists them: the messaging apps, then
45
53
  * the webhook. */
46
54
  export declare const NOTIFIER_CHANNELS: NotifierFileChannel[];
@@ -48,6 +48,13 @@ export const NOTIFIER_SECRET_VARS = {
48
48
  * Approve/Deny buttons in Slack, handled by the server itself at
49
49
  * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
50
50
  export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
51
+ /** A secret LoopEngine makes when it points a Telegram bot's webhook at
52
+ * POST /hooks/telegram/<agent>; Telegram sends it back with every update.
53
+ * Set, requests get Approve/Deny buttons and replies reach the agent;
54
+ * unset, messages carry only the decision link. */
55
+ export const NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR = 'LOOPENGINE_NOTIFIER_TELEGRAM_WEBHOOK_SECRET';
56
+ /** The apps whose people can talk to the agent there (core/messaging/). */
57
+ const TWO_WAY = ['slack', 'telegram'];
51
58
  /** In the order the Messaging tab lists them: the messaging apps, then
52
59
  * the webhook. */
53
60
  export const NOTIFIER_CHANNELS = ['slack', 'telegram', 'lark', 'email', 'webhook'];
@@ -83,17 +90,30 @@ export function validateNotifierFile(input) {
83
90
  if (raw.conversations !== undefined) {
84
91
  if (typeof raw.conversations !== 'boolean')
85
92
  throw new NotifierFileError('conversations must be true or false');
86
- if (channel === 'slack')
93
+ if (TWO_WAY.includes(channel))
87
94
  file.conversations = raw.conversations;
88
95
  }
89
- if (raw.approvers !== undefined && channel === 'slack') {
96
+ if (raw.jobs !== undefined) {
97
+ if (typeof raw.jobs !== 'boolean')
98
+ throw new NotifierFileError('jobs must be true or false');
99
+ if (TWO_WAY.includes(channel) && raw.jobs)
100
+ file.jobs = true;
101
+ }
102
+ if (raw.approvers !== undefined && TWO_WAY.includes(channel)) {
90
103
  if (!Array.isArray(raw.approvers))
91
104
  throw new NotifierFileError('approvers must be a list');
92
105
  const approvers = [];
93
106
  for (const a of raw.approvers) {
107
+ // Telegram ids are numbers; YAML may read one back as a number.
94
108
  const entry = a;
95
- if (!entry || typeof entry.user !== 'string' || !/^[A-Z0-9]{2,32}$/.test(entry.user))
109
+ if (entry && channel === 'telegram' && typeof entry.user === 'number')
110
+ entry.user = String(entry.user);
111
+ if (channel === 'slack' && (!entry || typeof entry.user !== 'string' || !/^[A-Z0-9]{2,32}$/.test(entry.user)))
96
112
  throw new NotifierFileError('each approver needs a Slack user id, like U0123ABC');
113
+ if (channel === 'telegram' && (!entry || typeof entry.user !== 'string' || !/^[0-9]{1,20}$/.test(entry.user)))
114
+ throw new NotifierFileError('each approver needs a Telegram user id, like 123456789');
115
+ if (!entry || typeof entry.user !== 'string')
116
+ continue;
97
117
  if (approvers.some((x) => x.user === entry.user))
98
118
  continue;
99
119
  approvers.push({ user: entry.user, ...(typeof entry.name === 'string' && entry.name.trim() ? { name: entry.name.trim().slice(0, 80) } : {}) });
@@ -287,9 +307,24 @@ export function notifierConfigFromFile(file, env, agentName = '') {
287
307
  // No card buttons: nothing built in handles Lark's card callbacks yet,
288
308
  // so a console-configured Lark notifier relies on the decision link.
289
309
  return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
290
- case 'telegram':
291
- // Links to the decision page, like Lark without buttons.
292
- return { config: { channel: 'telegram', config: { botToken: secret, chatId: file.chatId, publicUrl: file.publicUrl }, events: file.events } };
310
+ case 'telegram': {
311
+ // Buttons once the bot's webhook points at /hooks/telegram/<agent>
312
+ // (the secret is made then); otherwise the decision link does the job.
313
+ const webhook = Boolean(env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR));
314
+ return {
315
+ config: {
316
+ channel: 'telegram',
317
+ config: {
318
+ botToken: secret,
319
+ chatId: file.chatId,
320
+ publicUrl: file.publicUrl,
321
+ buttons: webhook,
322
+ replyHint: webhook && file.conversations !== false && process.env.LOOPENGINE_PLAN !== 'free',
323
+ },
324
+ events: file.events,
325
+ },
326
+ };
327
+ }
293
328
  case 'email':
294
329
  // Links to the decision page, like Lark without buttons; the secret
295
330
  // is the provider's API key.
@@ -1,5 +1,6 @@
1
1
  import type { ToolSchema } from '#core/agent-config.js';
2
2
  import type { WorkflowParameter } from '#core/workflows.js';
3
+ import type { TriggerSender } from '#core/workflow-triggers.js';
3
4
  import type { ModelCall } from '#core/run-agent.js';
4
5
  import type { StarterTemplate, TemplateApp } from './templates.js';
5
6
  export type Decision = 'allow' | 'ask' | 'deny';
@@ -50,7 +51,8 @@ export interface WorkflowPlan {
50
51
  id: string;
51
52
  summary: string;
52
53
  trigger: {
53
- kind: 'schedule' | 'on_request';
54
+ /** webhook: an outside service's events start runs (see `webhook`). */
55
+ kind: 'schedule' | 'on_request' | 'webhook';
54
56
  description: string;
55
57
  /** schedule only: "1h", "24h", "7d". */
56
58
  every?: string;
@@ -80,8 +82,13 @@ export interface WorkflowPlan {
80
82
  /** Values a run is given, filled into $name in the start message and
81
83
  * steps (core/workflows.ts). Empty when every run is the same. */
82
84
  parameters: Record<string, WorkflowParameter>;
83
- /** Whether each scheduled run's result is sent to the person through
84
- * the agent's messaging app, and why (shown on the review screen). */
85
+ /** A webhook that starts runs: set when the trigger is "webhook", and
86
+ * also when a scheduled workflow should run on an event too. Made on
87
+ * Create, turned off until the person adds the sender's signing secret. */
88
+ webhook?: PlannedWebhook;
89
+ /** Whether each scheduled or webhook-started run's result is sent to
90
+ * the person through the agent's messaging app, and why (shown on the
91
+ * review screen). */
85
92
  notify: {
86
93
  on: boolean;
87
94
  reason: string;
@@ -104,6 +111,25 @@ export interface WorkflowPlan {
104
111
  * use (filled in on Create, from mcpServers). */
105
112
  apps?: TemplateApp[];
106
113
  }
114
+ export interface PlannedWebhook {
115
+ /** Whose events: the sender whose signature it checks. "custom" is the
116
+ * person's own app or any other service that signs the body. */
117
+ sender: TriggerSender;
118
+ /** "Stripe: new disputes". */
119
+ name: string;
120
+ /** custom only: the header the signature comes in, when known. */
121
+ header?: string;
122
+ /** Only these events start a run: a dot path into the payload and its values. */
123
+ filter?: {
124
+ path: string;
125
+ values: string[];
126
+ };
127
+ /** Workflow parameter → dot path into the payload. */
128
+ mapping?: Record<string, string>;
129
+ /** What to choose in the sender's own webhook settings, in plain words:
130
+ * "In Stripe, listen for charge.dispute.created." */
131
+ setup?: string;
132
+ }
107
133
  export interface WorkflowPlannerTools {
108
134
  /** Every agent: its tools, installed abilities, rules and messaging. */
109
135
  listAgents(): Promise<unknown>;
@@ -125,13 +151,20 @@ export interface WorkflowPlannerTools {
125
151
  /** A server's tools, when it can be listed without a key (or with one already set). */
126
152
  mcpServerTools?(id: string): Promise<unknown>;
127
153
  }
128
- export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: a schedule (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1) or on_request (started from Chat or a message). startMessage is what the agent receives when it starts.\n6. Notify (scheduled workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
154
+ export declare const WORKFLOW_PLANNER_PROMPT = "You turn a workflow a person describes into a plan for a LoopEngine agent.\n\nLoopEngine agents call tools. Tools come from abilities (installable packages: public ones on npm, or the workspace's own private ones) or are built in. Each tool has a permission: allow (runs on its own), ask (waits for a person to approve, edit or deny it) or deny. When nobody is in Chat, questions and approvals go to the agent's messaging app (Slack, Telegram, Lark or email) and the run waits, for days if needed, then carries on.\n\nHow to plan:\n1. Look before you plan: list_agents (what exists, and what each agent's tools and rules are), list_workspace_abilities, search_public_abilities for each kind of step, ability_details for any ability you might use (its exact tool names and the settings it needs), and list_connections.\n2. Split the workflow into steps, in order. For each step:\n - something to fetch or do: kind \"tool\", with the exact tool name and where it comes from. Pick the source in this order:\n a. A tool the target agent already has (from list_agents): use it, nothing to install.\n b. An ability: a workspace ability (list_workspace_abilities) or a public one (search_public_abilities, e.g. \"firecrawl\" or \"web search\" for searching and reading the web). Set \"ability\" to its install spec (the npm name, or a workspace ability's spec) and use the exact tool name from ability_details.\n c. Only when no ability can do it, and search_mcp_servers is available: an app's MCP server. Search for the app (e.g. \"linear\", \"stripe\"), pick the app's own server (official), else a saved or verified one, then call mcp_server_tools. Set \"mcp\" to the server's id, and \"tool\" to its exact tool name when mcp_server_tools lists them (leave \"tool\" empty when it needs a key first, and say in \"detail\" what the step does). It's set up as a private ability when the workflow is created, which picks its tools and their permissions, so add no rules for MCP tools.\n - the person chooses or answers (\"ask me which\", \"let me pick\"): kind \"ask\", tool \"system_ask_user\" (built in, no ability). Say what options are offered. It reaches them through the agent's messaging app by itself, so don't add a separate step to post to Slack or another chat first, and don't install a chat ability just to ask.\n - the person reviews or approves before something happens (\"let me review it\", \"before it's sent\"): kind \"approval\", on the tool that does that thing; its rule becomes \"ask\".\n - the agent's own work (writing, summarising, choosing): kind \"other\".\n - nothing found can do it: set \"missing\" to what's needed, and add it to gaps.\n3. Rules: every tool the workflow uses gets one. Reading and searching: allow. Anything a person should see first (sending, publishing, paying, creating something others see): ask. Never set deny unless asked.\n4. Skill: the steps in order, as numbered instructions the agent follows, naming the tools and saying exactly when to stop and ask. Write it so the agent keeps going after each answer or decision.\n5. Trigger: how a run starts. startMessage is what the agent receives when it starts.\n - schedule: a time or interval (\"every Monday at 9\" is every \"7d\", at \"09:00\", weekday 1).\n - webhook: when something happens in another service that can call a URL (\"when a Stripe dispute comes in\", \"when a GitHub issue is opened\", \"when a Shopify order is paid\", \"when our app sends a new signup\"). Fill in \"webhook\" (below). Prefer it over polling on a schedule whenever the service sends webhooks.\n - on_request: started by a person, from Chat, a message, the Run button or an app's API call.\n A workflow that runs on a schedule and on an event as well: trigger schedule, and fill in \"webhook\" too.\n webhook: sender (\"stripe\", \"github\", \"shopify\", \"standard\" for Standard Webhooks senders such as Clerk, Resend or Svix, else \"custom\" for the person's own app or any other service); a short name (\"Stripe: new disputes\"); setup: one sentence on what to pick in the sender's webhook settings; filter: which events start a run; mapping: each parameter from a dot path in the payload. What senders send:\n - Stripe: one endpoint can get many event types, so filter path \"type\", values the event names (charge.dispute.created, checkout.session.completed, invoice.payment_failed, customer.subscription.deleted). The object is at data.object: data.object.id, data.object.customer, data.object.amount, data.object.charge.\n - GitHub: the event type is chosen in GitHub's settings (Issues, Pull requests, Pushes), so say it in setup; filter path \"action\", values such as opened, closed, created. Fields: issue.number, issue.title, pull_request.number, repository.full_name, sender.login.\n - Shopify: one webhook per topic (orders/paid, orders/create, customers/create), chosen in Shopify, so say it in setup and use no filter. The body is the object itself: id, name (the order number, like #1001), email, total_price, financial_status.\n - standard: filter path \"type\" (user.created, email.bounced); fields under data (data.id, data.email_addresses).\n - custom: whatever the person's app sends; say in setup what to post (JSON with these fields) and use the paths you described. Set \"header\" when the person names the signature header.\n Every required parameter should come from the payload through mapping; parameters are the only way event details reach the run.\n6. Notify (scheduled and webhook workflows): whether each run's result is sent to the person through the agent's messaging app. true when the result is for them and no step already delivers it (\"tell me\", \"send me\", \"let me know\", a summary or findings that no step posts or emails). false when a step already sends it (posting to a channel, emailing a report): it would arrive twice. false when the work itself is the outcome (answering emails, filing tasks), where a message every run is noise; questions and approvals reach them either way. Say why in one short sentence.\n7. Parameters: if the job is about something that changes from run to run (an order, a ticket, an alert, a customer, a date range), declare it as a parameter: a short snake_case name, a one-line description, and required when a run makes no sense without it. Write $name wherever its value goes, in startMessage and in the skill (\"Check the refund request for order $order_number.\"). Apps and people fill them in when they start a run. A scheduled job that finds its own work (\"check the inbox every 10 minutes\", \"last week's sales\") needs none. Don't make fixed settings, such as the repository or the channel, parameters: write them into the skill.\n8. Target: an existing agent only when it already has (or can install) the tools, none of the workflow's rules would change a decision it already has, and the same people would approve. Otherwise a new agent, with a short lowercase-hyphenated name and a system prompt saying who it is and what it's for. Give the reason either way.\n9. keys: the setting names its abilities need that list_connections or the agents don't show as set (from ability_details settings). Not for MCP servers: their keys are asked for on the plan.\n\nThen call save_plan once, with the whole plan. Don't ask the person questions: if something is unclear, plan the most reasonable version and note the assumption in gaps.";
129
155
  export declare function workflowPlannerTools(withMcp?: boolean): ToolSchema[];
130
156
  /** lowercase-hyphenated, 2–40 characters. */
131
157
  export declare function slugify(value: string, fallback?: string): string;
132
158
  /** A plan from the model's save_plan input, or undefined when it lacks a
133
159
  * name, steps, a skill or a new-agent prompt. Fills in what can be derived. */
134
160
  export declare function validateWorkflowPlan(raw: unknown): WorkflowPlan | undefined;
161
+ /** A plan's webhook, tidied: a known sender, a filter with values, and a
162
+ * mapping only for parameters the workflow has. Undefined without a
163
+ * sender. Also used on a plan edited before Create. */
164
+ export declare function planWebhook(raw: unknown, parameters: Record<string, WorkflowParameter>): PlannedWebhook | undefined;
165
+ /** What the person should know about a planned webhook: required
166
+ * parameters nothing in the payload fills. */
167
+ export declare function webhookNotes(webhook: PlannedWebhook, parameters: Record<string, WorkflowParameter>): string[];
135
168
  /** A plan's notify choice; off when the planner gave none. */
136
169
  export declare function planNotify(raw: unknown): WorkflowPlan['notify'];
137
170
  /** A plan's parameters, tidied: names made snake_case (or dropped), a