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,312 @@
1
+ // Private ability sources: where a project's own, unpublished abilities
2
+ // come from, next to the public ones on npm (packages tagged
3
+ // loopengine-ability). Set in the console's Settings and kept in
4
+ // ability-sources.yml in the project root (or the file
5
+ // LOOPENGINE_ABILITY_SOURCES_FILE names):
6
+ //
7
+ // sources:
8
+ // - id: github-loopengine-co
9
+ // type: github # a GitHub org or user; each ability is a repo
10
+ // owner: loopengine-co # with loopengine.ability.json at its root
11
+ // - id: npm-everymarket
12
+ // type: npm # a private registry, for one scope
13
+ // registry: https://npm.pkg.github.com
14
+ // scope: "@everymarket"
15
+ // - id: folder-abilities
16
+ // type: folder # a local folder of ability folders
17
+ // path: ../loopengine-abilities
18
+ //
19
+ // A source's token (a GitHub token that can read the repos, or the
20
+ // registry's auth token) is never in the file: it lives in the project
21
+ // env as LOOPENGINE_ABILITY_SOURCE_<ID>_TOKEN, saved like a model key.
22
+ // It's handed to `npm pack` only while an ability is fetched (see
23
+ // abilityFetchAuth), so an installed ability's recorded spec
24
+ // (github:owner/repo, @scope/name, file:/path) never carries it, and an
25
+ // upgrade fetches the same way.
26
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
27
+ import { homedir, tmpdir } from 'node:os';
28
+ import { basename, join, resolve } from 'node:path';
29
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
30
+ import { workspaceAbilityAt } from './workspace-abilities.js';
31
+ export class AbilitySourceError extends Error {
32
+ }
33
+ export const ABILITY_SOURCES_FILE_ENV = 'LOOPENGINE_ABILITY_SOURCES_FILE';
34
+ export function abilitySourcesPath() {
35
+ return resolve(process.cwd(), process.env[ABILITY_SOURCES_FILE_ENV]?.trim() || 'ability-sources.yml');
36
+ }
37
+ /** The env var a source's token lives in. */
38
+ export function sourceTokenVar(id) {
39
+ return `LOOPENGINE_ABILITY_SOURCE_${id.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_TOKEN`;
40
+ }
41
+ function sourceToken(source) {
42
+ return source.type === 'folder' ? undefined : process.env[sourceTokenVar(source.id)]?.trim() || undefined;
43
+ }
44
+ function slug(value) {
45
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
46
+ }
47
+ /** Throws AbilitySourceError naming the first problem. An id is made up
48
+ * from the source when none is given. */
49
+ export function validateAbilitySource(input) {
50
+ if (!input || typeof input !== 'object')
51
+ throw new AbilitySourceError('a source must be an object');
52
+ const raw = input;
53
+ const str = (key) => (typeof raw[key] === 'string' && raw[key].trim() ? raw[key].trim() : undefined);
54
+ const givenId = str('id');
55
+ if (givenId && !/^[a-z0-9][a-z0-9-]{0,39}$/.test(givenId))
56
+ throw new AbilitySourceError('id must be lowercase letters, digits and hyphens');
57
+ switch (raw.type) {
58
+ case 'github': {
59
+ const owner = str('owner')?.replace(/^https?:\/\/github\.com\//, '').replace(/\/+$/, '');
60
+ if (!owner || !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner))
61
+ throw new AbilitySourceError('owner must be a GitHub org or user name, like loopengine-co');
62
+ return { id: givenId ?? slug(`github-${owner}`), type: 'github', owner };
63
+ }
64
+ case 'npm': {
65
+ const registry = str('registry')?.replace(/\/+$/, '');
66
+ if (!registry || !/^https?:\/\/[^/]+/.test(registry))
67
+ throw new AbilitySourceError('registry must be an http(s) URL, like https://npm.pkg.github.com');
68
+ const scope = str('scope');
69
+ // A scope, so only its packages come from this registry; public
70
+ // abilities keep coming from npm.
71
+ if (!scope || !/^@[a-z0-9][a-z0-9._-]*$/.test(scope))
72
+ throw new AbilitySourceError('scope must be an npm scope, like @everymarket');
73
+ return { id: givenId ?? slug(`npm-${scope}`), type: 'npm', registry, scope };
74
+ }
75
+ case 'folder': {
76
+ const path = str('path');
77
+ if (!path)
78
+ throw new AbilitySourceError('path is required for a folder');
79
+ return { id: givenId ?? slug(`folder-${basename(path)}`), type: 'folder', path };
80
+ }
81
+ default:
82
+ throw new AbilitySourceError('type must be github, npm or folder');
83
+ }
84
+ }
85
+ export function readAbilitySources() {
86
+ const path = abilitySourcesPath();
87
+ if (!existsSync(path))
88
+ return [];
89
+ const parsed = parseYaml(readFileSync(path, 'utf8'));
90
+ if (parsed?.sources !== undefined && !Array.isArray(parsed.sources))
91
+ throw new AbilitySourceError('sources must be a list');
92
+ const sources = [];
93
+ for (const entry of parsed?.sources ?? []) {
94
+ const source = validateAbilitySource(entry);
95
+ if (!sources.some((s) => s.id === source.id))
96
+ sources.push(source);
97
+ }
98
+ return sources;
99
+ }
100
+ function writeAbilitySources(sources) {
101
+ const path = abilitySourcesPath();
102
+ if (!sources.length) {
103
+ rmSync(path, { force: true });
104
+ return;
105
+ }
106
+ const header = '# Private ability sources, edited from the console (Settings). Each source\'s\n' +
107
+ '# token lives in the project env as LOOPENGINE_ABILITY_SOURCE_<ID>_TOKEN.\n';
108
+ writeFileSync(path, header + stringifyYaml({ sources }));
109
+ }
110
+ /** Adds a source, or replaces the one with the same id. */
111
+ export function saveAbilitySource(input) {
112
+ const source = validateAbilitySource(input);
113
+ const sources = readAbilitySources();
114
+ const index = sources.findIndex((s) => s.id === source.id);
115
+ if (index === -1)
116
+ sources.push(source);
117
+ else
118
+ sources[index] = source;
119
+ writeAbilitySources(sources);
120
+ listCache.delete(source.id);
121
+ return source;
122
+ }
123
+ export function removeAbilitySource(id) {
124
+ writeAbilitySources(readAbilitySources().filter((s) => s.id !== id));
125
+ listCache.delete(id);
126
+ }
127
+ export function describeSource(source) {
128
+ if (source.type === 'github')
129
+ return `GitHub ${source.owner}`;
130
+ if (source.type === 'npm')
131
+ return `npm ${source.scope} (${new URL(source.registry).host})`;
132
+ return `Folder ${source.path}`;
133
+ }
134
+ // ---- Listing what a source offers ----
135
+ const LIST_TTL_MS = 5 * 60 * 1000;
136
+ const listCache = new Map();
137
+ /** The abilities a source offers. GitHub and npm listings are cached for
138
+ * five minutes per token; a folder is read fresh. Throws with a message
139
+ * fit for the console when the source can't be read. */
140
+ export async function listSourceAbilities(source, options = {}) {
141
+ if (source.type === 'folder')
142
+ return listFolder(source);
143
+ const token = sourceToken(source);
144
+ const key = JSON.stringify([source, token ?? '']);
145
+ const cached = listCache.get(source.id);
146
+ if (cached && cached.key === key && Date.now() - cached.at < LIST_TTL_MS)
147
+ return cached.abilities;
148
+ const doFetch = options.fetch ?? fetch;
149
+ const abilities = source.type === 'github' ? await listGitHub(source, token, doFetch) : await listNpm(source, token, doFetch);
150
+ listCache.set(source.id, { at: Date.now(), key, abilities });
151
+ return abilities;
152
+ }
153
+ function readAbilityDir(dir) {
154
+ if (!existsSync(join(dir, 'loopengine.ability.json')))
155
+ return undefined;
156
+ let pkg = {};
157
+ try {
158
+ pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
159
+ }
160
+ catch { }
161
+ return { name: pkg.name || basename(dir), description: pkg.description || '', version: pkg.version || '', spec: `file:${dir}` };
162
+ }
163
+ function listFolder(source) {
164
+ const root = resolve(process.cwd(), source.path);
165
+ if (!existsSync(root) || !statSync(root).isDirectory())
166
+ throw new Error(`${source.path} isn't a folder on this server`);
167
+ const own = readAbilityDir(root);
168
+ if (own)
169
+ return [own];
170
+ return readdirSync(root, { withFileTypes: true })
171
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && d.name !== 'node_modules')
172
+ .flatMap((d) => readAbilityDir(join(root, d.name)) ?? [])
173
+ .sort((a, b) => a.name.localeCompare(b.name));
174
+ }
175
+ async function githubJson(url, token, doFetch) {
176
+ const res = await doFetch(url, {
177
+ headers: { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'loopengine', ...(token ? { authorization: `Bearer ${token}` } : {}) },
178
+ signal: AbortSignal.timeout(15_000),
179
+ });
180
+ return { status: res.status, body: await res.json().catch(() => null) };
181
+ }
182
+ async function listGitHub(source, token, doFetch) {
183
+ const owner = source.owner;
184
+ // An org's repos; for a user, their own (private ones need a token for
185
+ // that user, through /user/repos).
186
+ let { status, body } = await githubJson(`https://api.github.com/orgs/${owner}/repos?per_page=100&type=all`, token, doFetch);
187
+ if (status === 404) {
188
+ ;
189
+ ({ status, body } = token
190
+ ? await githubJson('https://api.github.com/user/repos?per_page=100&affiliation=owner', token, doFetch)
191
+ : await githubJson(`https://api.github.com/users/${owner}/repos?per_page=100`, token, doFetch));
192
+ }
193
+ if (status === 401)
194
+ throw new Error('GitHub refused the token: check it and that it can read these repos');
195
+ if (status === 403)
196
+ throw new Error('GitHub refused the request (rate limit or token permissions); try again later or save a token');
197
+ if (status === 404)
198
+ throw new Error(`No GitHub org or user named ${owner}`);
199
+ if (status !== 200 || !Array.isArray(body))
200
+ throw new Error(`GitHub answered HTTP ${status}`);
201
+ const repos = body
202
+ .filter((r) => !r.archived && (r.owner?.login ?? owner).toLowerCase() === owner.toLowerCase());
203
+ const found = await Promise.all(repos.map(async (repo) => {
204
+ const manifest = await githubJson(`https://api.github.com/repos/${owner}/${repo.name}/contents/loopengine.ability.json`, token, doFetch);
205
+ if (manifest.status !== 200)
206
+ return undefined;
207
+ const pkgFile = await githubJson(`https://api.github.com/repos/${owner}/${repo.name}/contents/package.json`, token, doFetch);
208
+ let pkg = {};
209
+ const content = pkgFile.body?.content;
210
+ if (pkgFile.status === 200 && content) {
211
+ try {
212
+ pkg = JSON.parse(Buffer.from(content, 'base64').toString('utf8'));
213
+ }
214
+ catch { }
215
+ }
216
+ return { name: pkg.name || repo.name, description: pkg.description || repo.description || '', version: pkg.version || '', spec: `github:${owner}/${repo.name}` };
217
+ }));
218
+ return found.filter((a) => Boolean(a)).sort((a, b) => a.name.localeCompare(b.name));
219
+ }
220
+ async function listNpm(source, token, doFetch) {
221
+ const url = `${source.registry}/-/v1/search?text=${encodeURIComponent(`keywords:loopengine-ability ${source.scope}`)}&size=100`;
222
+ const res = await doFetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(15_000) });
223
+ if (res.status === 401 || res.status === 403)
224
+ throw new Error('The registry refused the token');
225
+ // GitHub Packages, for one, has no search; its abilities can still be
226
+ // installed by name.
227
+ if (!res.ok)
228
+ throw new Error(`This registry can't be searched (HTTP ${res.status}); install its abilities by name instead`);
229
+ const data = (await res.json());
230
+ return (data.objects ?? [])
231
+ .map((o) => o.package)
232
+ .filter((p) => p.name.startsWith(`${source.scope}/`))
233
+ .map((p) => ({ name: p.name, description: p.description ?? '', version: p.version, spec: p.name }));
234
+ }
235
+ // ---- Where an installed ability came from ----
236
+ /** Public (npm) or private (a source, a git repo or a local folder), from
237
+ * the spec it was installed with. */
238
+ export function abilityOrigin(spec, name, sources = safeReadSources()) {
239
+ const target = spec || name;
240
+ if (target.startsWith('file:') || target.startsWith('/') || target.startsWith('.')) {
241
+ const path = target.replace(/^file:/, '');
242
+ // The workspace's copy (core/workspace-abilities.ts): labelled by where
243
+ // that copy came from.
244
+ const copy = workspaceAbilityAt(path);
245
+ if (copy) {
246
+ if (copy.source.startsWith('upload:'))
247
+ return { kind: 'private', label: 'Workspace (uploaded)' };
248
+ const from = copy.source.startsWith('file:') ? undefined : abilityOrigin(copy.source, copy.name, sources);
249
+ return { kind: 'private', label: from && from.kind === 'private' ? `Workspace (${from.label})` : 'Workspace' };
250
+ }
251
+ const folder = sources.find((s) => s.type === 'folder' && resolve(process.cwd(), s.path) === resolve(path, '..'));
252
+ return { kind: 'private', label: folder ? describeSource(folder) : `Folder ${path}` };
253
+ }
254
+ const github = target.match(/^(?:github:|git\+ssh:\/\/git@github\.com\/|git\+https:\/\/github\.com\/|https:\/\/github\.com\/|git@github\.com:)([^/]+)\/([^/#.]+)/);
255
+ if (github)
256
+ return { kind: 'private', label: `GitHub ${github[1]}/${github[2]}` };
257
+ if (/^(git\+|git:|https?:)/.test(target))
258
+ return { kind: 'private', label: 'Git' };
259
+ const scope = target.match(/^(@[^/]+)\//)?.[1];
260
+ const npmSource = scope ? sources.find((s) => s.type === 'npm' && s.scope === scope) : undefined;
261
+ if (npmSource)
262
+ return { kind: 'private', label: describeSource(npmSource) };
263
+ return { kind: 'public', label: 'npm' };
264
+ }
265
+ function safeReadSources() {
266
+ try {
267
+ return readAbilitySources();
268
+ }
269
+ catch {
270
+ return [];
271
+ }
272
+ }
273
+ // ---- Fetching with a source's token ----
274
+ /** What `npm pack` needs to fetch from the private sources: GitHub tokens
275
+ * as git URL rewrites (only in the child's env, per owner), and a
276
+ * temporary npm config adding each private scope's registry and token to
277
+ * the user's own. `cleanup` removes that file. */
278
+ export function abilityFetchAuth(sources = safeReadSources()) {
279
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
280
+ const rewrites = [];
281
+ for (const source of sources) {
282
+ const token = sourceToken(source);
283
+ if (source.type !== 'github' || !token)
284
+ continue;
285
+ const authed = `https://x-access-token:${token}@github.com/${source.owner}/`;
286
+ for (const from of [`https://github.com/${source.owner}/`, `git@github.com:${source.owner}/`, `ssh://git@github.com/${source.owner}/`])
287
+ rewrites.push([`url.${authed}.insteadOf`, from]);
288
+ }
289
+ if (rewrites.length) {
290
+ const start = Number(process.env.GIT_CONFIG_COUNT) || 0;
291
+ rewrites.forEach(([key, value], i) => {
292
+ env[`GIT_CONFIG_KEY_${start + i}`] = key;
293
+ env[`GIT_CONFIG_VALUE_${start + i}`] = value;
294
+ });
295
+ env.GIT_CONFIG_COUNT = String(start + rewrites.length);
296
+ }
297
+ const npmLines = sources.flatMap((source) => {
298
+ if (source.type !== 'npm')
299
+ return [];
300
+ const token = sourceToken(source);
301
+ const url = new URL(source.registry);
302
+ return [`${source.scope}:registry=${source.registry}/`, ...(token ? [`//${url.host}${url.pathname.replace(/\/+$/, '')}/:_authToken=${token}`] : [])];
303
+ });
304
+ if (!npmLines.length)
305
+ return { args: [], env, cleanup: () => { } };
306
+ const dir = mkdtempSync(join(tmpdir(), 'loopengine-npmrc-'));
307
+ const userNpmrc = join(homedir(), '.npmrc');
308
+ const own = existsSync(userNpmrc) ? readFileSync(userNpmrc, 'utf8') + '\n' : '';
309
+ const file = join(dir, 'npmrc');
310
+ writeFileSync(file, own + npmLines.join('\n') + '\n', { mode: 0o600 });
311
+ return { args: ['--userconfig', file], env, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
312
+ }
@@ -219,6 +219,14 @@ export type HttpNotifierConfig = {
219
219
  buttons?: boolean;
220
220
  };
221
221
  events: HttpNotifierEvent[];
222
+ } | {
223
+ channel: 'telegram';
224
+ config: {
225
+ botToken: string;
226
+ chatId: string;
227
+ publicUrl?: string;
228
+ };
229
+ events: HttpNotifierEvent[];
222
230
  } | {
223
231
  channel: 'email';
224
232
  config: {
@@ -256,7 +264,7 @@ export type HttpNotifierConfig = {
256
264
  * export your own `createModelCall` for anything this can't express (a
257
265
  * canned/simulated `ModelCall` for a demo, a custom SDK client, a
258
266
  * provider this doesn't list). `model` is required for `openai`/
259
- * `deepseek`/`kimi`/`glm`/`gemini` (no safe hardcoded default — a
267
+ * `deepseek`/`kimi`/`glm`/`gemini`/`xai`/`meta` (no safe hardcoded default — a
260
268
  * flagship model name changes too often to bake one in) but optional for
261
269
  * `anthropic` (defaults to `'claude-sonnet-5'`), mirroring each factory's
262
270
  * own options exactly. */
@@ -300,6 +308,18 @@ export type AgentModelConfig = {
300
308
  apiKey?: string;
301
309
  maxTokens?: number;
302
310
  baseURL?: string;
311
+ } | {
312
+ provider: 'xai';
313
+ model: string;
314
+ apiKey?: string;
315
+ maxTokens?: number;
316
+ baseURL?: string;
317
+ } | {
318
+ provider: 'meta';
319
+ model: string;
320
+ apiKey?: string;
321
+ maxTokens?: number;
322
+ baseURL?: string;
303
323
  };
304
324
  export interface ToolSchema {
305
325
  name: string;
@@ -46,6 +46,8 @@ const PROVIDER_KEY_ENV = {
46
46
  kimi: 'MOONSHOT_API_KEY',
47
47
  glm: 'GLM_API_KEY',
48
48
  gemini: 'GEMINI_API_KEY',
49
+ xai: 'XAI_API_KEY',
50
+ meta: 'META_API_KEY',
49
51
  };
50
52
  export async function synthesizeCreateModelCall(model) {
51
53
  let build;
@@ -69,10 +71,18 @@ export async function synthesizeCreateModelCall(model) {
69
71
  const { createGlmModelCall } = await import('./model-calls/glm-model-call.js');
70
72
  build = () => createGlmModelCall(model);
71
73
  }
72
- else {
74
+ else if (model.provider === 'gemini') {
73
75
  const { createGeminiModelCall } = await import('./model-calls/gemini-model-call.js');
74
76
  build = () => createGeminiModelCall(model);
75
77
  }
78
+ else if (model.provider === 'xai') {
79
+ const { createXaiModelCall } = await import('./model-calls/xai-model-call.js');
80
+ build = () => createXaiModelCall(model);
81
+ }
82
+ else {
83
+ const { createMetaModelCall } = await import('./model-calls/meta-model-call.js');
84
+ build = () => createMetaModelCall(model);
85
+ }
76
86
  // Rebuilt when the key it was built with changes, so a key saved or
77
87
  // replaced on the console's Models page applies to the next call
78
88
  // without a restart. An explicit `apiKey` in the config never changes.
@@ -1,6 +1,6 @@
1
1
  import { type Approver } from 'actauth';
2
2
  import type { AgentConfig, HttpNotifierConfig, QuestionHandler } from './agent-config.js';
3
- import { type NotifierFile } from './notifier-file.js';
3
+ import { type NotifierFile, type NotifierFileChannel } from './notifier-file.js';
4
4
  interface ResolvedHttpNotifier {
5
5
  approver?: Approver;
6
6
  questionHandler?: QuestionHandler;
@@ -17,6 +17,12 @@ export declare function describeNotifier(config: AgentConfig): {
17
17
  file?: NotifierFile;
18
18
  missingSecret?: string;
19
19
  error?: string;
20
+ /** The saved connections that send (the webhook, then the primary
21
+ * messaging app), and which are held back by a missing secret. */
22
+ active?: Array<{
23
+ channel: NotifierFileChannel;
24
+ missingSecret?: string;
25
+ }>;
20
26
  };
21
27
  /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
22
28
  * its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
@@ -1,11 +1,12 @@
1
1
  import { WebhookNotifier, postLifecycleWebhook } from './http-notify-triggers/webhook.js';
2
2
  import { SlackNotifier } from './http-notify-triggers/slack.js';
3
3
  import { LarkNotifier } from './http-notify-triggers/lark.js';
4
+ import { TelegramNotifier } from './http-notify-triggers/telegram.js';
4
5
  import { EmailNotifier } from './http-notify-triggers/email.js';
5
6
  import { DatabaseApprover } from './http-notify-triggers/database.js';
6
7
  import { RedisQueueApprover } from './http-notify-triggers/redis.js';
7
8
  import { agentDirFor, createAgentEnv } from './agent-env.js';
8
- import { notifierConfigFromFile, readNotifierFile } from './notifier-file.js';
9
+ import { notifierConfigFromFile, readActiveConnections, readNotifierFile } from './notifier-file.js';
9
10
  import { recordThread } from './messaging/threads.js';
10
11
  const EMPTY = {};
11
12
  // Keyed by the AgentConfig object itself, not its httpNotifier field
@@ -48,8 +49,15 @@ export function describeNotifier(config) {
48
49
  }
49
50
  if (!file)
50
51
  return { source: 'none' };
51
- const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
52
- return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
52
+ const env = createAgentEnv(config.name, dir);
53
+ const active = readActiveConnections(dir).map((c) => {
54
+ const { missingSecret } = notifierConfigFromFile(c, env, config.name);
55
+ return missingSecret ? { channel: c.channel, missingSecret } : { channel: c.channel };
56
+ });
57
+ const { missingSecret } = notifierConfigFromFile(file, env, config.name);
58
+ // Sending when any active connection has its secret.
59
+ const sending = active.some((a) => !a.missingSecret);
60
+ return { source: sending ? 'file' : 'none', channel: file.channel, events: file.events, file, missingSecret, active };
53
61
  }
54
62
  /** AgentConfig.httpNotifier when the agent sets one in code; otherwise
55
63
  * its agents/<name>/messaging.yml (see core/notifier-file.ts), re-read on
@@ -65,20 +73,61 @@ export function resolveHttpNotifier(config) {
65
73
  cache.set(config, resolved);
66
74
  return resolved;
67
75
  }
76
+ // The webhook (when set up) and the primary messaging app, each built on
77
+ // its own and then combined; one held back by a missing secret is left
78
+ // out.
68
79
  function resolveFromFile(config) {
69
80
  const dir = agentDirFor(config);
70
- let file;
81
+ let files;
71
82
  try {
72
- file = readNotifierFile(dir);
83
+ files = readActiveConnections(dir);
73
84
  }
74
85
  catch (err) {
75
86
  console.error(`[loopengine] ignoring the messaging settings in agents/${config.name}/:`, err instanceof Error ? err.message : err);
76
87
  return EMPTY;
77
88
  }
78
- if (!file)
79
- return EMPTY;
80
- const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
81
- return httpNotifier ? buildResolved(httpNotifier, dir) : EMPTY;
89
+ const env = createAgentEnv(config.name, dir);
90
+ const resolved = files.flatMap((file) => {
91
+ const { config: httpNotifier } = notifierConfigFromFile(file, env, config.name);
92
+ return httpNotifier ? [buildResolved(httpNotifier, dir)] : [];
93
+ });
94
+ return combineResolved(resolved);
95
+ }
96
+ /** Several notifiers as one: each request goes to every one of them under
97
+ * the first one's request id, so a decision through any of them resolves
98
+ * the same request; run started/finished go to all. One notifier is
99
+ * returned as it is. */
100
+ function combineResolved(list) {
101
+ if (list.length <= 1)
102
+ return list[0] ?? EMPTY;
103
+ const approvers = list.map((r) => r.approver).filter((a) => Boolean(a));
104
+ const questioners = list.map((r) => r.questionHandler).filter((q) => Boolean(q));
105
+ const starts = list.map((r) => r.onRunStart).filter((f) => Boolean(f));
106
+ const finishes = list.map((r) => r.onRunFinish).filter((f) => Boolean(f));
107
+ const fanOutApprover = {
108
+ requestDurableApproval(tool, args, scope, reason) {
109
+ const { pendingId } = approvers[0].requestDurableApproval(tool, args, scope, reason);
110
+ for (const other of approvers.slice(1))
111
+ other.requestDurableApproval(tool, args, scope, reason, pendingId);
112
+ return { pendingId };
113
+ },
114
+ };
115
+ const fanOutQuestions = {
116
+ notifyPendingQuestion(question, options, agent, sessionId) {
117
+ const { pendingId } = questioners[0].notifyPendingQuestion(question, options, agent, sessionId);
118
+ for (const other of questioners.slice(1))
119
+ other.notifyPendingQuestion(question, options, agent, sessionId, pendingId);
120
+ return { pendingId };
121
+ },
122
+ };
123
+ return {
124
+ approver: approvers.length === 0 ? undefined : (approvers.length === 1 ? approvers[0] : fanOutApprover),
125
+ questionHandler: questioners.length === 0 ? undefined : (questioners.length === 1 ? questioners[0] : fanOutQuestions),
126
+ onRunStart: starts.length ? (context) => { for (const f of starts)
127
+ f(context); } : undefined,
128
+ onRunFinish: finishes.length ? (context) => { for (const f of finishes)
129
+ f(context); } : undefined,
130
+ };
82
131
  }
83
132
  /** `agentDir`: where a Slack request's thread is recorded, so replies in
84
133
  * it reach the agent (core/messaging/). */
@@ -128,6 +177,16 @@ function buildResolved(httpNotifier, agentDir) {
128
177
  };
129
178
  break;
130
179
  }
180
+ case 'telegram': {
181
+ const { botToken, chatId, publicUrl } = httpNotifier.config;
182
+ const telegram = new TelegramNotifier({ botToken, chatId, publicUrl });
183
+ return {
184
+ approver: events.has('approval') ? telegram : undefined,
185
+ questionHandler: events.has('question') ? telegram : undefined,
186
+ onRunStart: events.has('run_start') ? (context) => telegram.postMessage(formatChatLifecycleMessage('run_start', context)) : undefined,
187
+ onRunFinish: events.has('run_finish') ? (context) => telegram.postMessage(formatChatLifecycleMessage('run_finish', context)) : undefined,
188
+ };
189
+ }
131
190
  case 'email': {
132
191
  const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl } = httpNotifier.config;
133
192
  const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl });
@@ -37,10 +37,10 @@ export declare class EmailNotifier implements DurableApprover, DurableQuestionHa
37
37
  /** Decision-page mode: one link to decide (or answer) on this server,
38
38
  * or where to find the request when the server's address isn't known. */
39
39
  private decideHtml;
40
- requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
40
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
41
41
  pendingId: string;
42
42
  };
43
- notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
43
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
44
44
  pendingId: string;
45
45
  };
46
46
  /** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
@@ -108,8 +108,7 @@ export class EmailNotifier {
108
108
  return `<p>Open this agent's Messaging tab in the LoopEngine console to ${kind === 'approval' ? 'approve or deny it' : 'answer'}.</p>`;
109
109
  return `<p><a href="${base}${decidePath(pendingId, kind)}">${kind === 'approval' ? 'Review and approve or deny' : 'Answer'}</a></p>` + this.expiryNote();
110
110
  }
111
- requestDurableApproval(tool, args, scope, reason) {
112
- const pendingId = randomUUID();
111
+ requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
113
112
  let links;
114
113
  if (this.magic) {
115
114
  const { resolveBaseUrl, signingSecret } = this.magic;
@@ -132,8 +131,7 @@ export class EmailNotifier {
132
131
  });
133
132
  return { pendingId };
134
133
  }
135
- notifyPendingQuestion(question, options, agent, sessionId) {
136
- const pendingId = randomUUID();
134
+ notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
137
135
  let links;
138
136
  if (this.magic) {
139
137
  const { answerBaseUrl, signingSecret } = this.magic;
@@ -23,10 +23,10 @@ export declare class LarkNotifier implements DurableApprover, DurableQuestionHan
23
23
  buttons?: boolean;
24
24
  });
25
25
  private decideLine;
26
- requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
26
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
27
27
  pendingId: string;
28
28
  };
29
- notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
29
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
30
30
  pendingId: string;
31
31
  };
32
32
  /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
@@ -81,8 +81,7 @@ export class LarkNotifier {
81
81
  const base = publicBaseUrl(this.publicUrl);
82
82
  return base ? `\n[${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}](${base}${decidePath(pendingId, kind)})` : '';
83
83
  }
84
- requestDurableApproval(tool, args, scope, reason) {
85
- const pendingId = randomUUID();
84
+ requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
86
85
  void (async () => {
87
86
  try {
88
87
  const token = await getTenantAccessToken(this.appId, this.appSecret);
@@ -116,8 +115,7 @@ export class LarkNotifier {
116
115
  })();
117
116
  return { pendingId };
118
117
  }
119
- notifyPendingQuestion(question, options, agent, sessionId) {
120
- const pendingId = randomUUID();
118
+ notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
121
119
  const optionButtons = (options ?? []).map((option) => ({
122
120
  tag: 'button',
123
121
  text: { tag: 'plain_text', content: option },
@@ -36,10 +36,10 @@ export declare class SlackNotifier implements DurableApprover, DurableQuestionHa
36
36
  private replyHintBlocks;
37
37
  private threadRecorder;
38
38
  private decideLine;
39
- requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
39
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
40
40
  pendingId: string;
41
41
  };
42
- notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined): {
42
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
43
43
  pendingId: string;
44
44
  };
45
45
  /** Public, not private: core/http-notifier.ts's own onRunStart/onRunFinish
@@ -66,8 +66,7 @@ export class SlackNotifier {
66
66
  const base = publicBaseUrl(this.publicUrl);
67
67
  return base ? `\n<${base}${decidePath(pendingId, kind)}|${kind === 'approval' ? 'Open to approve or deny' : 'Open to answer'}>` : '';
68
68
  }
69
- requestDurableApproval(tool, args, scope, reason) {
70
- const pendingId = randomUUID();
69
+ requestDurableApproval(tool, args, scope, reason, pendingId = randomUUID()) {
71
70
  this.postMessage(`Approval requested: ${tool} on ${scope.tenant}/${scope.environment}/${scope.agent}`, [
72
71
  {
73
72
  type: 'section',
@@ -92,8 +91,7 @@ export class SlackNotifier {
92
91
  ], this.threadRecorder(pendingId, 'approval'));
93
92
  return { pendingId };
94
93
  }
95
- notifyPendingQuestion(question, options, agent, sessionId) {
96
- const pendingId = randomUUID();
94
+ notifyPendingQuestion(question, options, agent, sessionId, pendingId = randomUUID()) {
97
95
  // Slack rejects a block whose buttons share an action_id
98
96
  // (invalid_blocks), so each option gets its own: answer_option_<n>.
99
97
  const optionButtons = (options ?? []).map((option, i) => ({
@@ -0,0 +1,28 @@
1
+ import type { DurableApprover, Scope } from 'actauth';
2
+ import type { DurableQuestionHandler } from '../agent-config.js';
3
+ import { type DeliveryResult } from './webhook.js';
4
+ /** The Messaging tab's Send test. */
5
+ export declare function sendTestTelegramMessage(botToken: string, chatId: string, text: string): Promise<DeliveryResult>;
6
+ export declare class TelegramNotifier implements DurableApprover, DurableQuestionHandler {
7
+ private readonly botToken;
8
+ private readonly chatId;
9
+ private readonly publicUrl?;
10
+ /** `publicUrl`: same meaning as SlackNotifier's — each message links to
11
+ * the decision page when the server's address is known. */
12
+ constructor(options: {
13
+ botToken: string;
14
+ chatId: string;
15
+ publicUrl?: string;
16
+ });
17
+ private decideLine;
18
+ private send;
19
+ requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string, pendingId?: string): {
20
+ pendingId: string;
21
+ };
22
+ notifyPendingQuestion(question: string, options: string[] | undefined, agent: string, sessionId: string | undefined, pendingId?: string): {
23
+ pendingId: string;
24
+ };
25
+ /** A plain announcement (run started/finished, a scheduled task's
26
+ * result), with nothing to decide. */
27
+ postMessage(text: string): void;
28
+ }