loopengine 0.1.95 → 0.1.98

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 (68) hide show
  1. package/README.md +21 -2
  2. package/dist/bin/ability-manager.d.ts +4 -0
  3. package/dist/bin/ability-manager.js +34 -2
  4. package/dist/bin/cli.d.ts +1 -1
  5. package/dist/bin/cli.js +3 -0
  6. package/dist/core/ability-details.d.ts +31 -0
  7. package/dist/core/ability-details.js +140 -0
  8. package/dist/core/ability-sources.d.ts +59 -0
  9. package/dist/core/ability-sources.js +312 -0
  10. package/dist/core/agent-config.d.ts +21 -1
  11. package/dist/core/discover-agents.js +11 -1
  12. package/dist/core/http-notifier.d.ts +7 -1
  13. package/dist/core/http-notifier.js +68 -9
  14. package/dist/core/http-notify-triggers/email.d.ts +2 -2
  15. package/dist/core/http-notify-triggers/email.js +2 -4
  16. package/dist/core/http-notify-triggers/lark.d.ts +2 -2
  17. package/dist/core/http-notify-triggers/lark.js +2 -4
  18. package/dist/core/http-notify-triggers/slack.d.ts +2 -2
  19. package/dist/core/http-notify-triggers/slack.js +2 -4
  20. package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
  21. package/dist/core/http-notify-triggers/telegram.js +71 -0
  22. package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
  23. package/dist/core/http-notify-triggers/webhook.js +2 -4
  24. package/dist/core/model-calls/meta-model-call.d.ts +15 -0
  25. package/dist/core/model-calls/meta-model-call.js +48 -0
  26. package/dist/core/model-calls/openai-model-call.d.ts +5 -0
  27. package/dist/core/model-calls/openai-model-call.js +47 -1
  28. package/dist/core/model-calls/xai-model-call.d.ts +15 -0
  29. package/dist/core/model-calls/xai-model-call.js +45 -0
  30. package/dist/core/notifier-file.d.ts +40 -6
  31. package/dist/core/notifier-file.js +119 -25
  32. package/dist/core/system-agents/workflow-planner.d.ts +100 -0
  33. package/dist/core/system-agents/workflow-planner.js +275 -0
  34. package/dist/core/workflows.d.ts +26 -0
  35. package/dist/core/workflows.js +39 -0
  36. package/dist/core/workspace-abilities.d.ts +31 -0
  37. package/dist/core/workspace-abilities.js +110 -0
  38. package/dist/index.d.ts +18 -4
  39. package/dist/index.js +17 -3
  40. package/dist/web/ability-details-admin.d.ts +17 -0
  41. package/dist/web/ability-details-admin.js +45 -0
  42. package/dist/web/ability-sources-admin.d.ts +33 -0
  43. package/dist/web/ability-sources-admin.js +65 -0
  44. package/dist/web/agent-file-admin.d.ts +1 -1
  45. package/dist/web/agent-file-admin.js +3 -0
  46. package/dist/web/agents-config-page.js +600 -220
  47. package/dist/web/agents-list-page.js +49 -20
  48. package/dist/web/console-shell.js +244 -3
  49. package/dist/web/dev-ui-styles.js +12 -0
  50. package/dist/web/global-config-page.js +348 -95
  51. package/dist/web/global-config.d.ts +44 -1
  52. package/dist/web/global-config.js +162 -2
  53. package/dist/web/interview-admin.js +2 -2
  54. package/dist/web/messaging-icons.d.ts +2 -0
  55. package/dist/web/messaging-icons.js +7 -0
  56. package/dist/web/notifier-admin.d.ts +33 -15
  57. package/dist/web/notifier-admin.js +109 -65
  58. package/dist/web/provider-icons.d.ts +2 -0
  59. package/dist/web/provider-icons.js +10 -0
  60. package/dist/web/templates-admin.d.ts +3 -0
  61. package/dist/web/templates-admin.js +5 -1
  62. package/dist/web/workflow-admin.d.ts +124 -0
  63. package/dist/web/workflow-admin.js +308 -0
  64. package/dist/web/workflow-page.d.ts +1 -0
  65. package/dist/web/workflow-page.js +226 -0
  66. package/dist/web/workspace-abilities-admin.d.ts +24 -0
  67. package/dist/web/workspace-abilities-admin.js +118 -0
  68. package/package.json +13 -1
@@ -0,0 +1,71 @@
1
+ // The sending half of a Telegram-backed DurableApprover/DurableQuestionHandler,
2
+ // so AgentConfig.httpNotifier (core/http-notifier.ts) can construct one
3
+ // for `channel: 'telegram'`, the way it does for Slack and Lark (read
4
+ // ./lark.ts first; this mirrors it).
5
+ //
6
+ // Posts through the Bot API's sendMessage (core.telegram.org/bots/api)
7
+ // to one chat: a group, a channel the bot can post in, or a person who
8
+ // has started the bot. Like a console-configured Lark notifier, each
9
+ // request carries a link to the decision page rather than buttons:
10
+ // inline-keyboard callbacks would need a webhook this server receives
11
+ // and verifies, which nothing here sets up yet. Plain text, no
12
+ // parse_mode, so tool names and arguments never need escaping.
13
+ import { randomUUID } from 'node:crypto';
14
+ import { decidePath } from '../approval-links.js';
15
+ import { publicBaseUrl } from './webhook.js';
16
+ // Telegram's limit for one message's text.
17
+ const MAX_TEXT = 4096;
18
+ async function sendTelegramMessage(botToken, chatId, text) {
19
+ try {
20
+ const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
21
+ method: 'POST',
22
+ headers: { 'content-type': 'application/json' },
23
+ body: JSON.stringify({ chat_id: chatId, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text, disable_web_page_preview: true }),
24
+ });
25
+ const body = (await res.json().catch(() => ({})));
26
+ return body.ok ? { ok: true, status: res.status } : { ok: false, status: res.status, error: body.description || `HTTP ${res.status}` };
27
+ }
28
+ catch (err) {
29
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
30
+ }
31
+ }
32
+ /** The Messaging tab's Send test. */
33
+ export function sendTestTelegramMessage(botToken, chatId, text) {
34
+ return sendTelegramMessage(botToken, chatId, text);
35
+ }
36
+ export class TelegramNotifier {
37
+ botToken;
38
+ chatId;
39
+ publicUrl;
40
+ /** `publicUrl`: same meaning as SlackNotifier's — each message links to
41
+ * the decision page when the server's address is known. */
42
+ constructor(options) {
43
+ this.botToken = options.botToken;
44
+ this.chatId = options.chatId;
45
+ this.publicUrl = options.publicUrl;
46
+ }
47
+ decideLine(pendingId, kind) {
48
+ const base = publicBaseUrl(this.publicUrl);
49
+ return base ? `\n\n${kind === 'approval' ? 'Approve or deny' : 'Answer'}: ${base}${decidePath(pendingId, kind)}` : '';
50
+ }
51
+ send(text, label) {
52
+ void sendTelegramMessage(this.botToken, this.chatId, text).then((result) => {
53
+ if (!result.ok)
54
+ console.error(`[loopengine] TelegramNotifier: send message failed for ${label}:`, result.error);
55
+ });
56
+ }
57
+ requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
58
+ this.send(`Approval requested\n\ntool: ${tool}\nargs: ${JSON.stringify(args)}\nscope: ${scope.tenant}/${scope.environment}/${scope.agent}\nreason: ${reason}${this.decideLine(pendingId, 'approval')}`, `pendingId '${pendingId}'`);
59
+ return { pendingId };
60
+ }
61
+ notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
62
+ const choices = options?.length ? `\n\nOptions: ${options.join(' / ')}` : '';
63
+ this.send(`Question from ${agent}\n\n${question}${choices}${sessionId ? `\n(session ${sessionId})` : ''}${this.decideLine(pendingId, 'question')}`, `pendingId '${pendingId}'`);
64
+ return { pendingId };
65
+ }
66
+ /** A plain announcement (run started/finished, a scheduled task's
67
+ * result), with nothing to decide. */
68
+ postMessage(text) {
69
+ this.send(text, 'an announcement');
70
+ }
71
+ }
@@ -22,10 +22,10 @@ export declare class WebhookNotifier implements DurableApprover, DurableQuestion
22
22
  retryDelaysMs?: number[];
23
23
  publicUrl?: string;
24
24
  });
25
- requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
25
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
26
26
  pendingId: string;
27
27
  };
28
- notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
28
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
29
29
  pendingId: string;
30
30
  };
31
31
  }
@@ -104,8 +104,7 @@ export class WebhookNotifier {
104
104
  this.retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS;
105
105
  this.publicUrl = options.publicUrl;
106
106
  }
107
- requestDurableApproval(tool, args, scope, reason) {
108
- const pendingId = randomUUID();
107
+ requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
109
108
  const requestedAt = new Date().toISOString();
110
109
  const resolvePath = `/pending-approvals/${pendingId}/resolve`;
111
110
  const decide = decidePath(pendingId, 'approval');
@@ -119,8 +118,7 @@ export class WebhookNotifier {
119
118
  void deliver(this.webhookUrl, body, { 'X-Actauth-Signature': sign(body, this.signingSecret) }, { agent: scope.agent, kind: 'approval', pendingId }, this.retryDelaysMs);
120
119
  return { pendingId };
121
120
  }
122
- notifyPendingQuestion(question, options, agent, sessionId) {
123
- const pendingId = randomUUID();
121
+ notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
124
122
  const requestedAt = new Date().toISOString();
125
123
  const answerPath = `/pending-questions/${pendingId}/answer`;
126
124
  const decide = decidePath(pendingId, 'question');
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
+ export interface MetaModelCallOptions {
4
+ /** Defaults to the META_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to Meta's own Model API — override to point at a proxy/mirror. */
11
+ baseURL?: string;
12
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
13
+ client?: OpenAI;
14
+ }
15
+ export declare function createMetaModelCall(options: MetaModelCallOptions): ModelCall;
@@ -0,0 +1,48 @@
1
+ // An eighth real ModelCall implementation, for Meta's Muse models
2
+ // (muse-spark-1.3 and later) through the Meta Model API. Its
3
+ // /chat/completions endpoint is OpenAI-compatible, function calling
4
+ // included, so this reuses openai-model-call.ts's translation verbatim via
5
+ // the real `openai` SDK client pointed at https://api.meta.ai/v1, same
6
+ // pattern deepseek/kimi/glm/gemini-model-call.ts established.
7
+ //
8
+ // META_API_KEY is the name LiteLLM uses; Meta's own examples read a
9
+ // generic MODEL_API_KEY, too vague next to the other providers' keys.
10
+ // Uses `max_completion_tokens`: Meta accepts `max_tokens` only as a
11
+ // deprecated alias. The Model API was a US-only public preview when this
12
+ // was written.
13
+ import OpenAI from 'openai';
14
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
15
+ export function createMetaModelCall(options) {
16
+ if (!options.client && !options.apiKey && !process.env.META_API_KEY) {
17
+ // Fails here, with a Meta-specific message, rather than letting the
18
+ // OpenAI SDK's own constructor validation reject it — that error says
19
+ // "set OPENAI_API_KEY", which would send someone who never touched
20
+ // that variable chasing the wrong fix.
21
+ throw new Error('createMetaModelCall: no apiKey given and META_API_KEY is not set.');
22
+ }
23
+ const client = options.client ??
24
+ new OpenAI({
25
+ // Explicitly null, not undefined, if neither is set — see
26
+ // kimi-model-call.ts's comment for why this matters even
27
+ // though the guard above already rules out reaching here with no
28
+ // key at all.
29
+ apiKey: options.apiKey ?? process.env.META_API_KEY ?? null,
30
+ baseURL: options.baseURL ?? 'https://api.meta.ai/v1',
31
+ });
32
+ const maxTokens = options.maxTokens ?? 4096;
33
+ return async (messages, system, tools) => {
34
+ const response = await client.chat.completions.create({
35
+ model: options.model,
36
+ max_completion_tokens: maxTokens,
37
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
38
+ tools: tools.length
39
+ ? tools.map((t) => ({
40
+ type: 'function',
41
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
42
+ }))
43
+ : undefined,
44
+ });
45
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
46
+ return { ...toModelResponse(response.choices[0]), usage };
47
+ };
48
+ }
@@ -22,6 +22,11 @@ export interface OpenAIModelCallOptions {
22
22
  /** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
23
23
  client?: OpenAI;
24
24
  }
25
+ /** A tool call's arguments. Some OpenAI-compatible models now and then
26
+ * add something after the JSON object (a second copy, a stray character):
27
+ * the first complete object is used then. Arguments cut off part-way still
28
+ * throw, so the caller can ask again. */
29
+ export declare function parseToolArguments(raw: string): Record<string, unknown>;
25
30
  export declare function toMessageParams(message: Message): ChatMessage[];
26
31
  export declare function toStopReason(finishReason: string): string;
27
32
  export declare function toModelResponse(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): ModelResponse;
@@ -25,6 +25,52 @@
25
25
  // limit field name, which is only decided at the request-building call
26
26
  // site below, not in these shared translation functions.
27
27
  import OpenAI from 'openai';
28
+ /** A tool call's arguments. Some OpenAI-compatible models now and then
29
+ * add something after the JSON object (a second copy, a stray character):
30
+ * the first complete object is used then. Arguments cut off part-way still
31
+ * throw, so the caller can ask again. */
32
+ export function parseToolArguments(raw) {
33
+ const text = (raw ?? '').trim();
34
+ if (!text)
35
+ return {};
36
+ try {
37
+ return JSON.parse(text);
38
+ }
39
+ catch (err) {
40
+ const end = firstObjectEnd(text);
41
+ if (end > 0 && end < text.length)
42
+ return JSON.parse(text.slice(0, end));
43
+ throw err;
44
+ }
45
+ }
46
+ /** Where the first top-level JSON object in `text` ends (after its closing
47
+ * brace), or -1 when it doesn't close. */
48
+ function firstObjectEnd(text) {
49
+ if (text[0] !== '{')
50
+ return -1;
51
+ let depth = 0;
52
+ let inString = false;
53
+ let escaped = false;
54
+ for (let i = 0; i < text.length; i++) {
55
+ const c = text[i];
56
+ if (inString) {
57
+ if (escaped)
58
+ escaped = false;
59
+ else if (c === '\\')
60
+ escaped = true;
61
+ else if (c === '"')
62
+ inString = false;
63
+ continue;
64
+ }
65
+ if (c === '"')
66
+ inString = true;
67
+ else if (c === '{')
68
+ depth++;
69
+ else if (c === '}' && --depth === 0)
70
+ return i + 1;
71
+ }
72
+ return -1;
73
+ }
28
74
  export function toMessageParams(message) {
29
75
  if (typeof message.content === 'string') {
30
76
  return [{ role: message.role === 'assistant' ? 'assistant' : 'user', content: message.content }];
@@ -101,7 +147,7 @@ export function toModelResponse(choice) {
101
147
  type: 'tool_use',
102
148
  id: call.id,
103
149
  name: call.function.name,
104
- input: JSON.parse(call.function.arguments),
150
+ input: parseToolArguments(call.function.arguments),
105
151
  });
106
152
  }
107
153
  return { stop_reason: toStopReason(choice.finish_reason), content };
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from '#core/run-agent.js';
3
+ export interface XaiModelCallOptions {
4
+ /** Defaults to the XAI_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to xAI's own API — override to point at a proxy/mirror. */
11
+ baseURL?: string;
12
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
13
+ client?: OpenAI;
14
+ }
15
+ export declare function createXaiModelCall(options: XaiModelCallOptions): ModelCall;
@@ -0,0 +1,45 @@
1
+ // A seventh real ModelCall implementation. xAI's API (Grok models) is
2
+ // OpenAI-compatible: its own docs (docs.x.ai) point the OpenAI SDK at
3
+ // https://api.x.ai/v1 with an xAI key, same messages/tools/tool_choice
4
+ // request shape and tool_calls response shape. So this reuses
5
+ // openai-model-call.ts's translation verbatim via the real `openai` SDK
6
+ // client, same pattern deepseek/kimi/glm/gemini-model-call.ts established.
7
+ //
8
+ // Uses `max_completion_tokens`: xAI documents `max_tokens` as deprecated,
9
+ // as OpenAI does.
10
+ import OpenAI from 'openai';
11
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
12
+ export function createXaiModelCall(options) {
13
+ if (!options.client && !options.apiKey && !process.env.XAI_API_KEY) {
14
+ // Fails here, with a xAI-specific message, rather than letting the
15
+ // OpenAI SDK's own constructor validation reject it — that error says
16
+ // "set OPENAI_API_KEY", which would send someone who never touched
17
+ // that variable chasing the wrong fix.
18
+ throw new Error('createXaiModelCall: no apiKey given and XAI_API_KEY is not set.');
19
+ }
20
+ const client = options.client ??
21
+ new OpenAI({
22
+ // Explicitly null, not undefined, if neither is set — see
23
+ // kimi-model-call.ts's comment for why this matters even
24
+ // though the guard above already rules out reaching here with no
25
+ // key at all.
26
+ apiKey: options.apiKey ?? process.env.XAI_API_KEY ?? null,
27
+ baseURL: options.baseURL ?? 'https://api.x.ai/v1',
28
+ });
29
+ const maxTokens = options.maxTokens ?? 4096;
30
+ return async (messages, system, tools) => {
31
+ const response = await client.chat.completions.create({
32
+ model: options.model,
33
+ max_completion_tokens: maxTokens,
34
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
35
+ tools: tools.length
36
+ ? tools.map((t) => ({
37
+ type: 'function',
38
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
39
+ }))
40
+ : undefined,
41
+ });
42
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
43
+ return { ...toModelResponse(response.choices[0]), usage };
44
+ };
45
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
2
2
  import { type EmailProvider } from '#core/email-senders.js';
3
- export type NotifierFileChannel = 'webhook' | 'slack' | 'lark' | 'email';
3
+ export type NotifierFileChannel = 'webhook' | 'telegram' | 'slack' | 'lark' | 'email';
4
4
  export interface NotifierFile {
5
5
  channel: NotifierFileChannel;
6
6
  events: HttpNotifierEvent[];
@@ -8,7 +8,8 @@ export interface NotifierFile {
8
8
  url?: string;
9
9
  /** slack: channel to post into. */
10
10
  channelId?: string;
11
- /** lark: app id and chat to post into. */
11
+ /** lark: app id and chat to post into. telegram: the chat to post
12
+ * into (a numeric id like -1001234567890, or @channelname). */
12
13
  appId?: string;
13
14
  chatId?: string;
14
15
  /** email: who receives requests (comma-separated), who they come from
@@ -40,6 +41,12 @@ export declare const NOTIFIER_SECRET_VARS: Record<NotifierFileChannel, string>;
40
41
  * Approve/Deny buttons in Slack, handled by the server itself at
41
42
  * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
42
43
  export declare const NOTIFIER_SLACK_SIGNING_SECRET_VAR = "LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET";
44
+ /** In the order the Messaging tab lists them: the messaging apps, then
45
+ * the webhook. */
46
+ export declare const NOTIFIER_CHANNELS: NotifierFileChannel[];
47
+ /** The apps that take turns as primary. The webhook isn't one: it always
48
+ * sends once set up. */
49
+ export declare const PRIMARY_CHANNELS: NotifierFileChannel[];
43
50
  export declare class NotifierFileError extends Error {
44
51
  }
45
52
  /** The older single-connection file, still read when messaging.yml is absent. */
@@ -47,11 +54,38 @@ export declare function notifierFilePath(dir: string): string;
47
54
  export declare function messagingFilePath(dir: string): string;
48
55
  /** Throws NotifierFileError naming the first problem. */
49
56
  export declare function validateNotifierFile(input: unknown): NotifierFile;
50
- /** The agent's connection: messaging.yml's first, else the older
51
- * notifier.yml's; undefined when it has neither. */
57
+ export interface MessagingConnections {
58
+ /** At most one per app, in the order saved. */
59
+ connections: NotifierFile[];
60
+ /** The messaging app in use, if any (never the webhook). */
61
+ primary?: NotifierFileChannel;
62
+ }
63
+ /** Every saved connection and which messaging app is primary:
64
+ * messaging.yml's, else the older notifier.yml's one connection. */
65
+ export declare function readMessagingConnections(dir: string): MessagingConnections;
66
+ /** The connections that send: the webhook when one is set up, then the
67
+ * primary messaging app. */
68
+ export declare function readActiveConnections(dir: string): NotifierFile[];
69
+ /** The agent's main connection: the primary messaging app, else the
70
+ * webhook; undefined when neither is set up. It's the one the console
71
+ * reports, and the one Slack's replies and approvers go by. Use
72
+ * readActiveConnections for everything that sends. */
52
73
  export declare function readNotifierFile(dir: string): NotifierFile | undefined;
53
- /** Writes messaging.yml (and drops an older notifier.yml it replaces). */
54
- export declare function writeNotifierFile(dir: string, input: unknown): NotifierFile;
74
+ /** Saves one app's connection, replacing that app's earlier one and
75
+ * keeping the others. The first messaging app saved becomes primary;
76
+ * `makePrimary` makes this one primary too (a webhook never is: it
77
+ * always sends). Writes messaging.yml (and drops an older notifier.yml
78
+ * it replaces). */
79
+ export declare function writeNotifierFile(dir: string, input: unknown, options?: {
80
+ makePrimary?: boolean;
81
+ }): NotifierFile;
82
+ /** Makes a saved connection the one in use. */
83
+ export declare function setPrimaryConnection(dir: string, channel: string): void;
84
+ /** Removes one app's connection. Removing the primary leaves no
85
+ * messaging app primary, rather than quietly moving requests to another
86
+ * one. */
87
+ export declare function removeMessagingConnection(dir: string, channel: string): void;
88
+ /** Removes every connection. */
55
89
  export declare function removeNotifierFile(dir: string): void;
56
90
  /** The HttpNotifierConfig a connection describes, with its secret read
57
91
  * from `env` — or which secret is missing, so nothing is sent unsigned
@@ -4,21 +4,31 @@
4
4
  // in code. Only consulted when the agent's own AgentConfig.httpNotifier
5
5
  // is unset; code always wins.
6
6
  //
7
- // The file holds a list of connections, though only the first is used
8
- // for now:
7
+ // The file holds at most one connection per app, set up independently
8
+ // from the tab's own section for each. A webhook always sends once it's
9
+ // set up. Of the messaging apps (Telegram, Slack, Lark, email), only the
10
+ // primary one does: requests, announcements and Slack's replies go
11
+ // through it, and the others stay saved but idle until made primary. So
12
+ // a request can go to the webhook and the primary app both, under one
13
+ // request id.
9
14
  //
15
+ // primary: slack
10
16
  // connections:
11
17
  // - app: slack
12
18
  // channelId: C0123456
13
19
  // events: [approval, question]
14
20
  // conversations: true
21
+ // - app: telegram
22
+ // chatId: "-1001234567890"
23
+ // events: [approval, question]
15
24
  //
16
- // An older agents/<name>/notifier.yml (one connection, `channel:` for
17
- // `app:`) is still read; saving from the console replaces it with
18
- // messaging.yml.
25
+ // A file without `primary:` (written before there was one) uses its
26
+ // first messaging app; `primary:` left empty means none is. An older
27
+ // agents/<name>/notifier.yml (one connection, `channel:` for `app:`) is
28
+ // still read; saving from the console replaces it with messaging.yml.
19
29
  //
20
30
  // The file holds only non-secret settings. The one secret each channel
21
- // needs (a webhook signing secret, a Slack bot token, a Lark app secret, an email provider's API key)
31
+ // needs (a webhook signing secret, a Slack or Telegram bot token, a Lark app secret, an email provider's API key)
22
32
  // lives in the agent's own env (agents/<name>/.env by default, through
23
33
  // core/secret-store.ts) under a fixed name, so it's never written into a
24
34
  // file that might get committed.
@@ -29,6 +39,7 @@ import { EMAIL_PROVIDERS, createProviderSendEmail, parseAddress, parseRecipients
29
39
  /** The env var each channel's secret is read from, per agent. */
30
40
  export const NOTIFIER_SECRET_VARS = {
31
41
  webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
42
+ telegram: 'LOOPENGINE_NOTIFIER_TELEGRAM_BOT_TOKEN',
32
43
  slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
33
44
  lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
34
45
  email: 'LOOPENGINE_NOTIFIER_EMAIL_API_KEY',
@@ -37,7 +48,14 @@ export const NOTIFIER_SECRET_VARS = {
37
48
  * Approve/Deny buttons in Slack, handled by the server itself at
38
49
  * POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
39
50
  export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
40
- const CHANNELS = ['webhook', 'slack', 'lark', 'email'];
51
+ /** In the order the Messaging tab lists them: the messaging apps, then
52
+ * the webhook. */
53
+ export const NOTIFIER_CHANNELS = ['slack', 'telegram', 'lark', 'email', 'webhook'];
54
+ /** The apps that take turns as primary. The webhook isn't one: it always
55
+ * sends once set up. */
56
+ export const PRIMARY_CHANNELS = ['telegram', 'slack', 'lark', 'email'];
57
+ const canBePrimary = (channel) => PRIMARY_CHANNELS.includes(channel);
58
+ const CHANNELS = NOTIFIER_CHANNELS;
41
59
  const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
42
60
  export class NotifierFileError extends Error {
43
61
  }
@@ -101,6 +119,14 @@ export function validateNotifierFile(input) {
101
119
  throw new NotifierFileError('channelId is required for slack');
102
120
  file.channelId = channelId;
103
121
  }
122
+ else if (channel === 'telegram') {
123
+ // Strings even when numeric: YAML would read -1001234567890 as a
124
+ // number, so it's quoted when written and accepted either way.
125
+ const chatId = typeof raw.chatId === 'number' ? String(raw.chatId) : str('chatId');
126
+ if (!chatId || !/^(-?[0-9]{1,20}|@[A-Za-z0-9_]{4,64})$/.test(chatId))
127
+ throw new NotifierFileError('chatId must be a Telegram chat id, like -1001234567890, or @channelname');
128
+ file.chatId = chatId;
129
+ }
104
130
  else if (channel === 'lark') {
105
131
  const appId = str('appId');
106
132
  const chatId = str('chatId');
@@ -126,38 +152,103 @@ export function validateNotifierFile(input) {
126
152
  }
127
153
  return file;
128
154
  }
129
- /** The agent's connection: messaging.yml's first, else the older
130
- * notifier.yml's; undefined when it has neither. */
131
- export function readNotifierFile(dir) {
155
+ /** Every saved connection and which messaging app is primary:
156
+ * messaging.yml's, else the older notifier.yml's one connection. */
157
+ export function readMessagingConnections(dir) {
132
158
  const messagingPath = messagingFilePath(dir);
133
159
  if (existsSync(messagingPath)) {
134
160
  const parsed = parseYaml(readFileSync(messagingPath, 'utf8'));
135
161
  if (parsed?.connections !== undefined && !Array.isArray(parsed.connections))
136
162
  throw new NotifierFileError('connections must be a list');
137
- const first = parsed?.connections?.[0];
138
- if (first === undefined)
139
- return undefined;
140
- if (!first || typeof first !== 'object')
141
- throw new NotifierFileError('each connection must be an object');
142
- const { app, ...rest } = first;
143
- return validateNotifierFile({ channel: app, ...rest });
163
+ const connections = [];
164
+ for (const entry of parsed?.connections ?? []) {
165
+ if (!entry || typeof entry !== 'object')
166
+ throw new NotifierFileError('each connection must be an object');
167
+ const { app, ...rest } = entry;
168
+ const file = validateNotifierFile({ channel: app, ...rest });
169
+ if (!connections.some((c) => c.channel === file.channel))
170
+ connections.push(file);
171
+ }
172
+ // No `primary:` at all is a file from before there was one: its first
173
+ // messaging app was the one used, so it still is.
174
+ const hasPrimaryKey = parsed !== null && typeof parsed === 'object' && 'primary' in parsed;
175
+ const named = hasPrimaryKey ? parsed.primary : connections.find((c) => canBePrimary(c.channel))?.channel;
176
+ const primary = canBePrimary(named) ? connections.find((c) => c.channel === named)?.channel : undefined;
177
+ return primary ? { connections, primary } : { connections };
144
178
  }
145
179
  const path = notifierFilePath(dir);
146
180
  if (!existsSync(path))
147
- return undefined;
148
- return validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
181
+ return { connections: [] };
182
+ const file = validateNotifierFile(parseYaml(readFileSync(path, 'utf8')));
183
+ return canBePrimary(file.channel) ? { connections: [file], primary: file.channel } : { connections: [file] };
149
184
  }
150
- /** Writes messaging.yml (and drops an older notifier.yml it replaces). */
151
- export function writeNotifierFile(dir, input) {
152
- const file = validateNotifierFile(input);
153
- const { channel, ...rest } = file;
185
+ /** The connections that send: the webhook when one is set up, then the
186
+ * primary messaging app. */
187
+ export function readActiveConnections(dir) {
188
+ const { connections, primary } = readMessagingConnections(dir);
189
+ const webhook = connections.find((c) => c.channel === 'webhook');
190
+ const app = connections.find((c) => c.channel === primary);
191
+ return [...(webhook ? [webhook] : []), ...(app ? [app] : [])];
192
+ }
193
+ /** The agent's main connection: the primary messaging app, else the
194
+ * webhook; undefined when neither is set up. It's the one the console
195
+ * reports, and the one Slack's replies and approvers go by. Use
196
+ * readActiveConnections for everything that sends. */
197
+ export function readNotifierFile(dir) {
198
+ const { connections, primary } = readMessagingConnections(dir);
199
+ return connections.find((c) => c.channel === primary) ?? connections.find((c) => c.channel === 'webhook');
200
+ }
201
+ function writeMessagingConnections(dir, { connections, primary }) {
202
+ if (!connections.length) {
203
+ removeNotifierFile(dir);
204
+ return;
205
+ }
154
206
  const header = '# Where this agent sends approval requests and questions when it is called over HTTP,\n' +
155
207
  '# and whether it answers replies there. Edited from the console (Messaging tab).\n' +
156
- `# The secret lives in this agent's env as ${NOTIFIER_SECRET_VARS[channel]}.\n`;
157
- writeFileSync(messagingFilePath(dir), header + stringifyYaml({ connections: [{ app: channel, ...rest }] }));
208
+ '# A webhook always sends; of the messaging apps, only the primary one does.\n' +
209
+ '# Each secret lives in this agent\'s env: ' + connections.map((c) => NOTIFIER_SECRET_VARS[c.channel]).join(', ') + '.\n';
210
+ const body = { primary: primary ?? null, connections: connections.map(({ channel, ...rest }) => ({ app: channel, ...rest })) };
211
+ writeFileSync(messagingFilePath(dir), header + stringifyYaml(body));
158
212
  rmSync(notifierFilePath(dir), { force: true });
213
+ }
214
+ /** Saves one app's connection, replacing that app's earlier one and
215
+ * keeping the others. The first messaging app saved becomes primary;
216
+ * `makePrimary` makes this one primary too (a webhook never is: it
217
+ * always sends). Writes messaging.yml (and drops an older notifier.yml
218
+ * it replaces). */
219
+ export function writeNotifierFile(dir, input, options = {}) {
220
+ const file = validateNotifierFile(input);
221
+ const current = readMessagingConnections(dir);
222
+ const index = current.connections.findIndex((c) => c.channel === file.channel);
223
+ const connections = [...current.connections];
224
+ if (index === -1)
225
+ connections.push(file);
226
+ else
227
+ connections[index] = file;
228
+ const primary = canBePrimary(file.channel) && (options.makePrimary || !current.primary) ? file.channel : current.primary;
229
+ writeMessagingConnections(dir, { connections, primary });
159
230
  return file;
160
231
  }
232
+ /** Makes a saved connection the one in use. */
233
+ export function setPrimaryConnection(dir, channel) {
234
+ if (!canBePrimary(channel))
235
+ throw new NotifierFileError(`Only a messaging app (${PRIMARY_CHANNELS.join(', ')}) can be primary; a webhook always sends once set up`);
236
+ const current = readMessagingConnections(dir);
237
+ if (!current.connections.some((c) => c.channel === channel))
238
+ throw new NotifierFileError(`Set up ${channel} before making it primary`);
239
+ writeMessagingConnections(dir, { ...current, primary: channel });
240
+ }
241
+ /** Removes one app's connection. Removing the primary leaves no
242
+ * messaging app primary, rather than quietly moving requests to another
243
+ * one. */
244
+ export function removeMessagingConnection(dir, channel) {
245
+ const current = readMessagingConnections(dir);
246
+ writeMessagingConnections(dir, {
247
+ connections: current.connections.filter((c) => c.channel !== channel),
248
+ primary: current.primary === channel ? undefined : current.primary,
249
+ });
250
+ }
251
+ /** Removes every connection. */
161
252
  export function removeNotifierFile(dir) {
162
253
  rmSync(messagingFilePath(dir), { force: true });
163
254
  rmSync(notifierFilePath(dir), { force: true });
@@ -196,6 +287,9 @@ export function notifierConfigFromFile(file, env, agentName = '') {
196
287
  // No card buttons: nothing built in handles Lark's card callbacks yet,
197
288
  // so a console-configured Lark notifier relies on the decision link.
198
289
  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 } };
199
293
  case 'email':
200
294
  // Links to the decision page, like Lark without buttons; the secret
201
295
  // is the provider's API key.