loopengine 0.1.94 → 0.1.97
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.
- package/README.md +21 -2
- package/dist/bin/ability-manager.d.ts +4 -0
- package/dist/bin/ability-manager.js +34 -2
- package/dist/bin/cli.d.ts +1 -1
- package/dist/bin/cli.js +3 -0
- package/dist/core/ability-details.d.ts +31 -0
- package/dist/core/ability-details.js +140 -0
- package/dist/core/ability-sources.d.ts +59 -0
- package/dist/core/ability-sources.js +312 -0
- package/dist/core/agent-config.d.ts +21 -1
- package/dist/core/discover-agents.js +11 -1
- package/dist/core/http-notifier.d.ts +7 -1
- package/dist/core/http-notifier.js +68 -9
- package/dist/core/http-notify-triggers/email.d.ts +2 -2
- package/dist/core/http-notify-triggers/email.js +2 -4
- package/dist/core/http-notify-triggers/lark.d.ts +2 -2
- package/dist/core/http-notify-triggers/lark.js +2 -4
- package/dist/core/http-notify-triggers/slack.d.ts +2 -2
- package/dist/core/http-notify-triggers/slack.js +2 -4
- package/dist/core/http-notify-triggers/telegram.d.ts +28 -0
- package/dist/core/http-notify-triggers/telegram.js +71 -0
- package/dist/core/http-notify-triggers/webhook.d.ts +2 -2
- package/dist/core/http-notify-triggers/webhook.js +2 -4
- package/dist/core/model-calls/meta-model-call.d.ts +15 -0
- package/dist/core/model-calls/meta-model-call.js +48 -0
- package/dist/core/model-calls/openai-model-call.d.ts +5 -0
- package/dist/core/model-calls/openai-model-call.js +47 -1
- package/dist/core/model-calls/xai-model-call.d.ts +15 -0
- package/dist/core/model-calls/xai-model-call.js +45 -0
- package/dist/core/notifier-file.d.ts +40 -6
- package/dist/core/notifier-file.js +119 -25
- package/dist/core/system-agents/workflow-planner.d.ts +100 -0
- package/dist/core/system-agents/workflow-planner.js +278 -0
- package/dist/core/workflows.d.ts +26 -0
- package/dist/core/workflows.js +39 -0
- package/dist/core/workspace-abilities.d.ts +31 -0
- package/dist/core/workspace-abilities.js +110 -0
- package/dist/index.d.ts +18 -4
- package/dist/index.js +17 -3
- package/dist/web/ability-details-admin.d.ts +17 -0
- package/dist/web/ability-details-admin.js +45 -0
- package/dist/web/ability-sources-admin.d.ts +33 -0
- package/dist/web/ability-sources-admin.js +65 -0
- package/dist/web/agent-file-admin.d.ts +1 -1
- package/dist/web/agent-file-admin.js +3 -0
- package/dist/web/agents-config-page.js +608 -224
- package/dist/web/agents-list-page.js +49 -20
- package/dist/web/console-shell.js +248 -7
- package/dist/web/dev-ui-styles.js +13 -1
- package/dist/web/global-config-page.js +403 -81
- package/dist/web/global-config.d.ts +44 -1
- package/dist/web/global-config.js +162 -2
- package/dist/web/interview-admin.js +2 -2
- package/dist/web/messaging-icons.d.ts +2 -0
- package/dist/web/messaging-icons.js +7 -0
- package/dist/web/notifier-admin.d.ts +33 -15
- package/dist/web/notifier-admin.js +109 -65
- package/dist/web/provider-icons.d.ts +2 -0
- package/dist/web/provider-icons.js +10 -0
- package/dist/web/templates-admin.d.ts +3 -0
- package/dist/web/templates-admin.js +5 -1
- package/dist/web/workflow-admin.d.ts +124 -0
- package/dist/web/workflow-admin.js +308 -0
- package/dist/web/workflow-page.d.ts +1 -0
- package/dist/web/workflow-page.js +226 -0
- package/dist/web/workspace-abilities-admin.d.ts +24 -0
- package/dist/web/workspace-abilities-admin.js +118 -0
- 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
|
|
52
|
-
|
|
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
|
|
81
|
+
let files;
|
|
71
82
|
try {
|
|
72
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
+
}
|